diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 2817a8a414..1a673bf385 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -1,3 +1,12 @@ +"""Autogenerated input type of AcceptBusinessMemberInvitation""" +input AcceptBusinessMemberInvitationInput { + """The id of the invitation being accepted""" + invitationId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + """Autogenerated input type of AcceptTopicSuggestion""" input AcceptTopicSuggestionInput { """The Node ID of the repository.""" @@ -15,16 +24,8 @@ type AcceptTopicSuggestionPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The accepted topic. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `topic` will change from `Topic!` to `Topic`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - topic: Topic! + """The accepted topic.""" + topic: Topic } """ @@ -64,38 +65,14 @@ type AddCommentPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The edge from the subject's comment connection. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `commentEdge` will change from `IssueCommentEdge!` to `IssueCommentEdge`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - commentEdge: IssueCommentEdge! + """The edge from the subject's comment connection.""" + commentEdge: IssueCommentEdge - """ - The subject - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `subject` will change from `Node!` to `Node`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - subject: Node! + """The subject""" + subject: Node - """ - The edge from the subject's timeline connection. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `timelineEdge` will change from `IssueTimelineItemEdge!` to `IssueTimelineItemEdge`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - timelineEdge: IssueTimelineItemEdge! + """The edge from the subject's timeline connection.""" + timelineEdge: IssueTimelineItemEdge } """ @@ -132,30 +109,14 @@ input AddProjectCardInput { """Autogenerated return type of AddProjectCard""" type AddProjectCardPayload { - """ - The edge from the ProjectColumn's card connection. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `cardEdge` will change from `ProjectCardEdge!` to `ProjectCardEdge`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - cardEdge: ProjectCardEdge! + """The edge from the ProjectColumn's card connection.""" + cardEdge: ProjectCardEdge """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The ProjectColumn - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `projectColumn` will change from `Project!` to `ProjectColumn`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - projectColumn: Project! + """The ProjectColumn""" + projectColumn: ProjectColumn } """Autogenerated input type of AddProjectColumn""" @@ -175,27 +136,11 @@ type AddProjectColumnPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The edge from the project's column connection. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `columnEdge` will change from `ProjectColumnEdge!` to `ProjectColumnEdge`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - columnEdge: ProjectColumnEdge! + """The edge from the project's column connection.""" + columnEdge: ProjectColumnEdge - """ - The project - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `project` will change from `Project!` to `Project`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - project: Project! + """The project""" + project: Project } """Autogenerated input type of AddPullRequestReviewComment""" @@ -227,28 +172,11 @@ type AddPullRequestReviewCommentPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The newly created comment. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `comment` will change from `PullRequestReviewComment!` to `PullRequestReviewComment`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - comment: PullRequestReviewComment! + """The newly created comment.""" + comment: PullRequestReviewComment - """ - The edge from the review's comment connection. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `commentEdge` will change from - `PullRequestReviewCommentEdge!` to `PullRequestReviewCommentEdge`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - commentEdge: PullRequestReviewCommentEdge! + """The edge from the review's comment connection.""" + commentEdge: PullRequestReviewCommentEdge } """Autogenerated input type of AddPullRequestReview""" @@ -277,27 +205,11 @@ type AddPullRequestReviewPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The newly created pull request review. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `pullRequestReview` will change from `PullRequestReview!` to `PullRequestReview`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - pullRequestReview: PullRequestReview! + """The newly created pull request review.""" + pullRequestReview: PullRequestReview - """ - The edge from the pull request's review connection. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `reviewEdge` will change from `PullRequestReviewEdge!` to `PullRequestReviewEdge`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - reviewEdge: PullRequestReviewEdge! + """The edge from the pull request's review connection.""" + reviewEdge: PullRequestReviewEdge } """Autogenerated input type of AddReaction""" @@ -317,27 +229,11 @@ type AddReactionPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The reaction object. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `reaction` will change from `Reaction!` to `Reaction`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - reaction: Reaction! + """The reaction object.""" + reaction: Reaction - """ - The reactable subject. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `subject` will change from `Reactable!` to `Reactable`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - subject: Reactable! + """The reactable subject.""" + subject: Reactable } """Autogenerated input type of AddStar""" @@ -354,16 +250,8 @@ type AddStarPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The starrable. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `starrable` will change from `Starrable!` to `Starrable`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - starrable: Starrable! + """The starrable.""" + starrable: Starrable } """A GitHub App.""" @@ -763,6 +651,58 @@ type BranchProtectionRuleEdge { node: BranchProtectionRule } +"""Autogenerated input type of CancelBusinessAdminInvitation""" +input CancelBusinessAdminInvitationInput { + """The Node ID of the pending business admin invitation.""" + invitationId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated input type of CancelBusinessBillingManagerInvitation""" +input CancelBusinessBillingManagerInvitationInput { + """The Node ID of the pending business billing manager invitation.""" + invitationId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated input type of ChangeUserStatus""" +input ChangeUserStatusInput { + """ + The emoji to represent your status. Can either be a native Unicode emoji or an emoji name with colons, e.g., :grinning:. + """ + emoji: String + + """A short description of your current status.""" + message: String + + """ + The ID of the organization whose members will be allowed to see the status. If + omitted, the status will be publicly visible. + """ + organizationId: ID + + """ + Whether this status should indicate you are not fully available on GitHub, e.g., you are away. + """ + limitedAvailability: Boolean = false + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated return type of ChangeUserStatus""" +type ChangeUserStatusPayload { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + + """Your updated status.""" + status: UserStatus +} + """An object that can be closed""" interface Closable { """ @@ -796,21 +736,43 @@ type ClosedEvent implements Node & UniformResourceLocatable { url: URI! } +"""Autogenerated input type of ClosePullRequest""" +input ClosePullRequestInput { + """ID of the pull request to be closed.""" + pullRequestId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated return type of ClosePullRequest""" +type ClosePullRequestPayload { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + + """The pull request that was closed.""" + pullRequest: PullRequest +} + """The object which triggered a `ClosedEvent`.""" union Closer = Commit | PullRequest """The Code of Conduct for a repository""" -type CodeOfConduct { - """The body of the CoC""" +type CodeOfConduct implements Node { + """The body of the Code of Conduct""" body: String + id: ID! - """The key for the CoC""" + """The key for the Code of Conduct""" key: String! - """The formal name of the CoC""" + """The formal name of the Code of Conduct""" name: String! - """The path to the CoC""" + """The HTTP path for this Code of Conduct""" + resourcePath: URI + + """The HTTP URL for this Code of Conduct""" url: URI } @@ -1020,6 +982,31 @@ type Commit implements Node & GitObject & Subscribable & UniformResourceLocatabl """The number of deletions in this commit.""" deletions: Int! + """The deployments associated with a commit.""" + deployments( + """Environments to list deployments for""" + environments: [String!] + + """Ordering options for deployments returned from the connection.""" + orderBy: DeploymentOrder + + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + ): DeploymentConnection + """ The linear commit history starting from (and including) this commit, in the same order as `git log`. """ @@ -1369,6 +1356,64 @@ type CommitConnection { totalCount: Int! } +"""Ordering options for commit contribution connections.""" +input CommitContributionOrder { + """The field by which to order commit contributions.""" + field: CommitContributionOrderField! + + """The ordering direction.""" + direction: OrderDirection! +} + +"""Properties by which commit contribution connections can be ordered.""" +enum CommitContributionOrderField { + """Order commit contributions by when they were made.""" + OCCURRED_AT + + """Order commit contributions by how many commits they represent.""" + COMMIT_COUNT +} + +"""This aggregates commits made by a user within one repository.""" +type CommitContributionsByRepository { + """The commit contributions, each representing a day.""" + contributions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + + """ + Ordering options for commit contributions returned from the connection. + """ + orderBy: CommitContributionOrder + ): CreatedCommitContributionConnection! + + """The repository in which the commits were made.""" + repository: Repository! + + """ + The HTTP path for the user's commits to the repository in this time range. + """ + resourcePath: URI! + + """ + The HTTP URL for the user's commits to the repository in this time range. + """ + url: URI! +} + """An edge in a connection.""" type CommitEdge { """A cursor for use in pagination.""" @@ -1380,6 +1425,7 @@ type CommitEdge { """The connection type for Commit.""" type CommitHistoryConnection { + """A list of edges.""" edges: [CommitEdge] """A list of nodes.""" @@ -1420,6 +1466,34 @@ type ContentReference { reference: String! } +""" +Represents a contribution a user made on GitHub, such as opening an issue. +""" +interface Contribution { + """ + Whether this contribution is associated with a record you do not have access to. For + example, your own 'first issue' contribution may have been made on a repository you can no + longer access. + + """ + isRestricted: Boolean! + + """When this contribution was made.""" + occurredAt: DateTime! + + """The HTTP path for this contribution.""" + resourcePath: URI! + + """The HTTP URL for this contribution.""" + url: URI! + + """ + The user who made this contribution. + + """ + user: User! +} + """A calendar of contributions made on GitHub by a user.""" type ContributionCalendar { """ @@ -1485,13 +1559,39 @@ type ContributionCalendarWeek { firstDay: Date! } +"""Ordering options for contribution connections.""" +input ContributionOrder { + """The field by which to order contributions.""" + field: ContributionOrderField! + + """The ordering direction.""" + direction: OrderDirection! +} + +"""Properties by which contribution connections can be ordered.""" +enum ContributionOrderField { + """Order contributions by when they were made.""" + OCCURRED_AT +} + """ A contributions collection aggregates contributions such as opened issues and commits created by a user. """ type ContributionsCollection { + """Commit contributions made by the user, grouped by repository.""" + commitContributionsByRepository( + """How many repositories should be included.""" + maxRepositories: Int = 25 + ): [CommitContributionsByRepository!]! + """A calendar of this user's contributions on GitHub.""" contributionCalendar: ContributionCalendar! + """ + The years the user has been making contributions with the most recent year first. + """ + contributionYears: [Int!]! + """ Determine if this collection's time span ends in the current month. @@ -1507,6 +1607,50 @@ type ContributionsCollection { """The ending date and time of this collection.""" endedAt: DateTime! + """ + The first issue the user opened on GitHub. This will be null if that issue was + opened outside the collection's time range and ignoreTimeRange is false. If + the issue is not visible but the user has opted to show private contributions, + a RestrictedContribution will be returned. + """ + firstIssueContribution( + """ + If true, the first issue will be returned even if it was opened outside of the collection's time range. + """ + ignoreTimeRange: Boolean = false + ): CreatedIssueOrRestrictedContribution + + """ + The first pull request the user opened on GitHub. This will be null if that + pull request was opened outside the collection's time range and + ignoreTimeRange is not true. If the pull request is not visible but the user + has opted to show private contributions, a RestrictedContribution will be returned. + """ + firstPullRequestContribution( + """ + If true, the first pull request will be returned even if it was opened outside of the collection's time range. + """ + ignoreTimeRange: Boolean = false + ): CreatedPullRequestOrRestrictedContribution + + """ + The first repository the user created on GitHub. This will be null if that + first repository was created outside the collection's time range and + ignoreTimeRange is false. If the repository is not visible, then a + RestrictedContribution is returned. + """ + firstRepositoryContribution( + """ + If true, the first repository will be returned even if it was opened outside of the collection's time range. + """ + ignoreTimeRange: Boolean = false + ): CreatedRepositoryOrRestrictedContribution + + """ + Does the user have any more activity in the timeline that occurred prior to the collection's time range? + """ + hasActivityInThePast: Boolean! + """Determine if there are any contributions in this collection.""" hasAnyContributions: Boolean! @@ -1520,6 +1664,45 @@ type ContributionsCollection { """Whether or not the collector's time span is all within the same day.""" isSingleDay: Boolean! + """A list of issues the user opened.""" + issueContributions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + + """Should the user's first issue ever be excluded from the result.""" + excludeFirst: Boolean = false + + """Should the user's most commented issue be excluded from the result.""" + excludePopular: Boolean = false + + """Ordering options for contributions returned from the connection.""" + orderBy: ContributionOrder + ): CreatedIssueContributionConnection! + + """ + When the user signed up for GitHub. This will be null if that sign up date + falls outside the collection's time range and ignoreTimeRange is false. + """ + joinedGitHubContribution( + """ + If true, the contribution will be returned even if the user signed up outside of the collection's time range. + """ + ignoreTimeRange: Boolean = false + ): JoinedGitHubContribution + """ The date of the most recent restricted contribution the user made in this time period. Can only be non-null when the user has enabled private contribution counts. @@ -1534,15 +1717,117 @@ type ContributionsCollection { mostRecentCollectionWithActivity: ContributionsCollection """ - A count of contributions made by the user that the viewer cannot access. Only - non-zero when the user has chosen to share their private contribution counts. + Returns a different contributions collection from an earlier time range than this one + that does not have any contributions. + """ - restrictedContributionsCount: Int! + mostRecentCollectionWithoutActivity: ContributionsCollection - """The beginning date and time of this collection.""" - startedAt: DateTime! + """ + The issue the user opened on GitHub that received the most comments in the specified + time frame. + + """ + popularIssueContribution: CreatedIssueContribution - """How many commits were made by the user in this time span.""" + """ + The pull request the user opened on GitHub that received the most comments in the + specified time frame. + + """ + popularPullRequestContribution: CreatedPullRequestContribution + + """Pull request contributions made by the user.""" + pullRequestContributions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + + """ + Should the user's first pull request ever be excluded from the result. + """ + excludeFirst: Boolean = false + + """ + Should the user's most commented pull request be excluded from the result. + """ + excludePopular: Boolean = false + + """Ordering options for contributions returned from the connection.""" + orderBy: ContributionOrder + ): CreatedPullRequestContributionConnection! + + """Pull request review contributions made by the user.""" + pullRequestReviewContributions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + + """Ordering options for contributions returned from the connection.""" + orderBy: ContributionOrder + ): CreatedPullRequestReviewContributionConnection! + + """ + A list of repositories owned by the user that the user created in this time range. + """ + repositoryContributions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + + """Should the user's first repository ever be excluded from the result.""" + excludeFirst: Boolean = false + + """Ordering options for contributions returned from the connection.""" + orderBy: ContributionOrder + ): CreatedRepositoryContributionConnection! + + """ + A count of contributions made by the user that the viewer cannot access. Only + non-zero when the user has chosen to share their private contribution counts. + """ + restrictedContributionsCount: Int! + + """The beginning date and time of this collection.""" + startedAt: DateTime! + + """How many commits were made by the user in this time span.""" totalCommitContributions: Int! """How many issues the user opened.""" @@ -1708,6 +1993,310 @@ input CreateContentAttachmentInput { clientMutationId: String } +""" +Represents the contribution a user made by committing to a repository. +""" +type CreatedCommitContribution implements Contribution { + """ + How many commits were made on this day to this repository by the user. + """ + commitCount: Int! + + """ + Whether this contribution is associated with a record you do not have access to. For + example, your own 'first issue' contribution may have been made on a repository you can no + longer access. + + """ + isRestricted: Boolean! + + """When this contribution was made.""" + occurredAt: DateTime! + + """The repository the user made a commit in.""" + repository: Repository! + + """The HTTP path for this contribution.""" + resourcePath: URI! + + """The HTTP URL for this contribution.""" + url: URI! + + """ + The user who made this contribution. + + """ + user: User! +} + +"""The connection type for CreatedCommitContribution.""" +type CreatedCommitContributionConnection { + """A list of edges.""" + edges: [CreatedCommitContributionEdge] + + """A list of nodes.""" + nodes: [CreatedCommitContribution] + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """ + Identifies the total count of commits across days and repositories in the connection. + + """ + totalCount: Int! +} + +"""An edge in a connection.""" +type CreatedCommitContributionEdge { + """A cursor for use in pagination.""" + cursor: String! + + """The item at the end of the edge.""" + node: CreatedCommitContribution +} + +""" +Represents the contribution a user made on GitHub by opening an issue. +""" +type CreatedIssueContribution implements Contribution { + """ + Whether this contribution is associated with a record you do not have access to. For + example, your own 'first issue' contribution may have been made on a repository you can no + longer access. + + """ + isRestricted: Boolean! + + """The issue that was opened.""" + issue: Issue! + + """When this contribution was made.""" + occurredAt: DateTime! + + """The HTTP path for this contribution.""" + resourcePath: URI! + + """The HTTP URL for this contribution.""" + url: URI! + + """ + The user who made this contribution. + + """ + user: User! +} + +"""The connection type for CreatedIssueContribution.""" +type CreatedIssueContributionConnection { + """A list of edges.""" + edges: [CreatedIssueContributionEdge] + + """A list of nodes.""" + nodes: [CreatedIssueContribution] + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """Identifies the total count of items in the connection.""" + totalCount: Int! +} + +"""An edge in a connection.""" +type CreatedIssueContributionEdge { + """A cursor for use in pagination.""" + cursor: String! + + """The item at the end of the edge.""" + node: CreatedIssueContribution +} + +""" +Represents either a issue the viewer can access or a restricted contribution. +""" +union CreatedIssueOrRestrictedContribution = CreatedIssueContribution | RestrictedContribution + +""" +Represents the contribution a user made on GitHub by opening a pull request. +""" +type CreatedPullRequestContribution implements Contribution { + """ + Whether this contribution is associated with a record you do not have access to. For + example, your own 'first issue' contribution may have been made on a repository you can no + longer access. + + """ + isRestricted: Boolean! + + """When this contribution was made.""" + occurredAt: DateTime! + + """The pull request that was opened.""" + pullRequest: PullRequest! + + """The HTTP path for this contribution.""" + resourcePath: URI! + + """The HTTP URL for this contribution.""" + url: URI! + + """ + The user who made this contribution. + + """ + user: User! +} + +"""The connection type for CreatedPullRequestContribution.""" +type CreatedPullRequestContributionConnection { + """A list of edges.""" + edges: [CreatedPullRequestContributionEdge] + + """A list of nodes.""" + nodes: [CreatedPullRequestContribution] + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """Identifies the total count of items in the connection.""" + totalCount: Int! +} + +"""An edge in a connection.""" +type CreatedPullRequestContributionEdge { + """A cursor for use in pagination.""" + cursor: String! + + """The item at the end of the edge.""" + node: CreatedPullRequestContribution +} + +""" +Represents either a pull request the viewer can access or a restricted contribution. +""" +union CreatedPullRequestOrRestrictedContribution = CreatedPullRequestContribution | RestrictedContribution + +""" +Represents the contribution a user made by leaving a review on a pull request. +""" +type CreatedPullRequestReviewContribution implements Contribution { + """ + Whether this contribution is associated with a record you do not have access to. For + example, your own 'first issue' contribution may have been made on a repository you can no + longer access. + + """ + isRestricted: Boolean! + + """When this contribution was made.""" + occurredAt: DateTime! + + """The pull request the user reviewed.""" + pullRequest: PullRequest! + + """The review the user left on the pull request.""" + pullRequestReview: PullRequestReview! + + """The repository containing the pull request that the user reviewed.""" + repository: Repository! + + """The HTTP path for this contribution.""" + resourcePath: URI! + + """The HTTP URL for this contribution.""" + url: URI! + + """ + The user who made this contribution. + + """ + user: User! +} + +"""The connection type for CreatedPullRequestReviewContribution.""" +type CreatedPullRequestReviewContributionConnection { + """A list of edges.""" + edges: [CreatedPullRequestReviewContributionEdge] + + """A list of nodes.""" + nodes: [CreatedPullRequestReviewContribution] + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """Identifies the total count of items in the connection.""" + totalCount: Int! +} + +"""An edge in a connection.""" +type CreatedPullRequestReviewContributionEdge { + """A cursor for use in pagination.""" + cursor: String! + + """The item at the end of the edge.""" + node: CreatedPullRequestReviewContribution +} + +""" +Represents the contribution a user made on GitHub by creating a repository. +""" +type CreatedRepositoryContribution implements Contribution { + """ + Whether this contribution is associated with a record you do not have access to. For + example, your own 'first issue' contribution may have been made on a repository you can no + longer access. + + """ + isRestricted: Boolean! + + """When this contribution was made.""" + occurredAt: DateTime! + + """The repository that was created.""" + repository: Repository! + + """The HTTP path for this contribution.""" + resourcePath: URI! + + """The HTTP URL for this contribution.""" + url: URI! + + """ + The user who made this contribution. + + """ + user: User! +} + +"""The connection type for CreatedRepositoryContribution.""" +type CreatedRepositoryContributionConnection { + """A list of edges.""" + edges: [CreatedRepositoryContributionEdge] + + """A list of nodes.""" + nodes: [CreatedRepositoryContribution] + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """Identifies the total count of items in the connection.""" + totalCount: Int! +} + +"""An edge in a connection.""" +type CreatedRepositoryContributionEdge { + """A cursor for use in pagination.""" + cursor: String! + + """The item at the end of the edge.""" + node: CreatedRepositoryContribution +} + +""" +Represents either a repository the viewer can access or a restricted contribution. +""" +union CreatedRepositoryOrRestrictedContribution = CreatedRepositoryContribution | RestrictedContribution + """Autogenerated input type of CreateProject""" input CreateProjectInput { """The owner ID to create the project under.""" @@ -1728,16 +2317,50 @@ type CreateProjectPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String + """The new project.""" + project: Project +} + +"""Autogenerated input type of CreatePullRequest""" +input CreatePullRequestInput { + """The Node ID of the repository.""" + repositoryId: ID! + """ - The new project. + The name of the branch you want your changes pulled into. This should be an existing branch + on the current repository. You cannot update the base branch on a pull request to point + to another repository. - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `project` will change from `Project!` to `Project`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. + """ + baseRefName: String! + + """ + The name of the branch where your changes are implemented. For cross-repository pull requests + in the same network, namespace `head_ref_name` with a user like this: `username:branch`. """ - project: Project! + headRefName: String! + + """The title of the pull request.""" + title: String! + + """The contents of the pull request.""" + body: String + + """Indicates whether maintainers can modify the pull request.""" + maintainerCanModify: Boolean = true + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated return type of CreatePullRequest""" +type CreatePullRequestPayload { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + + """The new pull request.""" + pullRequest: PullRequest } """Represents a mention made by one issue or pull request to another.""" @@ -1795,18 +2418,10 @@ input DeclineTopicSuggestionInput { """Autogenerated return type of DeclineTopicSuggestion""" type DeclineTopicSuggestionPayload { """A unique identifier for the client performing the mutation.""" - clientMutationId: String - - """ - The declined topic. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `topic` will change from `Topic!` to `Topic`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - topic: Topic! + clientMutationId: String + + """The declined topic.""" + topic: Topic } """The possible default permissions for repositories.""" @@ -1845,6 +2460,15 @@ type DeleteBranchProtectionRulePayload { clientMutationId: String } +"""Autogenerated input type of DeleteIssue""" +input DeleteIssueInput { + """The ID of the issue to delete.""" + issueId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + """Autogenerated input type of DeleteProjectCard""" input DeleteProjectCardInput { """The id of the card to delete.""" @@ -1859,27 +2483,11 @@ type DeleteProjectCardPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The column the deleted card was in. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `column` will change from `ProjectColumn!` to `ProjectColumn`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - column: ProjectColumn! + """The column the deleted card was in.""" + column: ProjectColumn - """ - The deleted card ID. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `deletedCardId` will change from `ID!` to `ID`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - deletedCardId: ID! + """The deleted card ID.""" + deletedCardId: ID } """Autogenerated input type of DeleteProjectColumn""" @@ -1896,27 +2504,11 @@ type DeleteProjectColumnPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The deleted column ID. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `deletedColumnId` will change from `ID!` to `ID`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - deletedColumnId: ID! + """The deleted column ID.""" + deletedColumnId: ID - """ - The project the deleted column was in. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `project` will change from `Project!` to `Project`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - project: Project! + """The project the deleted column was in.""" + project: Project } """Autogenerated input type of DeleteProject""" @@ -1933,16 +2525,26 @@ type DeleteProjectPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The repository or organization the project was removed from. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `owner` will change from `ProjectOwner!` to `ProjectOwner`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - owner: ProjectOwner! + """The repository or organization the project was removed from.""" + owner: ProjectOwner +} + +"""Autogenerated input type of DeletePullRequestReviewComment""" +input DeletePullRequestReviewCommentInput { + """The ID of the comment to delete.""" + id: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated return type of DeletePullRequestReviewComment""" +type DeletePullRequestReviewCommentPayload { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + + """The pull request review the deleted comment belonged to.""" + pullRequestReview: PullRequestReview } """Autogenerated input type of DeletePullRequestReview""" @@ -1959,16 +2561,8 @@ type DeletePullRequestReviewPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The deleted pull request review. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `pullRequestReview` will change from `PullRequestReview!` to `PullRequestReview`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - pullRequestReview: PullRequestReview! + """The deleted pull request review.""" + pullRequestReview: PullRequestReview } """Represents a 'demilestoned' event on a given issue or pull request.""" @@ -2165,6 +2759,21 @@ type DeploymentEnvironmentChangedEvent implements Node { pullRequest: PullRequest! } +"""Ordering options for deployment connections""" +input DeploymentOrder { + """The field to order deployments by.""" + field: DeploymentOrderField! + + """The ordering direction.""" + direction: OrderDirection! +} + +"""Properties by which deployment connections can be ordered.""" +enum DeploymentOrderField { + """Order collection by creation time""" + CREATED_AT +} + """The possible states in which a deployment can be.""" enum DeploymentState { """The pending deployment was not updated after 30 minutes.""" @@ -2187,6 +2796,12 @@ enum DeploymentState { """The deployment is pending.""" PENDING + + """The deployment has queued""" + QUEUED + + """The deployment is in progress.""" + IN_PROGRESS } """Describes the status of a given deployment attempt.""" @@ -2257,6 +2872,12 @@ enum DeploymentStatusState { """The deployment experienced an error.""" ERROR + + """The deployment is queued""" + QUEUED + + """The deployment is in progress.""" + IN_PROGRESS } """Autogenerated input type of DismissPullRequestReview""" @@ -2276,16 +2897,8 @@ type DismissPullRequestReviewPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The dismissed pull request review. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `pullRequestReview` will change from `PullRequestReview!` to `PullRequestReview`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - pullRequestReview: PullRequestReview! + """The dismissed pull request review.""" + pullRequestReview: PullRequestReview } """Specifies a review comment to be left with a Pull Request Review.""" @@ -2413,8 +3026,17 @@ type Gist implements Node & Starrable { """The gist description.""" description: String + + """The files in this gist.""" + files( + """The maximum number of files to return.""" + limit: Int = 10 + ): [GistFile] id: ID! + """Identifies if the gist is a fork.""" + isFork: Boolean! + """Whether the gist is public or not.""" isPublic: Boolean! @@ -2594,6 +3216,41 @@ type GistEdge { node: Gist } +"""A file in a gist.""" +type GistFile { + """ + The file name encoded to remove characters that are invalid in URL paths. + """ + encodedName: String + + """The gist file encoding.""" + encoding: String + + """The file extension from the file name.""" + extension: String + + """Indicates if this file is an image.""" + isImage: Boolean! + + """Whether the file's contents were truncated.""" + isTruncated: Boolean! + + """The programming language this file is written in.""" + language: Language + + """The gist file name.""" + name: String + + """The gist file size in bytes.""" + size: Int + + """UTF8 text data or null if the file is binary""" + text( + """Optionally truncate the returned file to this length.""" + truncate: Int + ): String +} + """Ordering options for gist connections""" input GistOrder { """The field to order repositories by.""" @@ -2883,6 +3540,73 @@ type HeadRefRestoredEvent implements Node { """A string containing HTML code.""" scalar HTML +""" +The possible states in which authentication can be configured with an Identity Provider. +""" +enum IdentityProviderConfigurationState { + """Authentication with an Identity Provider is configured and enforced.""" + ENFORCED + + """ + Authentication with an Identity Provider is configured but not enforced. + """ + CONFIGURED + + """Authentication with an Identity Provider is not configured.""" + UNCONFIGURED +} + +"""Autogenerated input type of ImportProject""" +input ImportProjectInput { + """The name of the Organization or User to create the Project under.""" + ownerName: String! + + """The name of Project.""" + name: String! + + """The description of Project.""" + body: String + + """Whether the Project is public or not.""" + public: Boolean = false + + """A list of columns containing issues and pull requests.""" + columnImports: [ProjectColumnImport!]! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated input type of InviteBusinessAdmin""" +input InviteBusinessAdminInput { + """The ID of the business to which you want to invite an administrator.""" + businessId: ID! + + """The login of a user to invite as an administrator.""" + invitee: String + + """The email of the person to invite as an administrator.""" + email: String + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated input type of InviteBusinessBillingManager""" +input InviteBusinessBillingManagerInput { + """The ID of the business to which you want to invite a billing manager.""" + businessId: ID! + + """The login of a user to invite as a billing manager.""" + invitee: String + + """The email of the person to invite as a billing manager.""" + email: String + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + """ An Issue is a place to discuss ideas, enhancements, tasks, and bugs for a project. """ @@ -3021,9 +3745,6 @@ type Issue implements Node & Assignable & Closable & Comment & Updatable & Updat """List of project cards associated with this issue.""" projectCards( - """A list of archived states to filter the cards by""" - archivedStates: [ProjectCardArchivedState] - """ Returns the elements in the list that come after the specified cursor. """ @@ -3039,6 +3760,9 @@ type Issue implements Node & Assignable & Closable & Comment & Updatable & Updat """Returns the last _n_ elements from the list.""" last: Int + + """A list of archived states to filter the cards by""" + archivedStates: [ProjectCardArchivedState] ): ProjectCardConnection! """Identifies when the comment was published at.""" @@ -3413,7 +4137,7 @@ type IssueTimelineItemEdge { } """An item in an issue timeline""" -union IssueTimelineItems = IssueComment | CrossReferencedEvent | AddedToProjectEvent | AssignedEvent | ClosedEvent | CommentDeletedEvent | ConvertedNoteToIssueEvent | DemilestonedEvent | LabeledEvent | LockedEvent | MentionedEvent | MilestonedEvent | MovedColumnsInProjectEvent | ReferencedEvent | RemovedFromProjectEvent | RenamedTitleEvent | ReopenedEvent | SubscribedEvent | TransferredEvent | UnassignedEvent | UnlabeledEvent | UnlockedEvent | UnsubscribedEvent +union IssueTimelineItems = IssueComment | CrossReferencedEvent | AddedToProjectEvent | AssignedEvent | ClosedEvent | CommentDeletedEvent | ConvertedNoteToIssueEvent | DemilestonedEvent | LabeledEvent | LockedEvent | MentionedEvent | MilestonedEvent | MovedColumnsInProjectEvent | PinnedEvent | ReferencedEvent | RemovedFromProjectEvent | RenamedTitleEvent | ReopenedEvent | SubscribedEvent | TransferredEvent | UnassignedEvent | UnlabeledEvent | UnlockedEvent | UnpinnedEvent | UnsubscribedEvent """An edge in a connection.""" type IssueTimelineItemsEdge { @@ -3473,6 +4197,9 @@ enum IssueTimelineItemsItemType { """ MOVED_COLUMNS_IN_PROJECT_EVENT + """Represents a 'pinned' event on a given issue or pull request.""" + PINNED_EVENT + """Represents a 'referenced' event on a given `ReferencedSubject`.""" REFERENCED_EVENT @@ -3502,10 +4229,39 @@ enum IssueTimelineItemsItemType { """Represents an 'unlocked' event on a given issue or pull request.""" UNLOCKED_EVENT + """Represents an 'unpinned' event on a given issue or pull request.""" + UNPINNED_EVENT + """Represents an 'unsubscribed' event on a given `Subscribable`.""" UNSUBSCRIBED_EVENT } +"""Represents a user signing up for a GitHub account.""" +type JoinedGitHubContribution implements Contribution { + """ + Whether this contribution is associated with a record you do not have access to. For + example, your own 'first issue' contribution may have been made on a repository you can no + longer access. + + """ + isRestricted: Boolean! + + """When this contribution was made.""" + occurredAt: DateTime! + + """The HTTP path for this contribution.""" + resourcePath: URI! + + """The HTTP URL for this contribution.""" + url: URI! + + """ + The user who made this contribution. + + """ + user: User! +} + """ A label for categorizing Issues or Milestones with a given Repository. """ @@ -3950,11 +4706,41 @@ type MarketplaceListing implements Node { """ isPaid: Boolean! + """ + Whether this listing has been approved for display in the Marketplace. + """ + isPublic: Boolean! + """ Whether this listing has been rejected by GitHub for display in the Marketplace. """ isRejected: Boolean! + """ + Whether this listing has been approved for unverified display in the Marketplace. + """ + isUnverified: Boolean! + + """ + Whether this draft listing has been submitted for review for approval to be unverified in the Marketplace. + """ + isUnverifiedPending: Boolean! + + """ + Whether this draft listing has been submitted for review from GitHub for approval to be verified in the Marketplace. + """ + isVerificationPendingFromDraft: Boolean! + + """ + Whether this unverified listing has been submitted for review from GitHub for approval to be verified in the Marketplace. + """ + isVerificationPendingFromUnverified: Boolean! + + """ + Whether this listing has been approved for verified display in the Marketplace. + """ + isVerified: Boolean! + """The hex color code, without the leading '#', for the logo background.""" logoBackgroundColor: String! @@ -4098,6 +4884,33 @@ type MarketplaceListingEdge { node: MarketplaceListing } +"""Entities that have members who can set status messages.""" +interface MemberStatusable { + """ + Get the status messages members of this entity have set that are either public or visible only to the organization. + """ + memberStatuses( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + + """Ordering options for user statuses returned from the connection.""" + orderBy: UserStatusOrder + ): UserStatusConnection! +} + """Represents a 'mentioned' event on a given issue or pull request.""" type MentionedEvent implements Node { """Identifies the actor who performed the event.""" @@ -4151,6 +4964,39 @@ type MergedEvent implements Node & UniformResourceLocatable { url: URI! } +"""Autogenerated input type of MergePullRequest""" +input MergePullRequestInput { + """ID of the pull request to be merged.""" + pullRequestId: ID! + + """ + Commit headline to use for the merge commit; if omitted, a default message will be used. + """ + commitHeadline: String + + """ + Commit body to use for the merge commit; if omitted, a default message will be used + """ + commitBody: String + + """ + OID that the pull request head ref must match to allow merge; if omitted, no check is performed. + """ + expectedHeadOid: GitObjectID + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated return type of MergePullRequest""" +type MergePullRequestPayload { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + + """The pull request that was merged.""" + pullRequest: PullRequest +} + """Represents a Milestone object on a given repository.""" type Milestone implements Node & Closable & UniformResourceLocatable { """ @@ -4382,16 +5228,8 @@ input MoveProjectCardInput { """Autogenerated return type of MoveProjectCard""" type MoveProjectCardPayload { - """ - The new edge of the moved card. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `cardEdge` will change from `ProjectCardEdge!` to `ProjectCardEdge`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - cardEdge: ProjectCardEdge! + """The new edge of the moved card.""" + cardEdge: ProjectCardEdge """A unique identifier for the client performing the mutation.""" clientMutationId: String @@ -4416,16 +5254,8 @@ type MoveProjectColumnPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The new edge of the moved column. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `columnEdge` will change from `ProjectColumnEdge!` to `ProjectColumnEdge`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - columnEdge: ProjectColumnEdge! + """The new edge of the moved column.""" + columnEdge: ProjectColumnEdge } """The root query for implementing GraphQL mutations.""" @@ -4456,12 +5286,21 @@ type Mutation { """Adds a star to a Starrable.""" addStar(input: AddStarInput!): AddStarPayload + """Update your status on GitHub.""" + changeUserStatus(input: ChangeUserStatusInput!): ChangeUserStatusPayload + + """Close a pull request.""" + closePullRequest(input: ClosePullRequestInput!): ClosePullRequestPayload + """Create a new branch protection rule""" createBranchProtectionRule(input: CreateBranchProtectionRuleInput!): CreateBranchProtectionRulePayload """Creates a new project.""" createProject(input: CreateProjectInput!): CreateProjectPayload + """Create a new pull request""" + createPullRequest(input: CreatePullRequestInput!): CreatePullRequestPayload + """Rejects a suggested topic for the repository.""" declineTopicSuggestion(input: DeclineTopicSuggestionInput!): DeclineTopicSuggestionPayload @@ -4480,12 +5319,18 @@ type Mutation { """Deletes a pull request review.""" deletePullRequestReview(input: DeletePullRequestReviewInput!): DeletePullRequestReviewPayload + """Deletes a pull request review comment.""" + deletePullRequestReviewComment(input: DeletePullRequestReviewCommentInput!): DeletePullRequestReviewCommentPayload + """Dismisses an approved or rejected pull request review.""" dismissPullRequestReview(input: DismissPullRequestReviewInput!): DismissPullRequestReviewPayload """Lock a lockable object""" lockLockable(input: LockLockableInput!): LockLockablePayload + """Merge a pull request.""" + mergePullRequest(input: MergePullRequestInput!): MergePullRequestPayload + """Moves a project card to another place.""" moveProjectCard(input: MoveProjectCardInput!): MoveProjectCardPayload @@ -4503,15 +5348,24 @@ type Mutation { """Removes a star from a Starrable.""" removeStar(input: RemoveStarInput!): RemoveStarPayload + """Reopen a pull request.""" + reopenPullRequest(input: ReopenPullRequestInput!): ReopenPullRequestPayload + """Set review requests on a pull request.""" requestReviews(input: RequestReviewsInput!): RequestReviewsPayload + """Marks a review thread as resolved.""" + resolveReviewThread(input: ResolveReviewThreadInput!): ResolveReviewThreadPayload + """Submits a pending pull request review.""" submitPullRequestReview(input: SubmitPullRequestReviewInput!): SubmitPullRequestReviewPayload """Unlock a lockable object""" unlockLockable(input: UnlockLockableInput!): UnlockLockablePayload + """Marks a review thread as unresolved.""" + unresolveReviewThread(input: UnresolveReviewThreadInput!): UnresolveReviewThreadPayload + """Create a new branch protection rule""" updateBranchProtectionRule(input: UpdateBranchProtectionRuleInput!): UpdateBranchProtectionRulePayload @@ -4524,6 +5378,9 @@ type Mutation { """Updates an existing project column.""" updateProjectColumn(input: UpdateProjectColumnInput!): UpdateProjectColumnPayload + """Update a pull request""" + updatePullRequest(input: UpdatePullRequestInput!): UpdatePullRequestPayload + """Updates the body of a pull request review.""" updatePullRequestReview(input: UpdatePullRequestReviewInput!): UpdatePullRequestReviewPayload @@ -4557,7 +5414,7 @@ enum OrderDirection { """ An account on GitHub, with one or more owners, that has repositories, members and teams. """ -type Organization implements Node & Actor & RegistryPackageOwner & RegistryPackageSearch & ProjectOwner & RepositoryOwner & UniformResourceLocatable { +type Organization implements Node & Actor & RegistryPackageOwner & RegistryPackageSearch & ProjectOwner & RepositoryOwner & UniformResourceLocatable & MemberStatusable { """A URL pointing to the organization's public avatar.""" avatarUrl( """The size of the resulting square image.""" @@ -4583,6 +5440,30 @@ type Organization implements Node & Actor & RegistryPackageOwner & RegistryPacka """The organization's login name.""" login: String! + """ + Get the status messages members of this entity have set that are either public or visible only to the organization. + """ + memberStatuses( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + + """Ordering options for user statuses returned from the connection.""" + orderBy: UserStatusOrder + ): UserStatusConnection! + """A list of users who are members of this organization.""" members( """ @@ -4630,8 +5511,27 @@ type Organization implements Node & Actor & RegistryPackageOwner & RegistryPacka """The HTTP URL creating a new team""" newTeamUrl: URI! - """The billing email for the organization.""" - organizationBillingEmail: String + """The billing email for the organization.""" + organizationBillingEmail: String + + """A list of users who have been invited to join this organization.""" + pendingMembers( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + ): UserConnection! """A list of repositories this user has pinned to their profile""" pinnedRepositories( @@ -5028,6 +5928,11 @@ type OrganizationMemberEdge { """A cursor for use in pagination.""" cursor: String! + """ + Whether the organization member has two factor enabled or not. Returns null if information is not available to viewer. + """ + hasTwoFactorEnabled: Boolean + """The item at the end of the edge.""" node: User @@ -5059,6 +5964,28 @@ type PageInfo { startCursor: String } +"""Autogenerated input type of PinIssue""" +input PinIssueInput { + """The ID of the issue to be pinned""" + issueId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Represents a 'pinned' event on a given issue or pull request.""" +type PinnedEvent implements Node { + """Identifies the actor who performed the event.""" + actor: Actor + + """Identifies the date and time when the object was created.""" + createdAt: DateTime! + id: ID! + + """Identifies the issue associated with the event.""" + issue: Issue! +} + """ Projects manage issues, pull requests and notes within a project owner. """ @@ -5113,15 +6040,12 @@ type Project implements Node & Closable & Updatable { number: Int! """ - The project's owner. Currently limited to repositories and organizations. + The project's owner. Currently limited to repositories, organizations, and users. """ owner: ProjectOwner! """List of pending cards in this project""" pendingCards( - """A list of archived states to filter the cards by""" - archivedStates: [ProjectCardArchivedState] - """ Returns the elements in the list that come after the specified cursor. """ @@ -5137,6 +6061,9 @@ type Project implements Node & Closable & Updatable { """Returns the last _n_ elements from the list.""" last: Int + + """A list of archived states to filter the cards by""" + archivedStates: [ProjectCardArchivedState] ): ProjectCardConnection! """The HTTP path for this project""" @@ -5234,6 +6161,17 @@ type ProjectCardEdge { node: ProjectCard } +""" +An issue or PR and its owning repository to be used in a project card. +""" +input ProjectCardImport { + """Repository name with owner (owner/repository).""" + repository: String! + + """The issue or pull request number.""" + number: Int! +} + """Types that can be inside Project Cards.""" union ProjectCardItem = Issue | PullRequest @@ -5253,9 +6191,6 @@ enum ProjectCardState { type ProjectColumn implements Node { """List of cards in the column""" cards( - """A list of archived states to filter the cards by""" - archivedStates: [ProjectCardArchivedState] - """ Returns the elements in the list that come after the specified cursor. """ @@ -5271,6 +6206,9 @@ type ProjectColumn implements Node { """Returns the last _n_ elements from the list.""" last: Int + + """A list of archived states to filter the cards by""" + archivedStates: [ProjectCardArchivedState] ): ProjectCardConnection! """Identifies the date and time when the object was created.""" @@ -5323,6 +6261,18 @@ type ProjectColumnEdge { node: ProjectColumn } +"""A project column and a list of its issues and PRs.""" +input ProjectColumnImport { + """The name of the column.""" + columnName: String! + + """The position of the column, starting from 0.""" + position: Int! + + """A list of issues and pull requests in the column.""" + issues: [ProjectCardImport!] +} + """The semantic purpose of the column - todo, in progress, or done.""" enum ProjectColumnPurpose { """The column contains cards still to be worked on""" @@ -5542,10 +6492,24 @@ type ProtectedBranchEdge { """A user's public key.""" type PublicKey implements Node { + """The last time this authorization was used to perform an action""" + accessedAt: DateTime + + """Identifies the date and time when the object was created.""" + createdAt: DateTime! + + """The fingerprint for this PublicKey""" + fingerprint: String id: ID! + """Whether this PublicKey is read-only or not""" + isReadOnly: Boolean! + """The public key string""" key: String! + + """Identifies the date and time when the object was last updated.""" + updatedAt: DateTime! } """The connection type for PublicKey.""" @@ -5691,6 +6655,25 @@ type PullRequest implements Node & Assignable & Closable & Comment & Updatable & """The actor who edited this pull request's body.""" editor: Actor + """Lists the files changed within this pull request.""" + files( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + ): PullRequestChangedFileConnection + """Identifies the head Ref associated with the pull request.""" headRef: Ref @@ -5806,9 +6789,6 @@ type PullRequest implements Node & Assignable & Closable & Comment & Updatable & """List of project cards associated with this pull request.""" projectCards( - """A list of archived states to filter the cards by""" - archivedStates: [ProjectCardArchivedState] - """ Returns the elements in the list that come after the specified cursor. """ @@ -5824,6 +6804,9 @@ type PullRequest implements Node & Assignable & Closable & Comment & Updatable & """Returns the last _n_ elements from the list.""" last: Int + + """A list of archived states to filter the cards by""" + archivedStates: [ProjectCardArchivedState] ): ProjectCardConnection! """Identifies when the comment was published at.""" @@ -5888,6 +6871,25 @@ type PullRequest implements Node & Assignable & Closable & Comment & Updatable & last: Int ): ReviewRequestConnection + """The list of all review threads for this pull request.""" + reviewThreads( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + ): PullRequestReviewThreadConnection! + """A list of reviews associated with the pull request.""" reviews( """ @@ -5999,6 +7001,42 @@ type PullRequest implements Node & Assignable & Closable & Comment & Updatable & viewerSubscription: SubscriptionState } +"""A file changed in a pull request.""" +type PullRequestChangedFile { + """The number of additions to the file.""" + additions: Int! + + """The number of deletions to the file.""" + deletions: Int! + + """The path of the file.""" + path: String! +} + +"""The connection type for PullRequestChangedFile.""" +type PullRequestChangedFileConnection { + """A list of edges.""" + edges: [PullRequestChangedFileEdge] + + """A list of nodes.""" + nodes: [PullRequestChangedFile] + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """Identifies the total count of items in the connection.""" + totalCount: Int! +} + +"""An edge in a connection.""" +type PullRequestChangedFileEdge { + """A cursor for use in pagination.""" + cursor: String! + + """The item at the end of the edge.""" + node: PullRequestChangedFile +} + """Represents a Git commit part of a pull request.""" type PullRequestCommit implements Node & UniformResourceLocatable { """The Git commit object""" @@ -6100,7 +7138,7 @@ enum PullRequestPubSubTopic { } """A review object for a given pull request.""" -type PullRequestReview implements Node & Comment & Deletable & Updatable & UpdatableComment & RepositoryNode { +type PullRequestReview implements Node & Comment & Deletable & Updatable & UpdatableComment & Reactable & RepositoryNode { """The actor who authored the comment.""" author: Actor @@ -6184,6 +7222,34 @@ type PullRequestReview implements Node & Comment & Deletable & Updatable & Updat """Identifies the pull request associated with this pull request review.""" pullRequest: PullRequest! + """A list of reactions grouped by content left on the subject.""" + reactionGroups: [ReactionGroup!] + + """A list of Reactions left on the Issue.""" + reactions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + + """Allows filtering Reactions by emoji.""" + content: ReactionContent + + """Allows specifying the order in which reactions are returned.""" + orderBy: ReactionOrder + ): ReactionConnection! + """The repository associated with this node.""" repository: Repository! @@ -6224,6 +7290,9 @@ type PullRequestReview implements Node & Comment & Deletable & Updatable & Updat """Check if the current viewer can delete this object.""" viewerCanDelete: Boolean! + """Can user react to this subject""" + viewerCanReact: Boolean! + """Check if the current viewer can update this object.""" viewerCanUpdate: Boolean! @@ -6507,11 +7576,47 @@ type PullRequestReviewThread implements Node { ): PullRequestReviewCommentConnection! id: ID! + """Whether this thread has been resolved""" + isResolved: Boolean! + """Identifies the pull request associated with this thread.""" pullRequest: PullRequest! """Identifies the repository associated with this thread.""" repository: Repository! + + """The user who resolved this thread""" + resolvedBy: User + + """Whether or not the viewer can resolve this thread""" + viewerCanResolve: Boolean! + + """Whether or not the viewer can unresolve this thread""" + viewerCanUnresolve: Boolean! +} + +"""Review comment threads for a pull request review.""" +type PullRequestReviewThreadConnection { + """A list of edges.""" + edges: [PullRequestReviewThreadEdge] + + """A list of nodes.""" + nodes: [PullRequestReviewThread] + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """Identifies the total count of items in the connection.""" + totalCount: Int! +} + +"""An edge in a connection.""" +type PullRequestReviewThreadEdge { + """A cursor for use in pagination.""" + cursor: String! + + """The item at the end of the edge.""" + node: PullRequestReviewThread } """The possible states of a pull request.""" @@ -6554,7 +7659,7 @@ type PullRequestTimelineItemEdge { } """An item in a pull request timeline""" -union PullRequestTimelineItems = PullRequestCommit | PullRequestReview | PullRequestReviewThread | BaseRefChangedEvent | BaseRefForcePushedEvent | DeployedEvent | DeploymentEnvironmentChangedEvent | HeadRefDeletedEvent | HeadRefForcePushedEvent | HeadRefRestoredEvent | MergedEvent | ReviewDismissedEvent | ReviewRequestedEvent | ReviewRequestRemovedEvent | IssueComment | CrossReferencedEvent | AddedToProjectEvent | AssignedEvent | ClosedEvent | CommentDeletedEvent | ConvertedNoteToIssueEvent | DemilestonedEvent | LabeledEvent | LockedEvent | MentionedEvent | MilestonedEvent | MovedColumnsInProjectEvent | ReferencedEvent | RemovedFromProjectEvent | RenamedTitleEvent | ReopenedEvent | SubscribedEvent | TransferredEvent | UnassignedEvent | UnlabeledEvent | UnlockedEvent | UnsubscribedEvent +union PullRequestTimelineItems = PullRequestCommit | PullRequestReview | PullRequestReviewThread | BaseRefChangedEvent | BaseRefForcePushedEvent | DeployedEvent | DeploymentEnvironmentChangedEvent | HeadRefDeletedEvent | HeadRefForcePushedEvent | HeadRefRestoredEvent | MergedEvent | ReviewDismissedEvent | ReviewRequestedEvent | ReviewRequestRemovedEvent | IssueComment | CrossReferencedEvent | AddedToProjectEvent | AssignedEvent | ClosedEvent | CommentDeletedEvent | ConvertedNoteToIssueEvent | DemilestonedEvent | LabeledEvent | LockedEvent | MentionedEvent | MilestonedEvent | MovedColumnsInProjectEvent | PinnedEvent | ReferencedEvent | RemovedFromProjectEvent | RenamedTitleEvent | ReopenedEvent | SubscribedEvent | TransferredEvent | UnassignedEvent | UnlabeledEvent | UnlockedEvent | UnpinnedEvent | UnsubscribedEvent """An edge in a connection.""" type PullRequestTimelineItemsEdge { @@ -6670,6 +7775,9 @@ enum PullRequestTimelineItemsItemType { """ MOVED_COLUMNS_IN_PROJECT_EVENT + """Represents a 'pinned' event on a given issue or pull request.""" + PINNED_EVENT + """Represents a 'referenced' event on a given `ReferencedSubject`.""" REFERENCED_EVENT @@ -6699,6 +7807,9 @@ enum PullRequestTimelineItemsItemType { """Represents an 'unlocked' event on a given issue or pull request.""" UNLOCKED_EVENT + """Represents an 'unpinned' event on a given issue or pull request.""" + UNPINNED_EVENT + """Represents an 'unsubscribed' event on a given `Subscribable`.""" UNSUBSCRIBED_EVENT } @@ -6713,11 +7824,6 @@ type PushAllowance implements Node { """ branchProtectionRule: BranchProtectionRule id: ID! - - """ - Identifies the protected branch associated with the allowed user or team. - """ - protectedBranch: ProtectedBranch! @deprecated(reason: "The `ProtectedBranch` type is deprecated and will be removed soon. Use `Repository.branchProtectionRule` instead. Removal on 2019-01-01 UTC.") } """Types that can be an actor.""" @@ -6934,6 +8040,74 @@ type Query { type: SearchType! ): SearchResultItemConnection! + """GitHub Security Advisories""" + securityAdvisories( + """Ordering options for the returned topics.""" + orderBy: SecurityAdvisoryOrder + + """Filter advisories by identifier, e.g. GHSA or CVE.""" + identifier: SecurityAdvisoryIdentifierFilter + + """Filter advisories to those published since a time in the past.""" + publishedSince: DateTime + + """Filter advisories to those updated since a time in the past.""" + updatedSince: DateTime + + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + ): SecurityAdvisoryConnection! + + """Fetch a Security Advisory by its GHSA ID""" + securityAdvisory( + """GitHub Security Advisory ID.""" + ghsaId: String! + ): SecurityAdvisory + + """Software Vulnerabilities documented by GitHub Security Advisories""" + securityVulnerabilities( + """Ordering options for the returned topics.""" + orderBy: SecurityVulnerabilityOrder + + """An ecosystem to filter vulnerabilities by.""" + ecosystem: SecurityAdvisoryEcosystem + + """A package name to filter vulnerabilities by.""" + package: String + + """A list of severities to filter vulnerabilities by.""" + severities: [SecurityAdvisorySeverity!] + + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + ): SecurityVulnerabilityConnection! + """Look up a topic by name.""" topic( """The topic's name.""" @@ -7093,6 +8267,12 @@ enum ReactionContent { """Represents the ❤️ emoji.""" HEART + + """Represents the 🚀 emoji.""" + ROCKET + + """Represents the 👀 emoji.""" + EYES } """An edge in a connection.""" @@ -7279,6 +8459,17 @@ enum RefOrderField { ALPHABETICAL } +""" +Autogenerated input type of RegenerateBusinessIdentityProviderRecoveryCodes +""" +input RegenerateBusinessIdentityProviderRecoveryCodesInput { + """The ID of the business on which to set an Identity Provider.""" + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + """Represents an owner of a registry package.""" interface RegistryPackageOwner { id: ID! @@ -7453,6 +8644,39 @@ enum ReleaseOrderField { NAME } +"""Autogenerated input type of RemoveBusinessAdmin""" +input RemoveBusinessAdminInput { + """The Business ID to update.""" + businessId: ID! + + """The login of the user to add as an admin.""" + login: String! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated input type of RemoveBusinessBillingManager""" +input RemoveBusinessBillingManagerInput { + """The Business ID to update.""" + businessId: ID! + + """The login of the user to add as a billing manager.""" + login: String! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated input type of RemoveBusinessIdentityProvider""" +input RemoveBusinessIdentityProviderInput { + """The ID of the business from which to remove the Identity Provider.""" + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + """ Represents a 'removed_from_project' event on a given issue or pull request. """ @@ -7485,16 +8709,8 @@ type RemoveOutsideCollaboratorPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The user that was removed as an outside collaborator. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `removedUser` will change from `User!` to `User`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - removedUser: User! + """The user that was removed as an outside collaborator.""" + removedUser: User } """Autogenerated input type of RemoveReaction""" @@ -7514,27 +8730,11 @@ type RemoveReactionPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The reaction object. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `reaction` will change from `Reaction!` to `Reaction`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - reaction: Reaction! - - """ - The reactable subject. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `subject` will change from `Reactable!` to `Reactable`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - subject: Reactable! + """The reaction object.""" + reaction: Reaction + + """The reactable subject.""" + subject: Reactable } """Autogenerated input type of RemoveStar""" @@ -7551,16 +8751,8 @@ type RemoveStarPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The starrable. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `starrable` will change from `Starrable!` to `Starrable`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - starrable: Starrable! + """The starrable.""" + starrable: Starrable } """Represents a 'renamed' event on a given issue or pull request""" @@ -7598,6 +8790,24 @@ type ReopenedEvent implements Node { id: ID! } +"""Autogenerated input type of ReopenPullRequest""" +input ReopenPullRequestInput { + """ID of the pull request to be reopened.""" + pullRequestId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated return type of ReopenPullRequest""" +type ReopenPullRequestPayload { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + + """The pull request that was reopened.""" + pullRequest: PullRequest +} + """The reasons a piece of content can be reported or minimized.""" enum ReportedContentClassifiers { """A spammy piece of content""" @@ -7661,6 +8871,9 @@ type Repository implements Node & ProjectOwner & RegistryPackageOwner & Subscrib """A list of collaborators associated with the repository.""" collaborators( + """Collaborators affiliation level with a repository.""" + affiliation: CollaboratorAffiliation + """ Returns the elements in the list that come after the specified cursor. """ @@ -7676,9 +8889,6 @@ type Repository implements Node & ProjectOwner & RegistryPackageOwner & Subscrib """Returns the last _n_ elements from the list.""" last: Int - - """Collaborators affiliation level with a repository.""" - affiliation: CollaboratorAffiliation ): RepositoryCollaboratorConnection """A list of commit comments associated with the repository.""" @@ -7730,6 +8940,12 @@ type Repository implements Node & ProjectOwner & RegistryPackageOwner & Subscrib """Deployments associated with the repository""" deployments( + """Environments to list deployments for""" + environments: [String!] + + """Ordering options for deployments returned from the connection.""" + orderBy: DeploymentOrder + """ Returns the elements in the list that come after the specified cursor. """ @@ -7745,9 +8961,6 @@ type Repository implements Node & ProjectOwner & RegistryPackageOwner & Subscrib """Returns the last _n_ elements from the list.""" last: Int - - """Environments to list deployments for""" - environments: [String!] ): DeploymentConnection! """The description of the repository.""" @@ -8752,27 +9965,11 @@ type RequestReviewsPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The pull request that is getting requests. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `pullRequest` will change from `PullRequest!` to `PullRequest`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - pullRequest: PullRequest! + """The pull request that is getting requests.""" + pullRequest: PullRequest - """ - The edge from the pull request to the requested reviewers. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `requestedReviewersEdge` will change from `UserEdge!` to `UserEdge`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - requestedReviewersEdge: UserEdge! + """The edge from the pull request to the requested reviewers.""" + requestedReviewersEdge: UserEdge } """Autogenerated input type of ResolveReviewThread""" @@ -8784,6 +9981,41 @@ input ResolveReviewThreadInput { clientMutationId: String } +"""Autogenerated return type of ResolveReviewThread""" +type ResolveReviewThreadPayload { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + + """The thread to resolve.""" + thread: PullRequestReviewThread +} + +"""Represents a private contribution a user made on GitHub.""" +type RestrictedContribution implements Contribution { + """ + Whether this contribution is associated with a record you do not have access to. For + example, your own 'first issue' contribution may have been made on a repository you can no + longer access. + + """ + isRestricted: Boolean! + + """When this contribution was made.""" + occurredAt: DateTime! + + """The HTTP path for this contribution.""" + resourcePath: URI! + + """The HTTP URL for this contribution.""" + url: URI! + + """ + The user who made this contribution. + + """ + user: User! +} + """ A team or user who has the ability to dismiss a review on a protected branch. """ @@ -8796,11 +10028,6 @@ type ReviewDismissalAllowance implements Node { """ branchProtectionRule: BranchProtectionRule id: ID! - - """ - Identifies the protected branch associated with the allowed user or team. - """ - protectedBranch: ProtectedBranch! @deprecated(reason: "The `ProtectedBranch` type is deprecated and will be removed soon. Use `ReviewDismissalAllowance.branchProtectionRule` instead. Removal on 2019-01-01 UTC.") } """Types that can be an actor.""" @@ -8842,13 +10069,23 @@ type ReviewDismissedEvent implements Node & UniformResourceLocatable { """Identifies the primary key from the database.""" databaseId: Int + + """ + Identifies the optional message associated with the 'review_dismissed' event. + """ + dismissalMessage: String + + """ + Identifies the optional message associated with the event, rendered to HTML. + """ + dismissalMessageHTML: String id: ID! """Identifies the message associated with the 'review_dismissed' event.""" - message: String! + message: String! @deprecated(reason: "`message` is being removed because it not nullable, whereas the underlying field is optional. Use `dismissalMessage` instead. Removal on 2019-07-01 UTC.") """The message associated with the event, rendered to HTML.""" - messageHtml: HTML! + messageHtml: HTML! @deprecated(reason: "`messageHtml` is being removed because it not nullable, whereas the underlying field is optional. Use `dismissalMessageHTML` instead. Removal on 2019-07-01 UTC.") """ Identifies the previous state of the review with the 'review_dismissed' event. @@ -8994,6 +10231,285 @@ enum SearchType { USER } +"""A GitHub Security Advisory""" +type SecurityAdvisory implements Node { + """Identifies the primary key from the database.""" + databaseId: Int + + """This is a long plaintext description of the advisory""" + description: String! + + """The GitHub Security Advisory ID""" + ghsaId: String! + id: ID! + + """A list of identifiers for this advisory""" + identifiers: [SecurityAdvisoryIdentifier!]! + + """When the advisory was published""" + publishedAt: DateTime! + + """A list of references for this advisory""" + references: [SecurityAdvisoryReference!]! + + """The severity of the advisory""" + severity: SecurityAdvisorySeverity! + + """A short plaintext summary of the advisory""" + summary: String! + + """When the advisory was last updated""" + updatedAt: DateTime! + + """Vulnerabilities associated with this Advisory""" + vulnerabilities( + """Ordering options for the returned topics.""" + orderBy: SecurityVulnerabilityOrder + + """An ecosystem to filter vulnerabilities by.""" + ecosystem: SecurityAdvisoryEcosystem + + """A package name to filter vulnerabilities by.""" + package: String + + """A list of severities to filter vulnerabilities by.""" + severities: [SecurityAdvisorySeverity!] + + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + ): SecurityVulnerabilityConnection! + + """When the advisory was withdrawn, if it has been withdrawn""" + withdrawnAt: DateTime +} + +"""The connection type for SecurityAdvisory.""" +type SecurityAdvisoryConnection { + """A list of edges.""" + edges: [SecurityAdvisoryEdge] + + """A list of nodes.""" + nodes: [SecurityAdvisory] + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """Identifies the total count of items in the connection.""" + totalCount: Int! +} + +"""The possible ecosystems of a security vulnerability's package.""" +enum SecurityAdvisoryEcosystem { + """Ruby gems hosted at RubyGems.org""" + RUBYGEMS + + """JavaScript packages hosted at npmjs.com""" + NPM + + """Python packages hosted at PyPI.org""" + PIP + + """Java artifacts hosted at the Maven central repository""" + MAVEN + + """.NET packages hosted at the NuGet Gallery""" + NUGET +} + +"""An edge in a connection.""" +type SecurityAdvisoryEdge { + """A cursor for use in pagination.""" + cursor: String! + + """The item at the end of the edge.""" + node: SecurityAdvisory +} + +"""A GitHub Security Advisory Identifier""" +type SecurityAdvisoryIdentifier { + """The identifier type, e.g. GHSA, CVE""" + type: String! + + """The identifier""" + value: String! +} + +"""An advisory identifier to filter results on.""" +input SecurityAdvisoryIdentifierFilter { + """The identifier type.""" + type: SecurityAdvisoryIdentifierType! + + """The identifier string. Supports exact or partial matching.""" + value: String! +} + +"""Identifier formats available for advisories.""" +enum SecurityAdvisoryIdentifierType { + """Common Vulnerabilities and Exposures Identifier.""" + CVE + + """GitHub Security Advisory ID.""" + GHSA +} + +"""Ordering options for security advisory connections""" +input SecurityAdvisoryOrder { + """The field to order security advisories by.""" + field: SecurityAdvisoryOrderField! + + """The ordering direction.""" + direction: OrderDirection! +} + +"""Properties by which security advisory connections can be ordered.""" +enum SecurityAdvisoryOrderField { + """Order advisories by publication time""" + PUBLISHED_AT + + """Order advisories by update time""" + UPDATED_AT +} + +"""An individual package""" +type SecurityAdvisoryPackage { + """The ecosystem the package belongs to, e.g. RUBYGEMS, NPM""" + ecosystem: SecurityAdvisoryEcosystem! + + """The package name""" + name: String! +} + +"""An individual package version""" +type SecurityAdvisoryPackageVersion { + """The package name or version""" + identifier: String! +} + +"""A GitHub Security Advisory Reference""" +type SecurityAdvisoryReference { + """A publicly accessible reference""" + url: URI! +} + +"""Severity of the vulnerability.""" +enum SecurityAdvisorySeverity { + """Low.""" + LOW + + """Moderate.""" + MODERATE + + """High.""" + HIGH + + """Critical.""" + CRITICAL +} + +"""An individual vulnerability within an Advisory""" +type SecurityVulnerability { + """The Advisory associated with this Vulnerability""" + advisory: SecurityAdvisory! + + """The first version containing a fix for the vulnerability""" + firstPatchedVersion: SecurityAdvisoryPackageVersion + + """A description of the vulnerable package""" + package: SecurityAdvisoryPackage! + + """The severity of the vulnerability within this package""" + severity: SecurityAdvisorySeverity! + + """When the vulnerability was last updated""" + updatedAt: DateTime! + + """ + A string that describes the vulnerable package versions. + This string follows a basic syntax with a few forms. + + `= 0.2.0` denotes a single vulnerable version. + + `<= 1.0.8` denotes a version range up to and including the specified version + + `< 0.1.11` denotes a version range up to, but excluding, the specified version + + `>= 4.3.0, < 4.3.5` denotes a version range with a known minimum and maximum version. + + `>= 0.0.1` denotes a version range with a known minimum, but no known maximum + + """ + vulnerableVersionRange: String! +} + +"""The connection type for SecurityVulnerability.""" +type SecurityVulnerabilityConnection { + """A list of edges.""" + edges: [SecurityVulnerabilityEdge] + + """A list of nodes.""" + nodes: [SecurityVulnerability] + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """Identifies the total count of items in the connection.""" + totalCount: Int! +} + +"""An edge in a connection.""" +type SecurityVulnerabilityEdge { + """A cursor for use in pagination.""" + cursor: String! + + """The item at the end of the edge.""" + node: SecurityVulnerability +} + +"""Ordering options for security vulnerability connections""" +input SecurityVulnerabilityOrder { + """The field to order security vulnerabilities by.""" + field: SecurityVulnerabilityOrderField! + + """The ordering direction.""" + direction: OrderDirection! +} + +""" +Properties by which security vulnerability connections can be ordered. +""" +enum SecurityVulnerabilityOrderField { + """Order vulnerability by update time""" + UPDATED_AT +} + +"""Autogenerated input type of SetBusinessIdentityProvider""" +input SetBusinessIdentityProviderInput { + """The ID of the business on which to set an Identity Provider.""" + businessId: ID! + + """The URL endpoint for the Identity Provider's SAML SSO.""" + ssoUrl: URI! + + """The Issuer Entity ID for the SAML Identity Provider""" + issuer: String + + """ + The x509 certificate used by the Identity Provider to sign assertions and responses. + """ + idpCertificate: String! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + """Represents an S/MIME signature on a Commit or Tag.""" type SmimeSignature implements GitSignature { """Email used to sign this object.""" @@ -9202,16 +10718,8 @@ type SubmitPullRequestReviewPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The submitted pull request review. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `pullRequestReview` will change from `PullRequestReview!` to `PullRequestReview`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - pullRequestReview: PullRequestReview! + """The submitted pull request review.""" + pullRequestReview: PullRequestReview } """Entities that can be subscribed to for web and email notifications.""" @@ -9300,7 +10808,7 @@ type Tag implements Node & GitObject { } """A team of users in an organization.""" -type Team implements Node & Subscribable { +type Team implements Node & Subscribable & MemberStatusable { """A list of teams that are ancestors of this team.""" ancestors( """ @@ -9389,6 +10897,30 @@ type Team implements Node & Subscribable { last: Int ): OrganizationInvitationConnection + """ + Get the status messages members of this entity have set that are either public or visible only to the organization. + """ + memberStatuses( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + + """Ordering options for user statuses returned from the connection.""" + orderBy: UserStatusOrder + ): UserStatusConnection! + """A list of users who are members of this team.""" members( """ @@ -9726,10 +11258,13 @@ type Topic implements Node & Starrable { """ A list of related topics, including aliases of this topic, sorted with the most relevant - first. + first. Returns up to 10 Topics. """ - relatedTopics: [Topic!]! + relatedTopics( + """How many topics to return.""" + first: Int = 3 + ): [Topic!]! """A list of users who have starred this starrable.""" stargazers( @@ -9965,8 +11500,30 @@ input UnminimizeCommentInput { """The Node ID of the subject to modify.""" subjectId: ID! - """A unique identifier for the client performing the mutation.""" - clientMutationId: String + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated input type of UnpinIssue""" +input UnpinIssueInput { + """The ID of the issue to be unpinned""" + issueId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Represents an 'unpinned' event on a given issue or pull request.""" +type UnpinnedEvent implements Node { + """Identifies the actor who performed the event.""" + actor: Actor + + """Identifies the date and time when the object was created.""" + createdAt: DateTime! + id: ID! + + """Identifies the issue associated with the event.""" + issue: Issue! } """Autogenerated input type of UnresolveReviewThread""" @@ -9978,6 +11535,15 @@ input UnresolveReviewThreadInput { clientMutationId: String } +"""Autogenerated return type of UnresolveReviewThread""" +type UnresolveReviewThreadPayload { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + + """The thread to resolve.""" + thread: PullRequestReviewThread +} + """Represents an 'unsubscribed' event on a given `Subscribable`.""" type UnsubscribedEvent implements Node { """Identifies the actor who performed the event.""" @@ -10069,6 +11635,162 @@ type UpdateBranchProtectionRulePayload { clientMutationId: String } +""" +Autogenerated input type of UpdateBusinessAllowPrivateRepositoryForkingSetting +""" +input UpdateBusinessAllowPrivateRepositoryForkingSettingInput { + """ + The ID of the business on which to set the allow private repository forking setting. + """ + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +""" +Autogenerated input type of UpdateBusinessDefaultRepositoryPermissionSetting +""" +input UpdateBusinessDefaultRepositoryPermissionSettingInput { + """ + The ID of the business on which to set the default repository permission setting. + """ + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +""" +Autogenerated input type of UpdateBusinessMembersCanChangeRepositoryVisibilitySetting +""" +input UpdateBusinessMembersCanChangeRepositoryVisibilitySettingInput { + """ + The ID of the business on which to set the members can change repository visibility setting. + """ + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +""" +Autogenerated input type of UpdateBusinessMembersCanCreateRepositoriesSetting +""" +input UpdateBusinessMembersCanCreateRepositoriesSettingInput { + """ + The ID of the business on which to set the members can create repositories setting. + """ + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +""" +Autogenerated input type of UpdateBusinessMembersCanDeleteIssuesSetting +""" +input UpdateBusinessMembersCanDeleteIssuesSettingInput { + """ + The ID of the business on which to set the members can delete issues setting. + """ + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +""" +Autogenerated input type of UpdateBusinessMembersCanDeleteRepositoriesSetting +""" +input UpdateBusinessMembersCanDeleteRepositoriesSettingInput { + """ + The ID of the business on which to set the members can delete repositories setting. + """ + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +""" +Autogenerated input type of UpdateBusinessMembersCanInviteCollaboratorsSetting +""" +input UpdateBusinessMembersCanInviteCollaboratorsSettingInput { + """ + The ID of the business on which to set the members can invite collaborators setting. + """ + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated input type of UpdateBusinessOrganizationProjectsSetting""" +input UpdateBusinessOrganizationProjectsSettingInput { + """ + The ID of the business on which to set the organization projects setting. + """ + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated input type of UpdateBusinessProfile""" +input UpdateBusinessProfileInput { + """The Business ID to update.""" + businessId: ID! + + """The name of business.""" + name: String + + """The description of the business.""" + description: String + + """The URL of the business's website""" + websiteUrl: String + + """The location of the business""" + location: String + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated input type of UpdateBusinessRepositoryProjectsSetting""" +input UpdateBusinessRepositoryProjectsSettingInput { + """ + The ID of the business on which to set the repository projects setting. + """ + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated input type of UpdateBusinessTeamDiscussionsSetting""" +input UpdateBusinessTeamDiscussionsSettingInput { + """The ID of the business on which to set the team discussions setting.""" + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +""" +Autogenerated input type of UpdateBusinessTwoFactorAuthenticationRequiredSetting +""" +input UpdateBusinessTwoFactorAuthenticationRequiredSettingInput { + """ + The ID of the business on which to set the two factor authentication required setting. + """ + businessId: ID! + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + """Autogenerated input type of UpdateProjectCard""" input UpdateProjectCardInput { """The ProjectCard ID to update.""" @@ -10089,16 +11811,8 @@ type UpdateProjectCardPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The updated ProjectCard. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `projectCard` will change from `ProjectCard!` to `ProjectCard`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - projectCard: ProjectCard! + """The updated ProjectCard.""" + projectCard: ProjectCard } """Autogenerated input type of UpdateProjectColumn""" @@ -10118,16 +11832,8 @@ type UpdateProjectColumnPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The updated project column. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `projectColumn` will change from `ProjectColumn!` to `ProjectColumn`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - projectColumn: ProjectColumn! + """The updated project column.""" + projectColumn: ProjectColumn } """Autogenerated input type of UpdateProject""" @@ -10156,16 +11862,42 @@ type UpdateProjectPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String + """The updated project.""" + project: Project +} + +"""Autogenerated input type of UpdatePullRequest""" +input UpdatePullRequestInput { + """The Node ID of the pull request.""" + pullRequestId: ID! + """ - The updated project. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `project` will change from `Project!` to `Project`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. + The name of the branch you want your changes pulled into. This should be an existing branch + on the current repository. """ - project: Project! + baseRefName: String + + """The title of the pull request.""" + title: String + + """The contents of the pull request.""" + body: String + + """Indicates whether maintainers can modify the pull request.""" + maintainerCanModify: Boolean + + """A unique identifier for the client performing the mutation.""" + clientMutationId: String +} + +"""Autogenerated return type of UpdatePullRequest""" +type UpdatePullRequestPayload { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + + """The updated pull request.""" + pullRequest: PullRequest } """Autogenerated input type of UpdatePullRequestReviewComment""" @@ -10185,17 +11917,8 @@ type UpdatePullRequestReviewCommentPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The updated comment. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `pullRequestReviewComment` will change from - `PullRequestReviewComment!` to `PullRequestReviewComment`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - pullRequestReviewComment: PullRequestReviewComment! + """The updated comment.""" + pullRequestReviewComment: PullRequestReviewComment } """Autogenerated input type of UpdatePullRequestReview""" @@ -10215,16 +11938,8 @@ type UpdatePullRequestReviewPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The updated pull request review. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `pullRequestReview` will change from `PullRequestReview!` to `PullRequestReview`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - pullRequestReview: PullRequestReview! + """The updated pull request review.""" + pullRequestReview: PullRequestReview } """Autogenerated input type of UpdateSubscription""" @@ -10244,16 +11959,8 @@ type UpdateSubscriptionPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String - """ - The input subscribable entity. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `subscribable` will change from `Subscribable!` to `Subscribable`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - subscribable: Subscribable! + """The input subscribable entity.""" + subscribable: Subscribable } """Autogenerated input type of UpdateTopics""" @@ -10276,16 +11983,8 @@ type UpdateTopicsPayload { """Names of the provided topics that are not valid.""" invalidTopicNames: [String!] - """ - The updated repository. - - **Upcoming Change on 2019-01-01 UTC** - **Description:** Type for `repository` will change from `Repository!` to `Repository`. - **Reason:** In preparation for an upcoming change to the way we report - mutation errors, non-nullable payload fields are becoming nullable. - - """ - repository: Repository! + """The updated repository.""" + repository: Repository } """An RFC 3986, RFC 3987, and RFC 6570 (level 4) compliant URI string.""" @@ -10294,7 +11993,7 @@ scalar URI """ A user is an individual's account on GitHub that owns repositories and can make new content. """ -type User implements Node & Actor & RegistryPackageOwner & RegistryPackageSearch & RepositoryOwner & UniformResourceLocatable { +type User implements Node & Actor & RegistryPackageOwner & RegistryPackageSearch & ProjectOwner & RepositoryOwner & UniformResourceLocatable { """A URL pointing to the user's public avatar.""" avatarUrl( """The size of the resulting square image.""" @@ -10599,6 +12298,46 @@ type User implements Node & Actor & RegistryPackageOwner & RegistryPackageSearch last: Int ): RepositoryConnection! + """Find project by number.""" + project( + """The project number to find.""" + number: Int! + ): Project + + """A list of projects under the owner.""" + projects( + """Ordering options for projects returned from the connection""" + orderBy: ProjectOrder + + """Query to search projects by, currently only searching by name.""" + search: String + + """A list of states to filter the projects by.""" + states: [ProjectState!] + + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """Returns the first _n_ elements from the list.""" + first: Int + + """Returns the last _n_ elements from the list.""" + last: Int + ): ProjectConnection! + + """The HTTP path listing user's projects""" + projectsResourcePath: URI! + + """The HTTP URL listing user's projects""" + projectsUrl: URI! + """A list of public keys associated with this user.""" publicKeys( """ @@ -10776,12 +12515,18 @@ type User implements Node & Actor & RegistryPackageOwner & RegistryPackageSearch last: Int ): StarredRepositoryConnection! + """The user's description of what they're currently doing.""" + status: UserStatus + """Identifies the date and time when the object was last updated.""" updatedAt: DateTime! """The HTTP URL for this user""" url: URI! + """Can the current viewer create new projects on this owner.""" + viewerCanCreateProjects: Boolean! + """Whether or not the viewer is able to follow the user.""" viewerCanFollow: Boolean! @@ -10905,5 +12650,75 @@ type UserEdge { node: User } +"""The user's description of what they're currently doing.""" +type UserStatus implements Node { + """Identifies the date and time when the object was created.""" + createdAt: DateTime! + + """An emoji summarizing the user's status.""" + emoji: String + + """ID of the object.""" + id: ID! + + """ + Whether this status indicates the user is not fully available on GitHub. + """ + indicatesLimitedAvailability: Boolean! + + """A brief message describing what the user is doing.""" + message: String + + """ + The organization whose members can see this status. If null, this status is publicly visible. + """ + organization: Organization + + """Identifies the date and time when the object was last updated.""" + updatedAt: DateTime! + + """The user who has this status.""" + user: User! +} + +"""The connection type for UserStatus.""" +type UserStatusConnection { + """A list of edges.""" + edges: [UserStatusEdge] + + """A list of nodes.""" + nodes: [UserStatus] + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """Identifies the total count of items in the connection.""" + totalCount: Int! +} + +"""An edge in a connection.""" +type UserStatusEdge { + """A cursor for use in pagination.""" + cursor: String! + + """The item at the end of the edge.""" + node: UserStatus +} + +"""Ordering options for user status connections.""" +input UserStatusOrder { + """The field to order user statuses by.""" + field: UserStatusOrderField! + + """The ordering direction.""" + direction: OrderDirection! +} + +"""Properties by which user status connections can be ordered.""" +enum UserStatusOrderField { + """Order user statuses by when they were updated.""" + UPDATED_AT +} + """A valid x509 certificate string""" scalar X509Certificate diff --git a/img/mona.svg b/img/mona.svg new file mode 100644 index 0000000000..25878ba975 --- /dev/null +++ b/img/mona.svg @@ -0,0 +1,75 @@ + + + + Mona + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/keymaps/git.cson b/keymaps/git.cson index 887217721b..661df96f14 100644 --- a/keymaps/git.cson +++ b/keymaps/git.cson @@ -9,6 +9,7 @@ 'alt-g p': 'github:push' 'alt-g shift-p': 'github:force-push' 'alt-g =': 'github:clone' + 'alt-g r': 'github:open-reviews-tab' 'atom-text-editor': 'alt-m 1': 'github:resolve-as-ours' @@ -132,3 +133,20 @@ 'end': 'github:co-author:end' 'delete': 'github:co-author:delete' 'shift-backspace': 'github:co-author-exclude' + +'.platform-darwin .github-Reviews': + 'cmd-=': 'github:more-context' + 'cmd--': 'github:less-context' +'.platform-win32 .github-Reviews': + 'ctrl-=': 'github:more-context' + 'ctrl--': 'github:less-context' +'.platform-linux .github-Reviews': + 'ctrl-=': 'github:more-context' + 'ctrl--': 'github:less-context' + +'.platform-darwin .github-Review-reply atom-text-editor': + 'cmd-enter': 'github:submit-comment' +'.platform-win32 .github-Review-reply atom-text-editor': + 'ctrl-enter': 'github:submit-comment' +'.platform-linux .github-Review-reply atom-text-editor': + 'ctrl-enter': 'github:submit-comment' diff --git a/lib/atom/atom-text-editor.js b/lib/atom/atom-text-editor.js index aedfe29971..0cfea8bcee 100644 --- a/lib/atom/atom-text-editor.js +++ b/lib/atom/atom-text-editor.js @@ -18,7 +18,7 @@ const editorUpdateProps = { }; const editorCreationProps = { - buffer: PropTypes.object, // FIXME make proptype more specific + buffer: PropTypes.object, ...editorUpdateProps, }; @@ -30,8 +30,6 @@ export default class AtomTextEditor extends React.Component { static propTypes = { ...editorCreationProps, - workspace: PropTypes.object.isRequired, - didChangeCursorPosition: PropTypes.func, didAddSelection: PropTypes.func, didChangeSelectionRange: PropTypes.func, diff --git a/lib/atom/decoration.js b/lib/atom/decoration.js index ff4804b41b..f1597cca8d 100644 --- a/lib/atom/decoration.js +++ b/lib/atom/decoration.js @@ -44,9 +44,11 @@ class BareDecoration extends React.Component { this.decorationHolder = new RefHolder(); this.editorSub = new Disposable(); this.decorableSub = new Disposable(); + this.gutterSub = new Disposable(); this.domNode = null; this.item = null; + if (['gutter', 'overlay', 'block'].includes(this.props.type)) { this.domNode = document.createElement('div'); this.domNode.className = cx('react-atom-decoration', this.props.className); @@ -115,6 +117,23 @@ class BareDecoration extends React.Component { return; } + // delay decoration creation when it's a gutter type; + // instead wait for the Gutter to be added to the editor first + if (this.props.type === 'gutter') { + if (!this.props.gutterName) { + throw new Error('You are trying to decorate a gutter but did not supply gutterName prop.'); + } + this.props.editorHolder.map(editor => { + this.gutterSub = editor.observeGutters(gutter => { + if (gutter.name === this.props.gutterName) { + this.createDecoration(); + } + }); + return null; + }); + return; + } + this.createDecoration(); } @@ -126,7 +145,6 @@ class BareDecoration extends React.Component { const opts = this.getDecorationOpts(this.props); const editor = this.props.editorHolder.get(); const decorable = this.props.decorableHolder.get(); - this.decorationHolder.setter( editor[this.props.decorateMethod](decorable, opts), ); @@ -136,6 +154,7 @@ class BareDecoration extends React.Component { this.decorationHolder.map(decoration => decoration.destroy()); this.editorSub.dispose(); this.decorableSub.dispose(); + this.gutterSub.dispose(); } getDecorationOpts(props) { @@ -150,6 +169,7 @@ export default class Decoration extends React.Component { static propTypes = { editor: PropTypes.object, decorable: PropTypes.object, + decorateMethod: PropTypes.oneOf(['decorateMarker', 'decorateMarkerLayer']), } constructor(props) { @@ -184,28 +204,30 @@ export default class Decoration extends React.Component { } render() { - if (!this.state.editorHolder.isEmpty() && !this.state.decorableHolder.isEmpty()) { - return ( - - ); - } - return ( {editorHolder => ( - {({holder, decorateMethod}) => ( - - )} + {decorable => { + let holder = null; + let decorateMethod = null; + if (!this.state.decorableHolder.isEmpty()) { + holder = this.state.decorableHolder; + decorateMethod = this.props.decorateMethod; + } else { + holder = decorable.holder; + decorateMethod = decorable.decorateMethod; + } + + return ( + + ); + }} )} diff --git a/lib/atom/marker.js b/lib/atom/marker.js index 859ee74dd7..0fed0f7e01 100644 --- a/lib/atom/marker.js +++ b/lib/atom/marker.js @@ -13,6 +13,7 @@ const MarkablePropType = PropTypes.shape({ }); const markerProps = { + exclusive: PropTypes.bool, reversed: PropTypes.bool, invalidate: PropTypes.oneOf(['never', 'surround', 'overlap', 'inside', 'touch']), }; diff --git a/lib/atom/pane-item.js b/lib/atom/pane-item.js index f564d021d0..f4c306c2de 100644 --- a/lib/atom/pane-item.js +++ b/lib/atom/pane-item.js @@ -173,6 +173,7 @@ class OpenItem { this.domNode.tabIndex = '-1'; this.domNode.onfocus = this.onFocus.bind(this); this.stubItem = stub; + this.stubProps = stub ? stub.props : {}; this.match = match; this.itemHolder = new RefHolder(); } @@ -202,15 +203,15 @@ class OpenItem { } getStubProps() { - if (!this.itemHolder.isEmpty()) { - const item = this.itemHolder.get(); - return { - title: item.getTitle ? item.getTitle() : null, - iconName: item.getIconName ? item.getIconName() : null, - }; - } else { - return {}; - } + const itemProps = this.itemHolder.map(item => ({ + title: item.getTitle ? item.getTitle() : null, + iconName: item.getIconName ? item.getIconName() : null, + })); + + return { + ...this.stubProps, + ...itemProps, + }; } onFocus() { @@ -220,6 +221,7 @@ class OpenItem { renderPortal(renderProp) { return ReactDOM.createPortal( renderProp({ + deserialized: this.stubProps, itemHolder: this.itemHolder, params: this.match.getParams(), uri: this.match.getURI(), diff --git a/lib/atom/tooltip.js b/lib/atom/tooltip.js index 16d0f8de30..b1420e9d0f 100644 --- a/lib/atom/tooltip.js +++ b/lib/atom/tooltip.js @@ -36,6 +36,7 @@ export default class Tooltip extends React.Component { keyBindingTarget: PropTypes.element, children: PropTypes.element, itemHolder: RefHolderPropType, + tooltipHolder: RefHolderPropType, } static defaultProps = { @@ -126,6 +127,10 @@ export default class Tooltip extends React.Component { this.refSub = this.props.target.observe(t => { this.tipSub.dispose(); this.tipSub = this.props.manager.add(t, options); + const h = this.props.tooltipHolder; + if (h) { + h.setter(this.tipSub); + } }); } } diff --git a/lib/containers/__generated__/aggregatedReviewsContainerRefetchQuery.graphql.js b/lib/containers/__generated__/aggregatedReviewsContainerRefetchQuery.graphql.js new file mode 100644 index 0000000000..36c2b89b03 --- /dev/null +++ b/lib/containers/__generated__/aggregatedReviewsContainerRefetchQuery.graphql.js @@ -0,0 +1,815 @@ +/** + * @flow + * @relayHash 192d473345d7132542cc7b88318b7cc1 + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest } from 'relay-runtime'; +type aggregatedReviewsContainer_pullRequest$ref = any; +type prCheckoutController_pullRequest$ref = any; +export type aggregatedReviewsContainerRefetchQueryVariables = {| + prId: string, + reviewCount: number, + reviewCursor?: ?string, + threadCount: number, + threadCursor?: ?string, + commentCount: number, + commentCursor?: ?string, +|}; +export type aggregatedReviewsContainerRefetchQueryResponse = {| + +pullRequest: ?{| + +$fragmentRefs: prCheckoutController_pullRequest$ref & aggregatedReviewsContainer_pullRequest$ref + |} +|}; +export type aggregatedReviewsContainerRefetchQuery = {| + variables: aggregatedReviewsContainerRefetchQueryVariables, + response: aggregatedReviewsContainerRefetchQueryResponse, +|}; +*/ + + +/* +query aggregatedReviewsContainerRefetchQuery( + $prId: ID! + $reviewCount: Int! + $reviewCursor: String + $threadCount: Int! + $threadCursor: String + $commentCount: Int! + $commentCursor: String +) { + pullRequest: node(id: $prId) { + __typename + ...prCheckoutController_pullRequest + ...aggregatedReviewsContainer_pullRequest_qdneZ + id + } +} + +fragment prCheckoutController_pullRequest on PullRequest { + number + headRefName + headRepository { + name + url + sshUrl + owner { + __typename + login + id + } + id + } +} + +fragment aggregatedReviewsContainer_pullRequest_qdneZ on PullRequest { + id + ...reviewSummariesAccumulator_pullRequest_2zzc96 + ...reviewThreadsAccumulator_pullRequest_CKDvj +} + +fragment reviewSummariesAccumulator_pullRequest_2zzc96 on PullRequest { + url + reviews(first: $reviewCount, after: $reviewCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + bodyHTML + state + submittedAt + author { + __typename + login + avatarUrl + ... on Node { + id + } + } + ...emojiReactionsController_reactable + __typename + } + } + } +} + +fragment reviewThreadsAccumulator_pullRequest_CKDvj on PullRequest { + url + reviewThreads(first: $threadCount, after: $threadCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + isResolved + resolvedBy { + login + id + } + viewerCanResolve + viewerCanUnresolve + ...reviewCommentsAccumulator_reviewThread_1VbUmL + __typename + } + } + } +} + +fragment reviewCommentsAccumulator_reviewThread_1VbUmL on PullRequestReviewThread { + id + comments(first: $commentCount, after: $commentCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + author { + __typename + avatarUrl + login + ... on Node { + id + } + } + bodyHTML + isMinimized + state + viewerCanReact + path + position + createdAt + url + ...emojiReactionsController_reactable + __typename + } + } + } +} + +fragment emojiReactionsController_reactable on Reactable { + id + ...emojiReactionsView_reactable +} + +fragment emojiReactionsView_reactable on Reactable { + id + reactionGroups { + content + viewerHasReacted + users { + totalCount + } + } + viewerCanReact +} +*/ + +const node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "kind": "LocalArgument", + "name": "prId", + "type": "ID!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "reviewCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "reviewCursor", + "type": "String", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "threadCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "threadCursor", + "type": "String", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "commentCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "commentCursor", + "type": "String", + "defaultValue": null + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "prId", + "type": "ID!" + } +], +v2 = { + "kind": "ScalarField", + "alias": null, + "name": "__typename", + "args": null, + "storageKey": null +}, +v3 = { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null +}, +v4 = { + "kind": "ScalarField", + "alias": null, + "name": "url", + "args": null, + "storageKey": null +}, +v5 = { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null +}, +v6 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "reviewCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "reviewCount", + "type": "Int" + } +], +v7 = { + "kind": "LinkedField", + "alias": null, + "name": "pageInfo", + "storageKey": null, + "args": null, + "concreteType": "PageInfo", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "hasNextPage", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "endCursor", + "args": null, + "storageKey": null + } + ] +}, +v8 = { + "kind": "ScalarField", + "alias": null, + "name": "cursor", + "args": null, + "storageKey": null +}, +v9 = { + "kind": "ScalarField", + "alias": null, + "name": "bodyHTML", + "args": null, + "storageKey": null +}, +v10 = { + "kind": "ScalarField", + "alias": null, + "name": "state", + "args": null, + "storageKey": null +}, +v11 = { + "kind": "ScalarField", + "alias": null, + "name": "avatarUrl", + "args": null, + "storageKey": null +}, +v12 = { + "kind": "LinkedField", + "alias": null, + "name": "reactionGroups", + "storageKey": null, + "args": null, + "concreteType": "ReactionGroup", + "plural": true, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "content", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerHasReacted", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "users", + "storageKey": null, + "args": null, + "concreteType": "ReactingUserConnection", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "totalCount", + "args": null, + "storageKey": null + } + ] + } + ] +}, +v13 = { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanReact", + "args": null, + "storageKey": null +}, +v14 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "threadCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "threadCount", + "type": "Int" + } +], +v15 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "commentCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "commentCount", + "type": "Int" + } +]; +return { + "kind": "Request", + "fragment": { + "kind": "Fragment", + "name": "aggregatedReviewsContainerRefetchQuery", + "type": "Query", + "metadata": null, + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": "pullRequest", + "name": "node", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": null, + "plural": false, + "selections": [ + { + "kind": "FragmentSpread", + "name": "prCheckoutController_pullRequest", + "args": null + }, + { + "kind": "FragmentSpread", + "name": "aggregatedReviewsContainer_pullRequest", + "args": [ + { + "kind": "Variable", + "name": "commentCount", + "variableName": "commentCount", + "type": null + }, + { + "kind": "Variable", + "name": "commentCursor", + "variableName": "commentCursor", + "type": null + }, + { + "kind": "Variable", + "name": "reviewCount", + "variableName": "reviewCount", + "type": null + }, + { + "kind": "Variable", + "name": "reviewCursor", + "variableName": "reviewCursor", + "type": null + }, + { + "kind": "Variable", + "name": "threadCount", + "variableName": "threadCount", + "type": null + }, + { + "kind": "Variable", + "name": "threadCursor", + "variableName": "threadCursor", + "type": null + } + ] + } + ] + } + ] + }, + "operation": { + "kind": "Operation", + "name": "aggregatedReviewsContainerRefetchQuery", + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": "pullRequest", + "name": "node", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": null, + "plural": false, + "selections": [ + (v2/*: any*/), + (v3/*: any*/), + { + "kind": "InlineFragment", + "type": "PullRequest", + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "number", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "headRefName", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "headRepository", + "storageKey": null, + "args": null, + "concreteType": "Repository", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "name", + "args": null, + "storageKey": null + }, + (v4/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "sshUrl", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "owner", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v2/*: any*/), + (v5/*: any*/), + (v3/*: any*/) + ] + }, + (v3/*: any*/) + ] + }, + (v4/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "reviews", + "storageKey": null, + "args": (v6/*: any*/), + "concreteType": "PullRequestReviewConnection", + "plural": false, + "selections": [ + (v7/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewEdge", + "plural": true, + "selections": [ + (v8/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReview", + "plural": false, + "selections": [ + (v3/*: any*/), + (v9/*: any*/), + (v10/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "submittedAt", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "author", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v2/*: any*/), + (v5/*: any*/), + (v11/*: any*/), + (v3/*: any*/) + ] + }, + (v12/*: any*/), + (v13/*: any*/), + (v2/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "reviews", + "args": (v6/*: any*/), + "handle": "connection", + "key": "ReviewSummariesAccumulator_reviews", + "filters": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "reviewThreads", + "storageKey": null, + "args": (v14/*: any*/), + "concreteType": "PullRequestReviewThreadConnection", + "plural": false, + "selections": [ + (v7/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewThreadEdge", + "plural": true, + "selections": [ + (v8/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewThread", + "plural": false, + "selections": [ + (v3/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "isResolved", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "resolvedBy", + "storageKey": null, + "args": null, + "concreteType": "User", + "plural": false, + "selections": [ + (v5/*: any*/), + (v3/*: any*/) + ] + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanResolve", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanUnresolve", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "comments", + "storageKey": null, + "args": (v15/*: any*/), + "concreteType": "PullRequestReviewCommentConnection", + "plural": false, + "selections": [ + (v7/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewCommentEdge", + "plural": true, + "selections": [ + (v8/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewComment", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "path", + "args": null, + "storageKey": null + }, + (v3/*: any*/), + (v9/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "isMinimized", + "args": null, + "storageKey": null + }, + (v10/*: any*/), + (v13/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "author", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v2/*: any*/), + (v11/*: any*/), + (v5/*: any*/), + (v3/*: any*/) + ] + }, + { + "kind": "ScalarField", + "alias": null, + "name": "position", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "createdAt", + "args": null, + "storageKey": null + }, + (v4/*: any*/), + (v12/*: any*/), + (v2/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "comments", + "args": (v15/*: any*/), + "handle": "connection", + "key": "ReviewCommentsAccumulator_comments", + "filters": null + }, + (v2/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "reviewThreads", + "args": (v14/*: any*/), + "handle": "connection", + "key": "ReviewThreadsAccumulator_reviewThreads", + "filters": null + } + ] + } + ] + } + ] + }, + "params": { + "operationKind": "query", + "name": "aggregatedReviewsContainerRefetchQuery", + "id": null, + "text": "query aggregatedReviewsContainerRefetchQuery(\n $prId: ID!\n $reviewCount: Int!\n $reviewCursor: String\n $threadCount: Int!\n $threadCursor: String\n $commentCount: Int!\n $commentCursor: String\n) {\n pullRequest: node(id: $prId) {\n __typename\n ...prCheckoutController_pullRequest\n ...aggregatedReviewsContainer_pullRequest_qdneZ\n id\n }\n}\n\nfragment prCheckoutController_pullRequest on PullRequest {\n number\n headRefName\n headRepository {\n name\n url\n sshUrl\n owner {\n __typename\n login\n id\n }\n id\n }\n}\n\nfragment aggregatedReviewsContainer_pullRequest_qdneZ on PullRequest {\n id\n ...reviewSummariesAccumulator_pullRequest_2zzc96\n ...reviewThreadsAccumulator_pullRequest_CKDvj\n}\n\nfragment reviewSummariesAccumulator_pullRequest_2zzc96 on PullRequest {\n url\n reviews(first: $reviewCount, after: $reviewCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n bodyHTML\n state\n submittedAt\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n ...emojiReactionsController_reactable\n __typename\n }\n }\n }\n}\n\nfragment reviewThreadsAccumulator_pullRequest_CKDvj on PullRequest {\n url\n reviewThreads(first: $threadCount, after: $threadCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n isResolved\n resolvedBy {\n login\n id\n }\n viewerCanResolve\n viewerCanUnresolve\n ...reviewCommentsAccumulator_reviewThread_1VbUmL\n __typename\n }\n }\n }\n}\n\nfragment reviewCommentsAccumulator_reviewThread_1VbUmL on PullRequestReviewThread {\n id\n comments(first: $commentCount, after: $commentCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n isMinimized\n state\n viewerCanReact\n path\n position\n createdAt\n url\n ...emojiReactionsController_reactable\n __typename\n }\n }\n }\n}\n\nfragment emojiReactionsController_reactable on Reactable {\n id\n ...emojiReactionsView_reactable\n}\n\nfragment emojiReactionsView_reactable on Reactable {\n id\n reactionGroups {\n content\n viewerHasReacted\n users {\n totalCount\n }\n }\n viewerCanReact\n}\n", + "metadata": {} + } +}; +})(); +// prettier-ignore +(node/*: any*/).hash = '2bf1bb4fa69d264bcecbe81f41621908'; +module.exports = node; diff --git a/lib/containers/__generated__/aggregatedReviewsContainer_pullRequest.graphql.js b/lib/containers/__generated__/aggregatedReviewsContainer_pullRequest.graphql.js new file mode 100644 index 0000000000..89e7ce93f8 --- /dev/null +++ b/lib/containers/__generated__/aggregatedReviewsContainer_pullRequest.graphql.js @@ -0,0 +1,126 @@ +/** + * @flow + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ReaderFragment } from 'relay-runtime'; +type reviewSummariesAccumulator_pullRequest$ref = any; +type reviewThreadsAccumulator_pullRequest$ref = any; +import type { FragmentReference } from "relay-runtime"; +declare export opaque type aggregatedReviewsContainer_pullRequest$ref: FragmentReference; +export type aggregatedReviewsContainer_pullRequest = {| + +id: string, + +$fragmentRefs: reviewSummariesAccumulator_pullRequest$ref & reviewThreadsAccumulator_pullRequest$ref, + +$refType: aggregatedReviewsContainer_pullRequest$ref, +|}; +*/ + + +const node/*: ReaderFragment*/ = { + "kind": "Fragment", + "name": "aggregatedReviewsContainer_pullRequest", + "type": "PullRequest", + "metadata": null, + "argumentDefinitions": [ + { + "kind": "LocalArgument", + "name": "reviewCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "reviewCursor", + "type": "String", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "threadCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "threadCursor", + "type": "String", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "commentCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "commentCursor", + "type": "String", + "defaultValue": null + } + ], + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null + }, + { + "kind": "FragmentSpread", + "name": "reviewSummariesAccumulator_pullRequest", + "args": [ + { + "kind": "Variable", + "name": "reviewCount", + "variableName": "reviewCount", + "type": null + }, + { + "kind": "Variable", + "name": "reviewCursor", + "variableName": "reviewCursor", + "type": null + } + ] + }, + { + "kind": "FragmentSpread", + "name": "reviewThreadsAccumulator_pullRequest", + "args": [ + { + "kind": "Variable", + "name": "commentCount", + "variableName": "commentCount", + "type": null + }, + { + "kind": "Variable", + "name": "commentCursor", + "variableName": "commentCursor", + "type": null + }, + { + "kind": "Variable", + "name": "threadCount", + "variableName": "threadCount", + "type": null + }, + { + "kind": "Variable", + "name": "threadCursor", + "variableName": "threadCursor", + "type": null + } + ] + } + ] +}; +// prettier-ignore +(node/*: any*/).hash = '830225d5b83d6c320e16cf824fe0cca6'; +module.exports = node; diff --git a/lib/containers/__generated__/commentDecorationsContainerQuery.graphql.js b/lib/containers/__generated__/commentDecorationsContainerQuery.graphql.js new file mode 100644 index 0000000000..fdfeb1a497 --- /dev/null +++ b/lib/containers/__generated__/commentDecorationsContainerQuery.graphql.js @@ -0,0 +1,974 @@ +/** + * @flow + * @relayHash f3382815c97b25306e67683ad4004e03 + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest } from 'relay-runtime'; +type aggregatedReviewsContainer_pullRequest$ref = any; +type commentDecorationsController_pullRequests$ref = any; +export type commentDecorationsContainerQueryVariables = {| + headOwner: string, + headName: string, + headRef: string, + reviewCount: number, + reviewCursor?: ?string, + threadCount: number, + threadCursor?: ?string, + commentCount: number, + commentCursor?: ?string, + first: number, +|}; +export type commentDecorationsContainerQueryResponse = {| + +repository: ?{| + +ref: ?{| + +associatedPullRequests: {| + +totalCount: number, + +nodes: ?$ReadOnlyArray, + |} + |} + |} +|}; +export type commentDecorationsContainerQuery = {| + variables: commentDecorationsContainerQueryVariables, + response: commentDecorationsContainerQueryResponse, +|}; +*/ + + +/* +query commentDecorationsContainerQuery( + $headOwner: String! + $headName: String! + $headRef: String! + $reviewCount: Int! + $reviewCursor: String + $threadCount: Int! + $threadCursor: String + $commentCount: Int! + $commentCursor: String + $first: Int! +) { + repository(owner: $headOwner, name: $headName) { + ref(qualifiedName: $headRef) { + associatedPullRequests(first: $first, states: [OPEN]) { + totalCount + nodes { + number + headRefOid + ...commentDecorationsController_pullRequests + ...aggregatedReviewsContainer_pullRequest_qdneZ + id + } + } + id + } + id + } +} + +fragment commentDecorationsController_pullRequests on PullRequest { + number + headRefName + headRefOid + headRepository { + name + owner { + __typename + login + id + } + id + } + repository { + name + owner { + __typename + login + id + } + id + } +} + +fragment aggregatedReviewsContainer_pullRequest_qdneZ on PullRequest { + id + ...reviewSummariesAccumulator_pullRequest_2zzc96 + ...reviewThreadsAccumulator_pullRequest_CKDvj +} + +fragment reviewSummariesAccumulator_pullRequest_2zzc96 on PullRequest { + url + reviews(first: $reviewCount, after: $reviewCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + bodyHTML + state + submittedAt + author { + __typename + login + avatarUrl + ... on Node { + id + } + } + ...emojiReactionsController_reactable + __typename + } + } + } +} + +fragment reviewThreadsAccumulator_pullRequest_CKDvj on PullRequest { + url + reviewThreads(first: $threadCount, after: $threadCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + isResolved + resolvedBy { + login + id + } + viewerCanResolve + viewerCanUnresolve + ...reviewCommentsAccumulator_reviewThread_1VbUmL + __typename + } + } + } +} + +fragment reviewCommentsAccumulator_reviewThread_1VbUmL on PullRequestReviewThread { + id + comments(first: $commentCount, after: $commentCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + author { + __typename + avatarUrl + login + ... on Node { + id + } + } + bodyHTML + isMinimized + state + viewerCanReact + path + position + createdAt + url + ...emojiReactionsController_reactable + __typename + } + } + } +} + +fragment emojiReactionsController_reactable on Reactable { + id + ...emojiReactionsView_reactable +} + +fragment emojiReactionsView_reactable on Reactable { + id + reactionGroups { + content + viewerHasReacted + users { + totalCount + } + } + viewerCanReact +} +*/ + +const node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "kind": "LocalArgument", + "name": "headOwner", + "type": "String!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "headName", + "type": "String!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "headRef", + "type": "String!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "reviewCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "reviewCursor", + "type": "String", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "threadCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "threadCursor", + "type": "String", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "commentCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "commentCursor", + "type": "String", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "first", + "type": "Int!", + "defaultValue": null + } +], +v1 = [ + { + "kind": "Variable", + "name": "name", + "variableName": "headName", + "type": "String!" + }, + { + "kind": "Variable", + "name": "owner", + "variableName": "headOwner", + "type": "String!" + } +], +v2 = [ + { + "kind": "Variable", + "name": "qualifiedName", + "variableName": "headRef", + "type": "String!" + } +], +v3 = [ + { + "kind": "Variable", + "name": "first", + "variableName": "first", + "type": "Int" + }, + { + "kind": "Literal", + "name": "states", + "value": [ + "OPEN" + ], + "type": "[PullRequestState!]" + } +], +v4 = { + "kind": "ScalarField", + "alias": null, + "name": "totalCount", + "args": null, + "storageKey": null +}, +v5 = { + "kind": "ScalarField", + "alias": null, + "name": "number", + "args": null, + "storageKey": null +}, +v6 = { + "kind": "ScalarField", + "alias": null, + "name": "headRefOid", + "args": null, + "storageKey": null +}, +v7 = { + "kind": "ScalarField", + "alias": null, + "name": "__typename", + "args": null, + "storageKey": null +}, +v8 = { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null +}, +v9 = { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null +}, +v10 = [ + { + "kind": "ScalarField", + "alias": null, + "name": "name", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "owner", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v7/*: any*/), + (v8/*: any*/), + (v9/*: any*/) + ] + }, + (v9/*: any*/) +], +v11 = { + "kind": "ScalarField", + "alias": null, + "name": "url", + "args": null, + "storageKey": null +}, +v12 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "reviewCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "reviewCount", + "type": "Int" + } +], +v13 = { + "kind": "LinkedField", + "alias": null, + "name": "pageInfo", + "storageKey": null, + "args": null, + "concreteType": "PageInfo", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "hasNextPage", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "endCursor", + "args": null, + "storageKey": null + } + ] +}, +v14 = { + "kind": "ScalarField", + "alias": null, + "name": "cursor", + "args": null, + "storageKey": null +}, +v15 = { + "kind": "ScalarField", + "alias": null, + "name": "bodyHTML", + "args": null, + "storageKey": null +}, +v16 = { + "kind": "ScalarField", + "alias": null, + "name": "state", + "args": null, + "storageKey": null +}, +v17 = { + "kind": "ScalarField", + "alias": null, + "name": "avatarUrl", + "args": null, + "storageKey": null +}, +v18 = { + "kind": "LinkedField", + "alias": null, + "name": "reactionGroups", + "storageKey": null, + "args": null, + "concreteType": "ReactionGroup", + "plural": true, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "content", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerHasReacted", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "users", + "storageKey": null, + "args": null, + "concreteType": "ReactingUserConnection", + "plural": false, + "selections": [ + (v4/*: any*/) + ] + } + ] +}, +v19 = { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanReact", + "args": null, + "storageKey": null +}, +v20 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "threadCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "threadCount", + "type": "Int" + } +], +v21 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "commentCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "commentCount", + "type": "Int" + } +]; +return { + "kind": "Request", + "fragment": { + "kind": "Fragment", + "name": "commentDecorationsContainerQuery", + "type": "Query", + "metadata": null, + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "repository", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": "Repository", + "plural": false, + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "ref", + "storageKey": null, + "args": (v2/*: any*/), + "concreteType": "Ref", + "plural": false, + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "associatedPullRequests", + "storageKey": null, + "args": (v3/*: any*/), + "concreteType": "PullRequestConnection", + "plural": false, + "selections": [ + (v4/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "nodes", + "storageKey": null, + "args": null, + "concreteType": "PullRequest", + "plural": true, + "selections": [ + (v5/*: any*/), + (v6/*: any*/), + { + "kind": "FragmentSpread", + "name": "commentDecorationsController_pullRequests", + "args": null + }, + { + "kind": "FragmentSpread", + "name": "aggregatedReviewsContainer_pullRequest", + "args": [ + { + "kind": "Variable", + "name": "commentCount", + "variableName": "commentCount", + "type": null + }, + { + "kind": "Variable", + "name": "commentCursor", + "variableName": "commentCursor", + "type": null + }, + { + "kind": "Variable", + "name": "reviewCount", + "variableName": "reviewCount", + "type": null + }, + { + "kind": "Variable", + "name": "reviewCursor", + "variableName": "reviewCursor", + "type": null + }, + { + "kind": "Variable", + "name": "threadCount", + "variableName": "threadCount", + "type": null + }, + { + "kind": "Variable", + "name": "threadCursor", + "variableName": "threadCursor", + "type": null + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "operation": { + "kind": "Operation", + "name": "commentDecorationsContainerQuery", + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "repository", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": "Repository", + "plural": false, + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "ref", + "storageKey": null, + "args": (v2/*: any*/), + "concreteType": "Ref", + "plural": false, + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "associatedPullRequests", + "storageKey": null, + "args": (v3/*: any*/), + "concreteType": "PullRequestConnection", + "plural": false, + "selections": [ + (v4/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "nodes", + "storageKey": null, + "args": null, + "concreteType": "PullRequest", + "plural": true, + "selections": [ + (v5/*: any*/), + (v6/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "headRefName", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "headRepository", + "storageKey": null, + "args": null, + "concreteType": "Repository", + "plural": false, + "selections": (v10/*: any*/) + }, + { + "kind": "LinkedField", + "alias": null, + "name": "repository", + "storageKey": null, + "args": null, + "concreteType": "Repository", + "plural": false, + "selections": (v10/*: any*/) + }, + (v9/*: any*/), + (v11/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "reviews", + "storageKey": null, + "args": (v12/*: any*/), + "concreteType": "PullRequestReviewConnection", + "plural": false, + "selections": [ + (v13/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewEdge", + "plural": true, + "selections": [ + (v14/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReview", + "plural": false, + "selections": [ + (v9/*: any*/), + (v15/*: any*/), + (v16/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "submittedAt", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "author", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v7/*: any*/), + (v8/*: any*/), + (v17/*: any*/), + (v9/*: any*/) + ] + }, + (v18/*: any*/), + (v19/*: any*/), + (v7/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "reviews", + "args": (v12/*: any*/), + "handle": "connection", + "key": "ReviewSummariesAccumulator_reviews", + "filters": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "reviewThreads", + "storageKey": null, + "args": (v20/*: any*/), + "concreteType": "PullRequestReviewThreadConnection", + "plural": false, + "selections": [ + (v13/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewThreadEdge", + "plural": true, + "selections": [ + (v14/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewThread", + "plural": false, + "selections": [ + (v9/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "isResolved", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "resolvedBy", + "storageKey": null, + "args": null, + "concreteType": "User", + "plural": false, + "selections": [ + (v8/*: any*/), + (v9/*: any*/) + ] + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanResolve", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanUnresolve", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "comments", + "storageKey": null, + "args": (v21/*: any*/), + "concreteType": "PullRequestReviewCommentConnection", + "plural": false, + "selections": [ + (v13/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewCommentEdge", + "plural": true, + "selections": [ + (v14/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewComment", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "path", + "args": null, + "storageKey": null + }, + (v9/*: any*/), + (v15/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "isMinimized", + "args": null, + "storageKey": null + }, + (v16/*: any*/), + (v19/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "author", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v7/*: any*/), + (v17/*: any*/), + (v8/*: any*/), + (v9/*: any*/) + ] + }, + { + "kind": "ScalarField", + "alias": null, + "name": "position", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "createdAt", + "args": null, + "storageKey": null + }, + (v11/*: any*/), + (v18/*: any*/), + (v7/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "comments", + "args": (v21/*: any*/), + "handle": "connection", + "key": "ReviewCommentsAccumulator_comments", + "filters": null + }, + (v7/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "reviewThreads", + "args": (v20/*: any*/), + "handle": "connection", + "key": "ReviewThreadsAccumulator_reviewThreads", + "filters": null + } + ] + } + ] + }, + (v9/*: any*/) + ] + }, + (v9/*: any*/) + ] + } + ] + }, + "params": { + "operationKind": "query", + "name": "commentDecorationsContainerQuery", + "id": null, + "text": "query commentDecorationsContainerQuery(\n $headOwner: String!\n $headName: String!\n $headRef: String!\n $reviewCount: Int!\n $reviewCursor: String\n $threadCount: Int!\n $threadCursor: String\n $commentCount: Int!\n $commentCursor: String\n $first: Int!\n) {\n repository(owner: $headOwner, name: $headName) {\n ref(qualifiedName: $headRef) {\n associatedPullRequests(first: $first, states: [OPEN]) {\n totalCount\n nodes {\n number\n headRefOid\n ...commentDecorationsController_pullRequests\n ...aggregatedReviewsContainer_pullRequest_qdneZ\n id\n }\n }\n id\n }\n id\n }\n}\n\nfragment commentDecorationsController_pullRequests on PullRequest {\n number\n headRefName\n headRefOid\n headRepository {\n name\n owner {\n __typename\n login\n id\n }\n id\n }\n repository {\n name\n owner {\n __typename\n login\n id\n }\n id\n }\n}\n\nfragment aggregatedReviewsContainer_pullRequest_qdneZ on PullRequest {\n id\n ...reviewSummariesAccumulator_pullRequest_2zzc96\n ...reviewThreadsAccumulator_pullRequest_CKDvj\n}\n\nfragment reviewSummariesAccumulator_pullRequest_2zzc96 on PullRequest {\n url\n reviews(first: $reviewCount, after: $reviewCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n bodyHTML\n state\n submittedAt\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n ...emojiReactionsController_reactable\n __typename\n }\n }\n }\n}\n\nfragment reviewThreadsAccumulator_pullRequest_CKDvj on PullRequest {\n url\n reviewThreads(first: $threadCount, after: $threadCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n isResolved\n resolvedBy {\n login\n id\n }\n viewerCanResolve\n viewerCanUnresolve\n ...reviewCommentsAccumulator_reviewThread_1VbUmL\n __typename\n }\n }\n }\n}\n\nfragment reviewCommentsAccumulator_reviewThread_1VbUmL on PullRequestReviewThread {\n id\n comments(first: $commentCount, after: $commentCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n isMinimized\n state\n viewerCanReact\n path\n position\n createdAt\n url\n ...emojiReactionsController_reactable\n __typename\n }\n }\n }\n}\n\nfragment emojiReactionsController_reactable on Reactable {\n id\n ...emojiReactionsView_reactable\n}\n\nfragment emojiReactionsView_reactable on Reactable {\n id\n reactionGroups {\n content\n viewerHasReacted\n users {\n totalCount\n }\n }\n viewerCanReact\n}\n", + "metadata": {} + } +}; +})(); +// prettier-ignore +(node/*: any*/).hash = '8154acbf4c24d190f6fdf0254ae73817'; +module.exports = node; diff --git a/lib/containers/__generated__/currentPullRequestContainerQuery.graphql.js b/lib/containers/__generated__/currentPullRequestContainerQuery.graphql.js index 1d731a4e22..89632146e4 100644 --- a/lib/containers/__generated__/currentPullRequestContainerQuery.graphql.js +++ b/lib/containers/__generated__/currentPullRequestContainerQuery.graphql.js @@ -1,6 +1,6 @@ /** * @flow - * @relayHash 613bbee977dd8377c2b61e1755aca140 + * @relayHash f016f3e168638d3fc1cf3a098a60cfa6 */ /* eslint-disable */ @@ -73,6 +73,12 @@ fragment issueishListController_results on PullRequest { headRefName repository { id + name + owner { + __typename + login + id + } } commits(last: 1) { nodes { @@ -165,6 +171,20 @@ v4 = { "storageKey": null }, v5 = { + "kind": "ScalarField", + "alias": null, + "name": "__typename", + "args": null, + "storageKey": null +}, +v6 = { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null +}, +v7 = { "kind": "ScalarField", "alias": null, "name": "id", @@ -304,20 +324,8 @@ return { "concreteType": null, "plural": false, "selections": [ - { - "kind": "ScalarField", - "alias": null, - "name": "__typename", - "args": null, - "storageKey": null - }, - { - "kind": "ScalarField", - "alias": null, - "name": "login", - "args": null, - "storageKey": null - }, + (v5/*: any*/), + (v6/*: any*/), { "kind": "ScalarField", "alias": null, @@ -325,7 +333,7 @@ return { "args": null, "storageKey": null }, - (v5/*: any*/) + (v7/*: any*/) ] }, { @@ -351,7 +359,28 @@ return { "concreteType": "Repository", "plural": false, "selections": [ - (v5/*: any*/) + (v7/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "name", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "owner", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v5/*: any*/), + (v6/*: any*/), + (v7/*: any*/) + ] + } ] }, { @@ -413,29 +442,29 @@ return { "args": null, "storageKey": null }, - (v5/*: any*/) + (v7/*: any*/) ] }, - (v5/*: any*/) + (v7/*: any*/) ] }, - (v5/*: any*/) + (v7/*: any*/) ] }, - (v5/*: any*/) + (v7/*: any*/) ] } ] }, - (v5/*: any*/) + (v7/*: any*/) ] } ] }, - (v5/*: any*/) + (v7/*: any*/) ] }, - (v5/*: any*/) + (v7/*: any*/) ] } ] @@ -444,7 +473,7 @@ return { "operationKind": "query", "name": "currentPullRequestContainerQuery", "id": null, - "text": "query currentPullRequestContainerQuery(\n $headOwner: String!\n $headName: String!\n $headRef: String!\n $first: Int!\n) {\n repository(owner: $headOwner, name: $headName) {\n ref(qualifiedName: $headRef) {\n associatedPullRequests(first: $first, states: [OPEN]) {\n totalCount\n nodes {\n ...issueishListController_results\n id\n }\n }\n id\n }\n id\n }\n}\n\nfragment issueishListController_results on PullRequest {\n number\n title\n url\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n createdAt\n headRefName\n repository {\n id\n }\n commits(last: 1) {\n nodes {\n commit {\n status {\n contexts {\n state\n id\n }\n id\n }\n id\n }\n id\n }\n }\n}\n", + "text": "query currentPullRequestContainerQuery(\n $headOwner: String!\n $headName: String!\n $headRef: String!\n $first: Int!\n) {\n repository(owner: $headOwner, name: $headName) {\n ref(qualifiedName: $headRef) {\n associatedPullRequests(first: $first, states: [OPEN]) {\n totalCount\n nodes {\n ...issueishListController_results\n id\n }\n }\n id\n }\n id\n }\n}\n\nfragment issueishListController_results on PullRequest {\n number\n title\n url\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n createdAt\n headRefName\n repository {\n id\n name\n owner {\n __typename\n login\n id\n }\n }\n commits(last: 1) {\n nodes {\n commit {\n status {\n contexts {\n state\n id\n }\n id\n }\n id\n }\n id\n }\n }\n}\n", "metadata": {} } }; diff --git a/lib/containers/__generated__/issueishDetailContainerQuery.graphql.js b/lib/containers/__generated__/issueishDetailContainerQuery.graphql.js index 3ff8220276..137363001b 100644 --- a/lib/containers/__generated__/issueishDetailContainerQuery.graphql.js +++ b/lib/containers/__generated__/issueishDetailContainerQuery.graphql.js @@ -1,6 +1,6 @@ /** * @flow - * @relayHash 283bf481a1445e3f9dcf5ed04bb0001f + * @relayHash 5bfca2d5db66bb24b86a6c3580b37bd0 */ /* eslint-disable */ @@ -9,6 +9,7 @@ /*:: import type { ConcreteRequest } from 'relay-runtime'; +type aggregatedReviewsContainer_pullRequest$ref = any; type issueishDetailController_repository$ref = any; export type issueishDetailContainerQueryVariables = {| repoOwner: string, @@ -20,12 +21,22 @@ export type issueishDetailContainerQueryVariables = {| commitCursor?: ?string, reviewCount: number, reviewCursor?: ?string, + threadCount: number, + threadCursor?: ?string, commentCount: number, commentCursor?: ?string, |}; export type issueishDetailContainerQueryResponse = {| +repository: ?{| - +$fragmentRefs: issueishDetailController_repository$ref + +issueish: ?({| + +__typename: "PullRequest", + +$fragmentRefs: aggregatedReviewsContainer_pullRequest$ref, + |} | {| + // This will never be '%other', but we need some + // value in case none of the concrete values match. + +__typename: "%other" + |}), + +$fragmentRefs: issueishDetailController_repository$ref, |} |}; export type issueishDetailContainerQuery = {| @@ -46,17 +57,35 @@ query issueishDetailContainerQuery( $commitCursor: String $reviewCount: Int! $reviewCursor: String + $threadCount: Int! + $threadCursor: String $commentCount: Int! $commentCursor: String ) { repository(owner: $repoOwner, name: $repoName) { - ...issueishDetailController_repository_y3nHF + issueish: issueOrPullRequest(number: $issueishNumber) { + __typename + ... on PullRequest { + ...aggregatedReviewsContainer_pullRequest_qdneZ + } + ... on Node { + id + } + } + ...issueishDetailController_repository_1mXVvq id } } -fragment issueishDetailController_repository_y3nHF on Repository { +fragment aggregatedReviewsContainer_pullRequest_qdneZ on PullRequest { + id + ...reviewSummariesAccumulator_pullRequest_2zzc96 + ...reviewThreadsAccumulator_pullRequest_CKDvj +} + +fragment issueishDetailController_repository_1mXVvq on Repository { ...issueDetailView_repository + ...prCheckoutController_repository ...prDetailView_repository name owner { @@ -80,19 +109,8 @@ fragment issueishDetailController_repository_y3nHF on Repository { ... on PullRequest { title number - headRefName - headRepository { - name - owner { - __typename - login - id - } - url - sshUrl - id - } - ...prDetailView_pullRequest_2qM2KL + ...prCheckoutController_pullRequest + ...prDetailView_pullRequest_1TnD8A } ... on Node { id @@ -110,6 +128,15 @@ fragment issueDetailView_repository on Repository { } } +fragment prCheckoutController_repository on Repository { + name + owner { + __typename + login + id + } +} + fragment prDetailView_repository on Repository { id name @@ -121,10 +148,9 @@ fragment prDetailView_repository on Repository { } fragment issueDetailView_issue_3D8CP9 on Issue { + id __typename - ... on Node { - id - } + url state number title @@ -133,110 +159,59 @@ fragment issueDetailView_issue_3D8CP9 on Issue { __typename login avatarUrl - ... on User { - url - } - ... on Bot { - url - } + url ... on Node { id } } ...issueTimelineController_issue_3D8CP9 - ... on UniformResourceLocatable { + ...emojiReactionsView_reactable +} + +fragment prCheckoutController_pullRequest on PullRequest { + number + headRefName + headRepository { + name url - } - ... on Reactable { - reactionGroups { - content - users { - totalCount - } + sshUrl + owner { + __typename + login + id } + id } } -fragment prDetailView_pullRequest_2qM2KL on PullRequest { +fragment prDetailView_pullRequest_1TnD8A on PullRequest { + id __typename - ... on Node { - id - } + url isCrossRepository changedFiles - ...prReviewsContainer_pullRequest_y4qc0 - ...prCommitsView_pullRequest_38TpXw - countedCommits: commits { - totalCount - } - ...prStatusesView_pullRequest state number title bodyHTML baseRefName headRefName + countedCommits: commits { + totalCount + } author { __typename login avatarUrl - ... on User { - url - } - ... on Bot { - url - } + url ... on Node { id } } + ...prCommitsView_pullRequest_38TpXw + ...prStatusesView_pullRequest ...prTimelineController_pullRequest_3D8CP9 - ... on UniformResourceLocatable { - url - } - ... on Reactable { - reactionGroups { - content - users { - totalCount - } - } - } -} - -fragment prReviewsContainer_pullRequest_y4qc0 on PullRequest { - url - reviews(first: $reviewCount, after: $reviewCursor) { - pageInfo { - hasNextPage - endCursor - } - edges { - cursor - node { - id - body - state - submittedAt - login: author { - __typename - login - ... on Node { - id - } - } - author { - __typename - avatarUrl - ... on Node { - id - } - } - ...prReviewCommentsContainer_review_1VbUmL - __typename - } - } - } + ...emojiReactionsController_reactable } fragment prCommitsView_pullRequest_38TpXw on PullRequest { @@ -309,6 +284,23 @@ fragment prTimelineController_pullRequest_3D8CP9 on PullRequest { } } +fragment emojiReactionsController_reactable on Reactable { + id + ...emojiReactionsView_reactable +} + +fragment emojiReactionsView_reactable on Reactable { + id + reactionGroups { + content + viewerHasReacted + users { + totalCount + } + } + viewerCanReact +} + fragment headRefForcePushedEventView_issueish on PullRequest { headRefName headRepositoryOwner { @@ -534,10 +526,31 @@ fragment prCommitView_item on Commit { url } -fragment prReviewCommentsContainer_review_1VbUmL on PullRequestReview { - id - submittedAt - comments(first: $commentCount, after: $commentCursor) { +fragment issueTimelineController_issue_3D8CP9 on Issue { + url + timeline(first: $timelineCount, after: $timelineCursor) { + pageInfo { + endCursor + hasNextPage + } + edges { + cursor + node { + __typename + ...commitsView_nodes + ...issueCommentView_item + ...crossReferencedEventsView_nodes + ... on Node { + id + } + } + } + } +} + +fragment reviewSummariesAccumulator_pullRequest_2zzc96 on PullRequest { + url + reviews(first: $reviewCount, after: $reviewCursor) { pageInfo { hasNextPage endCursor @@ -546,46 +559,78 @@ fragment prReviewCommentsContainer_review_1VbUmL on PullRequestReview { cursor node { id + bodyHTML + state + submittedAt author { __typename - avatarUrl login + avatarUrl ... on Node { id } } - bodyHTML - isMinimized - path - position - replyTo { - id - } - createdAt - url + ...emojiReactionsController_reactable __typename } } } } -fragment issueTimelineController_issue_3D8CP9 on Issue { +fragment reviewThreadsAccumulator_pullRequest_CKDvj on PullRequest { url - timeline(first: $timelineCount, after: $timelineCursor) { + reviewThreads(first: $threadCount, after: $threadCursor) { pageInfo { - endCursor hasNextPage + endCursor } edges { cursor node { - __typename - ...commitsView_nodes - ...issueCommentView_item - ...crossReferencedEventsView_nodes - ... on Node { + id + isResolved + resolvedBy { + login id } + viewerCanResolve + viewerCanUnresolve + ...reviewCommentsAccumulator_reviewThread_1VbUmL + __typename + } + } + } +} + +fragment reviewCommentsAccumulator_reviewThread_1VbUmL on PullRequestReviewThread { + id + comments(first: $commentCount, after: $commentCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + author { + __typename + avatarUrl + login + ... on Node { + id + } + } + bodyHTML + isMinimized + state + viewerCanReact + path + position + createdAt + url + ...emojiReactionsController_reactable + __typename } } } @@ -648,6 +693,18 @@ var v0 = [ "type": "String", "defaultValue": null }, + { + "kind": "LocalArgument", + "name": "threadCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "threadCursor", + "type": "String", + "defaultValue": null + }, { "kind": "LocalArgument", "name": "commentCount", @@ -675,101 +732,116 @@ v1 = [ "type": "String!" } ], -v2 = { - "kind": "ScalarField", - "alias": null, - "name": "id", - "args": null, - "storageKey": null -}, +v2 = [ + { + "kind": "Variable", + "name": "number", + "variableName": "issueishNumber", + "type": "Int!" + } +], v3 = { "kind": "ScalarField", "alias": null, - "name": "name", + "name": "__typename", "args": null, "storageKey": null }, v4 = { "kind": "ScalarField", "alias": null, - "name": "__typename", + "name": "id", "args": null, "storageKey": null }, v5 = { "kind": "ScalarField", "alias": null, - "name": "login", + "name": "url", "args": null, "storageKey": null }, v6 = [ - (v4/*: any*/), - (v5/*: any*/), - (v2/*: any*/) -], -v7 = { - "kind": "LinkedField", - "alias": null, - "name": "owner", - "storageKey": null, - "args": null, - "concreteType": null, - "plural": false, - "selections": (v6/*: any*/) -}, -v8 = [ { "kind": "Variable", - "name": "number", - "variableName": "issueishNumber", - "type": "Int!" + "name": "after", + "variableName": "reviewCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "reviewCount", + "type": "Int" } ], -v9 = { +v7 = { "kind": "ScalarField", "alias": null, - "name": "title", + "name": "hasNextPage", "args": null, "storageKey": null }, -v10 = { +v8 = { "kind": "ScalarField", "alias": null, - "name": "number", + "name": "endCursor", "args": null, "storageKey": null }, -v11 = { - "kind": "ScalarField", +v9 = { + "kind": "LinkedField", "alias": null, - "name": "state", + "name": "pageInfo", + "storageKey": null, + "args": null, + "concreteType": "PageInfo", + "plural": false, + "selections": [ + (v7/*: any*/), + (v8/*: any*/) + ] +}, +v10 = { + "kind": "ScalarField", + "alias": null, + "name": "cursor", "args": null, "storageKey": null }, -v12 = { +v11 = { "kind": "ScalarField", "alias": null, "name": "bodyHTML", "args": null, "storageKey": null }, +v12 = { + "kind": "ScalarField", + "alias": null, + "name": "state", + "args": null, + "storageKey": null +}, v13 = { "kind": "ScalarField", "alias": null, - "name": "avatarUrl", + "name": "login", "args": null, "storageKey": null }, v14 = { "kind": "ScalarField", "alias": null, - "name": "url", + "name": "avatarUrl", "args": null, "storageKey": null }, v15 = [ - (v14/*: any*/) + (v3/*: any*/), + (v13/*: any*/), + (v14/*: any*/), + (v4/*: any*/) ], v16 = { "kind": "LinkedField", @@ -779,85 +851,215 @@ v16 = { "args": null, "concreteType": null, "plural": false, + "selections": (v15/*: any*/) +}, +v17 = [ + { + "kind": "ScalarField", + "alias": null, + "name": "totalCount", + "args": null, + "storageKey": null + } +], +v18 = { + "kind": "LinkedField", + "alias": null, + "name": "reactionGroups", + "storageKey": null, + "args": null, + "concreteType": "ReactionGroup", + "plural": true, "selections": [ - (v4/*: any*/), - (v5/*: any*/), - (v13/*: any*/), - (v2/*: any*/), { - "kind": "InlineFragment", - "type": "Bot", - "selections": (v15/*: any*/) + "kind": "ScalarField", + "alias": null, + "name": "content", + "args": null, + "storageKey": null }, { - "kind": "InlineFragment", - "type": "User", - "selections": (v15/*: any*/) + "kind": "ScalarField", + "alias": null, + "name": "viewerHasReacted", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "users", + "storageKey": null, + "args": null, + "concreteType": "ReactingUserConnection", + "plural": false, + "selections": (v17/*: any*/) } ] }, -v17 = [ +v19 = { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanReact", + "args": null, + "storageKey": null +}, +v20 = [ { "kind": "Variable", "name": "after", - "variableName": "timelineCursor", + "variableName": "threadCursor", "type": "String" }, { "kind": "Variable", "name": "first", - "variableName": "timelineCount", + "variableName": "threadCount", "type": "Int" } ], -v18 = { +v21 = [ + (v13/*: any*/), + (v4/*: any*/) +], +v22 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "commentCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "commentCount", + "type": "Int" + } +], +v23 = { "kind": "ScalarField", "alias": null, - "name": "endCursor", + "name": "path", "args": null, "storageKey": null }, -v19 = { +v24 = [ + (v3/*: any*/), + (v14/*: any*/), + (v13/*: any*/), + (v4/*: any*/) +], +v25 = { + "kind": "LinkedField", + "alias": null, + "name": "author", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": (v24/*: any*/) +}, +v26 = { "kind": "ScalarField", "alias": null, - "name": "hasNextPage", + "name": "position", "args": null, "storageKey": null }, -v20 = { +v27 = { + "kind": "ScalarField", + "alias": null, + "name": "createdAt", + "args": null, + "storageKey": null +}, +v28 = { + "kind": "ScalarField", + "alias": null, + "name": "name", + "args": null, + "storageKey": null +}, +v29 = [ + (v3/*: any*/), + (v13/*: any*/), + (v4/*: any*/) +], +v30 = { "kind": "LinkedField", "alias": null, - "name": "pageInfo", + "name": "owner", "storageKey": null, "args": null, - "concreteType": "PageInfo", + "concreteType": null, "plural": false, - "selections": [ - (v18/*: any*/), - (v19/*: any*/) - ] + "selections": (v29/*: any*/) }, -v21 = { +v31 = { "kind": "ScalarField", "alias": null, - "name": "cursor", + "name": "title", "args": null, "storageKey": null }, -v22 = { +v32 = { "kind": "ScalarField", "alias": null, - "name": "isCrossRepository", + "name": "number", "args": null, "storageKey": null }, -v23 = [ - (v4/*: any*/), - (v5/*: any*/), - (v13/*: any*/), - (v2/*: any*/) +v33 = { + "kind": "LinkedField", + "alias": null, + "name": "author", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v3/*: any*/), + (v13/*: any*/), + (v14/*: any*/), + (v5/*: any*/), + (v4/*: any*/) + ] +}, +v34 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "timelineCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "timelineCount", + "type": "Int" + } ], -v24 = { +v35 = { + "kind": "LinkedField", + "alias": null, + "name": "pageInfo", + "storageKey": null, + "args": null, + "concreteType": "PageInfo", + "plural": false, + "selections": [ + (v8/*: any*/), + (v7/*: any*/) + ] +}, +v36 = { + "kind": "ScalarField", + "alias": null, + "name": "isCrossRepository", + "args": null, + "storageKey": null +}, +v37 = { "kind": "InlineFragment", "type": "CrossReferencedEvent", "selections": [ @@ -868,7 +1070,7 @@ v24 = { "args": null, "storageKey": null }, - (v22/*: any*/), + (v36/*: any*/), { "kind": "LinkedField", "alias": null, @@ -877,7 +1079,7 @@ v24 = { "args": null, "concreteType": null, "plural": false, - "selections": (v23/*: any*/) + "selections": (v15/*: any*/) }, { "kind": "LinkedField", @@ -888,7 +1090,7 @@ v24 = { "concreteType": null, "plural": false, "selections": [ - (v4/*: any*/), + (v3/*: any*/), { "kind": "LinkedField", "alias": null, @@ -898,9 +1100,9 @@ v24 = { "concreteType": "Repository", "plural": false, "selections": [ - (v3/*: any*/), - (v7/*: any*/), - (v2/*: any*/), + (v28/*: any*/), + (v30/*: any*/), + (v4/*: any*/), { "kind": "ScalarField", "alias": null, @@ -910,14 +1112,14 @@ v24 = { } ] }, - (v2/*: any*/), + (v4/*: any*/), { "kind": "InlineFragment", "type": "PullRequest", "selections": [ - (v10/*: any*/), - (v9/*: any*/), - (v14/*: any*/), + (v32/*: any*/), + (v31/*: any*/), + (v5/*: any*/), { "kind": "ScalarField", "alias": "prState", @@ -931,9 +1133,9 @@ v24 = { "kind": "InlineFragment", "type": "Issue", "selections": [ - (v10/*: any*/), - (v9/*: any*/), - (v14/*: any*/), + (v32/*: any*/), + (v31/*: any*/), + (v5/*: any*/), { "kind": "ScalarField", "alias": "issueState", @@ -947,40 +1149,17 @@ v24 = { } ] }, -v25 = [ - (v4/*: any*/), - (v13/*: any*/), - (v5/*: any*/), - (v2/*: any*/) -], -v26 = { - "kind": "LinkedField", - "alias": null, - "name": "author", - "storageKey": null, - "args": null, - "concreteType": null, - "plural": false, - "selections": (v25/*: any*/) -}, -v27 = { - "kind": "ScalarField", - "alias": null, - "name": "createdAt", - "args": null, - "storageKey": null -}, -v28 = { +v38 = { "kind": "InlineFragment", "type": "IssueComment", "selections": [ - (v26/*: any*/), - (v12/*: any*/), + (v25/*: any*/), + (v11/*: any*/), (v27/*: any*/), - (v14/*: any*/) + (v5/*: any*/) ] }, -v29 = { +v39 = { "kind": "LinkedField", "alias": null, "name": "user", @@ -988,19 +1167,16 @@ v29 = { "args": null, "concreteType": "User", "plural": false, - "selections": [ - (v5/*: any*/), - (v2/*: any*/) - ] + "selections": (v21/*: any*/) }, -v30 = { +v40 = { "kind": "ScalarField", "alias": "sha", "name": "oid", "args": null, "storageKey": null }, -v31 = { +v41 = { "kind": "InlineFragment", "type": "Commit", "selections": [ @@ -1013,9 +1189,9 @@ v31 = { "concreteType": "GitActor", "plural": false, "selections": [ - (v3/*: any*/), - (v29/*: any*/), - (v13/*: any*/) + (v28/*: any*/), + (v39/*: any*/), + (v14/*: any*/) ] }, { @@ -1027,9 +1203,9 @@ v31 = { "concreteType": "GitActor", "plural": false, "selections": [ - (v3/*: any*/), - (v13/*: any*/), - (v29/*: any*/) + (v28/*: any*/), + (v14/*: any*/), + (v39/*: any*/) ] }, { @@ -1039,7 +1215,7 @@ v31 = { "args": null, "storageKey": null }, - (v30/*: any*/), + (v40/*: any*/), { "kind": "ScalarField", "alias": null, @@ -1063,44 +1239,7 @@ v31 = { } ] }, -v32 = [ - { - "kind": "ScalarField", - "alias": null, - "name": "totalCount", - "args": null, - "storageKey": null - } -], -v33 = { - "kind": "LinkedField", - "alias": null, - "name": "reactionGroups", - "storageKey": null, - "args": null, - "concreteType": "ReactionGroup", - "plural": true, - "selections": [ - { - "kind": "ScalarField", - "alias": null, - "name": "content", - "args": null, - "storageKey": null - }, - { - "kind": "LinkedField", - "alias": null, - "name": "users", - "storageKey": null, - "args": null, - "concreteType": "ReactingUserConnection", - "plural": false, - "selections": (v32/*: any*/) - } - ] -}, -v34 = [ +v42 = [ { "kind": "Variable", "name": "after", @@ -1114,62 +1253,7 @@ v34 = [ "type": "Int" } ], -v35 = [ - { - "kind": "Variable", - "name": "after", - "variableName": "reviewCursor", - "type": "String" - }, - { - "kind": "Variable", - "name": "first", - "variableName": "reviewCount", - "type": "Int" - } -], -v36 = { - "kind": "LinkedField", - "alias": null, - "name": "pageInfo", - "storageKey": null, - "args": null, - "concreteType": "PageInfo", - "plural": false, - "selections": [ - (v19/*: any*/), - (v18/*: any*/) - ] -}, -v37 = [ - { - "kind": "Variable", - "name": "after", - "variableName": "commentCursor", - "type": "String" - }, - { - "kind": "Variable", - "name": "first", - "variableName": "commentCount", - "type": "Int" - } -], -v38 = { - "kind": "ScalarField", - "alias": null, - "name": "path", - "args": null, - "storageKey": null -}, -v39 = { - "kind": "ScalarField", - "alias": null, - "name": "position", - "args": null, - "storageKey": null -}, -v40 = [ +v43 = [ { "kind": "ScalarField", "alias": null, @@ -1177,9 +1261,9 @@ v40 = [ "args": null, "storageKey": null }, - (v2/*: any*/) + (v4/*: any*/) ], -v41 = { +v44 = { "kind": "LinkedField", "alias": null, "name": "commit", @@ -1187,9 +1271,9 @@ v41 = { "args": null, "concreteType": "Commit", "plural": false, - "selections": (v40/*: any*/) + "selections": (v43/*: any*/) }, -v42 = { +v45 = { "kind": "LinkedField", "alias": null, "name": "actor", @@ -1197,7 +1281,7 @@ v42 = { "args": null, "concreteType": null, "plural": false, - "selections": (v25/*: any*/) + "selections": (v24/*: any*/) }; return { "kind": "Request", @@ -1217,22 +1301,70 @@ return { "concreteType": "Repository", "plural": false, "selections": [ + { + "kind": "LinkedField", + "alias": "issueish", + "name": "issueOrPullRequest", + "storageKey": null, + "args": (v2/*: any*/), + "concreteType": null, + "plural": false, + "selections": [ + (v3/*: any*/), + { + "kind": "InlineFragment", + "type": "PullRequest", + "selections": [ + { + "kind": "FragmentSpread", + "name": "aggregatedReviewsContainer_pullRequest", + "args": [ + { + "kind": "Variable", + "name": "commentCount", + "variableName": "commentCount", + "type": null + }, + { + "kind": "Variable", + "name": "commentCursor", + "variableName": "commentCursor", + "type": null + }, + { + "kind": "Variable", + "name": "reviewCount", + "variableName": "reviewCount", + "type": null + }, + { + "kind": "Variable", + "name": "reviewCursor", + "variableName": "reviewCursor", + "type": null + }, + { + "kind": "Variable", + "name": "threadCount", + "variableName": "threadCount", + "type": null + }, + { + "kind": "Variable", + "name": "threadCursor", + "variableName": "threadCursor", + "type": null + } + ] + } + ] + } + ] + }, { "kind": "FragmentSpread", "name": "issueishDetailController_repository", "args": [ - { - "kind": "Variable", - "name": "commentCount", - "variableName": "commentCount", - "type": null - }, - { - "kind": "Variable", - "name": "commentCursor", - "variableName": "commentCursor", - "type": null - }, { "kind": "Variable", "name": "commitCount", @@ -1251,18 +1383,6 @@ return { "variableName": "issueishNumber", "type": null }, - { - "kind": "Variable", - "name": "reviewCount", - "variableName": "reviewCount", - "type": null - }, - { - "kind": "Variable", - "name": "reviewCursor", - "variableName": "reviewCursor", - "type": null - }, { "kind": "Variable", "name": "timelineCount", @@ -1295,40 +1415,257 @@ return { "concreteType": "Repository", "plural": false, "selections": [ - (v2/*: any*/), - (v3/*: any*/), - (v7/*: any*/), + { + "kind": "LinkedField", + "alias": "issueish", + "name": "issueOrPullRequest", + "storageKey": null, + "args": (v2/*: any*/), + "concreteType": null, + "plural": false, + "selections": [ + (v3/*: any*/), + (v4/*: any*/), + { + "kind": "InlineFragment", + "type": "PullRequest", + "selections": [ + (v5/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "reviews", + "storageKey": null, + "args": (v6/*: any*/), + "concreteType": "PullRequestReviewConnection", + "plural": false, + "selections": [ + (v9/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewEdge", + "plural": true, + "selections": [ + (v10/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReview", + "plural": false, + "selections": [ + (v4/*: any*/), + (v11/*: any*/), + (v12/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "submittedAt", + "args": null, + "storageKey": null + }, + (v16/*: any*/), + (v18/*: any*/), + (v19/*: any*/), + (v3/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "reviews", + "args": (v6/*: any*/), + "handle": "connection", + "key": "ReviewSummariesAccumulator_reviews", + "filters": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "reviewThreads", + "storageKey": null, + "args": (v20/*: any*/), + "concreteType": "PullRequestReviewThreadConnection", + "plural": false, + "selections": [ + (v9/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewThreadEdge", + "plural": true, + "selections": [ + (v10/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewThread", + "plural": false, + "selections": [ + (v4/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "isResolved", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "resolvedBy", + "storageKey": null, + "args": null, + "concreteType": "User", + "plural": false, + "selections": (v21/*: any*/) + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanResolve", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanUnresolve", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "comments", + "storageKey": null, + "args": (v22/*: any*/), + "concreteType": "PullRequestReviewCommentConnection", + "plural": false, + "selections": [ + (v9/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewCommentEdge", + "plural": true, + "selections": [ + (v10/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewComment", + "plural": false, + "selections": [ + (v23/*: any*/), + (v4/*: any*/), + (v11/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "isMinimized", + "args": null, + "storageKey": null + }, + (v12/*: any*/), + (v19/*: any*/), + (v25/*: any*/), + (v26/*: any*/), + (v27/*: any*/), + (v5/*: any*/), + (v18/*: any*/), + (v3/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "comments", + "args": (v22/*: any*/), + "handle": "connection", + "key": "ReviewCommentsAccumulator_comments", + "filters": null + }, + (v3/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "reviewThreads", + "args": (v20/*: any*/), + "handle": "connection", + "key": "ReviewThreadsAccumulator_reviewThreads", + "filters": null + } + ] + } + ] + }, + (v4/*: any*/), + (v28/*: any*/), + (v30/*: any*/), { "kind": "LinkedField", "alias": "issue", "name": "issueOrPullRequest", "storageKey": null, - "args": (v8/*: any*/), + "args": (v2/*: any*/), "concreteType": null, "plural": false, "selections": [ + (v3/*: any*/), (v4/*: any*/), - (v2/*: any*/), { "kind": "InlineFragment", "type": "Issue", "selections": [ - (v9/*: any*/), - (v10/*: any*/), - (v11/*: any*/), (v12/*: any*/), - (v16/*: any*/), - (v14/*: any*/), + (v31/*: any*/), + (v5/*: any*/), + (v32/*: any*/), + (v11/*: any*/), + (v33/*: any*/), { "kind": "LinkedField", "alias": null, "name": "timeline", "storageKey": null, - "args": (v17/*: any*/), + "args": (v34/*: any*/), "concreteType": "IssueTimelineConnection", "plural": false, "selections": [ - (v20/*: any*/), + (v35/*: any*/), { "kind": "LinkedField", "alias": null, @@ -1338,7 +1675,7 @@ return { "concreteType": "IssueTimelineItemEdge", "plural": true, "selections": [ - (v21/*: any*/), + (v10/*: any*/), { "kind": "LinkedField", "alias": null, @@ -1348,11 +1685,11 @@ return { "concreteType": null, "plural": false, "selections": [ + (v3/*: any*/), (v4/*: any*/), - (v2/*: any*/), - (v24/*: any*/), - (v28/*: any*/), - (v31/*: any*/) + (v37/*: any*/), + (v38/*: any*/), + (v41/*: any*/) ] } ] @@ -1363,12 +1700,13 @@ return { "kind": "LinkedHandle", "alias": null, "name": "timeline", - "args": (v17/*: any*/), + "args": (v34/*: any*/), "handle": "connection", "key": "IssueTimelineController_timeline", "filters": null }, - (v33/*: any*/) + (v18/*: any*/), + (v19/*: any*/) ] } ] @@ -1378,26 +1716,86 @@ return { "alias": "pullRequest", "name": "issueOrPullRequest", "storageKey": null, - "args": (v8/*: any*/), + "args": (v2/*: any*/), "concreteType": null, "plural": false, "selections": [ + (v3/*: any*/), (v4/*: any*/), - (v2/*: any*/), { "kind": "InlineFragment", "type": "PullRequest", "selections": [ + (v11/*: any*/), + (v31/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "headRefName", + "args": null, + "storageKey": null + }, { "kind": "LinkedField", "alias": null, + "name": "headRepository", + "storageKey": null, + "args": null, + "concreteType": "Repository", + "plural": false, + "selections": [ + (v28/*: any*/), + (v5/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "sshUrl", + "args": null, + "storageKey": null + }, + (v30/*: any*/), + (v4/*: any*/) + ] + }, + (v5/*: any*/), + (v36/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "changedFiles", + "args": null, + "storageKey": null + }, + (v12/*: any*/), + (v32/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "baseRefName", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": "countedCommits", "name": "commits", "storageKey": null, - "args": (v34/*: any*/), + "args": null, + "concreteType": "PullRequestCommitConnection", + "plural": false, + "selections": (v17/*: any*/) + }, + (v33/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "commits", + "storageKey": null, + "args": (v42/*: any*/), "concreteType": "PullRequestCommitConnection", "plural": false, "selections": [ - (v20/*: any*/), + (v35/*: any*/), { "kind": "LinkedField", "alias": null, @@ -1407,7 +1805,7 @@ return { "concreteType": "PullRequestCommitEdge", "plural": true, "selections": [ - (v21/*: any*/), + (v10/*: any*/), { "kind": "LinkedField", "alias": null, @@ -1426,7 +1824,7 @@ return { "concreteType": "Commit", "plural": false, "selections": [ - (v2/*: any*/), + (v4/*: any*/), { "kind": "LinkedField", "alias": null, @@ -1436,8 +1834,8 @@ return { "concreteType": "GitActor", "plural": false, "selections": [ - (v13/*: any*/), - (v3/*: any*/), + (v14/*: any*/), + (v28/*: any*/), { "kind": "ScalarField", "alias": null, @@ -1468,12 +1866,12 @@ return { "args": null, "storageKey": null }, - (v30/*: any*/), - (v14/*: any*/) + (v40/*: any*/), + (v5/*: any*/) ] }, - (v2/*: any*/), - (v4/*: any*/) + (v4/*: any*/), + (v3/*: any*/) ] } ] @@ -1484,217 +1882,11 @@ return { "kind": "LinkedHandle", "alias": null, "name": "commits", - "args": (v34/*: any*/), + "args": (v42/*: any*/), "handle": "connection", "key": "prCommitsView_commits", "filters": null }, - (v9/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "headRefName", - "args": null, - "storageKey": null - }, - { - "kind": "LinkedField", - "alias": null, - "name": "headRepository", - "storageKey": null, - "args": null, - "concreteType": "Repository", - "plural": false, - "selections": [ - (v3/*: any*/), - (v7/*: any*/), - (v14/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "sshUrl", - "args": null, - "storageKey": null - }, - (v2/*: any*/) - ] - }, - (v22/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "changedFiles", - "args": null, - "storageKey": null - }, - (v14/*: any*/), - { - "kind": "LinkedField", - "alias": null, - "name": "reviews", - "storageKey": null, - "args": (v35/*: any*/), - "concreteType": "PullRequestReviewConnection", - "plural": false, - "selections": [ - (v36/*: any*/), - { - "kind": "LinkedField", - "alias": null, - "name": "edges", - "storageKey": null, - "args": null, - "concreteType": "PullRequestReviewEdge", - "plural": true, - "selections": [ - (v21/*: any*/), - { - "kind": "LinkedField", - "alias": null, - "name": "node", - "storageKey": null, - "args": null, - "concreteType": "PullRequestReview", - "plural": false, - "selections": [ - (v2/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "body", - "args": null, - "storageKey": null - }, - (v11/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "submittedAt", - "args": null, - "storageKey": null - }, - { - "kind": "LinkedField", - "alias": "login", - "name": "author", - "storageKey": null, - "args": null, - "concreteType": null, - "plural": false, - "selections": (v6/*: any*/) - }, - { - "kind": "LinkedField", - "alias": null, - "name": "author", - "storageKey": null, - "args": null, - "concreteType": null, - "plural": false, - "selections": [ - (v4/*: any*/), - (v13/*: any*/), - (v2/*: any*/) - ] - }, - { - "kind": "LinkedField", - "alias": null, - "name": "comments", - "storageKey": null, - "args": (v37/*: any*/), - "concreteType": "PullRequestReviewCommentConnection", - "plural": false, - "selections": [ - (v36/*: any*/), - { - "kind": "LinkedField", - "alias": null, - "name": "edges", - "storageKey": null, - "args": null, - "concreteType": "PullRequestReviewCommentEdge", - "plural": true, - "selections": [ - (v21/*: any*/), - { - "kind": "LinkedField", - "alias": null, - "name": "node", - "storageKey": null, - "args": null, - "concreteType": "PullRequestReviewComment", - "plural": false, - "selections": [ - (v2/*: any*/), - (v26/*: any*/), - (v12/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "isMinimized", - "args": null, - "storageKey": null - }, - (v38/*: any*/), - (v39/*: any*/), - { - "kind": "LinkedField", - "alias": null, - "name": "replyTo", - "storageKey": null, - "args": null, - "concreteType": "PullRequestReviewComment", - "plural": false, - "selections": [ - (v2/*: any*/) - ] - }, - (v27/*: any*/), - (v14/*: any*/), - (v4/*: any*/) - ] - } - ] - } - ] - }, - { - "kind": "LinkedHandle", - "alias": null, - "name": "comments", - "args": (v37/*: any*/), - "handle": "connection", - "key": "PrReviewCommentsContainer_comments", - "filters": null - }, - (v4/*: any*/) - ] - } - ] - } - ] - }, - { - "kind": "LinkedHandle", - "alias": null, - "name": "reviews", - "args": (v35/*: any*/), - "handle": "connection", - "key": "PrReviewsContainer_reviews", - "filters": null - }, - (v10/*: any*/), - { - "kind": "LinkedField", - "alias": "countedCommits", - "name": "commits", - "storageKey": null, - "args": null, - "concreteType": "PullRequestCommitConnection", - "plural": false, - "selections": (v32/*: any*/) - }, { "kind": "LinkedField", "alias": "recentCommits", @@ -1747,7 +1939,7 @@ return { "concreteType": "Status", "plural": false, "selections": [ - (v11/*: any*/), + (v12/*: any*/), { "kind": "LinkedField", "alias": null, @@ -1757,8 +1949,8 @@ return { "concreteType": "StatusContext", "plural": true, "selections": [ - (v2/*: any*/), - (v11/*: any*/), + (v4/*: any*/), + (v12/*: any*/), { "kind": "ScalarField", "alias": null, @@ -1782,29 +1974,19 @@ return { } ] }, - (v2/*: any*/) + (v4/*: any*/) ] }, - (v2/*: any*/) + (v4/*: any*/) ] }, - (v2/*: any*/) + (v4/*: any*/) ] } ] } ] }, - (v11/*: any*/), - (v12/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "baseRefName", - "args": null, - "storageKey": null - }, - (v16/*: any*/), { "kind": "LinkedField", "alias": null, @@ -1813,7 +1995,7 @@ return { "args": null, "concreteType": null, "plural": false, - "selections": (v6/*: any*/) + "selections": (v29/*: any*/) }, { "kind": "LinkedField", @@ -1824,8 +2006,8 @@ return { "concreteType": "Repository", "plural": false, "selections": [ - (v7/*: any*/), - (v2/*: any*/) + (v30/*: any*/), + (v4/*: any*/) ] }, { @@ -1833,11 +2015,11 @@ return { "alias": null, "name": "timeline", "storageKey": null, - "args": (v17/*: any*/), + "args": (v34/*: any*/), "concreteType": "PullRequestTimelineConnection", "plural": false, "selections": [ - (v20/*: any*/), + (v35/*: any*/), { "kind": "LinkedField", "alias": null, @@ -1847,7 +2029,7 @@ return { "concreteType": "PullRequestTimelineItemEdge", "plural": true, "selections": [ - (v21/*: any*/), + (v10/*: any*/), { "kind": "LinkedField", "alias": null, @@ -1857,14 +2039,14 @@ return { "concreteType": null, "plural": false, "selections": [ + (v3/*: any*/), (v4/*: any*/), - (v2/*: any*/), - (v24/*: any*/), + (v37/*: any*/), { "kind": "InlineFragment", "type": "CommitCommentThread", "selections": [ - (v41/*: any*/), + (v44/*: any*/), { "kind": "LinkedField", "alias": null, @@ -1899,22 +2081,13 @@ return { "concreteType": "CommitComment", "plural": false, "selections": [ - (v2/*: any*/), - { - "kind": "LinkedField", - "alias": null, - "name": "author", - "storageKey": null, - "args": null, - "concreteType": null, - "plural": false, - "selections": (v23/*: any*/) - }, - (v41/*: any*/), - (v12/*: any*/), + (v4/*: any*/), + (v16/*: any*/), + (v44/*: any*/), + (v11/*: any*/), (v27/*: any*/), - (v38/*: any*/), - (v39/*: any*/) + (v23/*: any*/), + (v26/*: any*/) ] } ] @@ -1927,7 +2100,7 @@ return { "kind": "InlineFragment", "type": "HeadRefForcePushedEvent", "selections": [ - (v42/*: any*/), + (v45/*: any*/), { "kind": "LinkedField", "alias": null, @@ -1936,7 +2109,7 @@ return { "args": null, "concreteType": "Commit", "plural": false, - "selections": (v40/*: any*/) + "selections": (v43/*: any*/) }, { "kind": "LinkedField", @@ -1946,7 +2119,7 @@ return { "args": null, "concreteType": "Commit", "plural": false, - "selections": (v40/*: any*/) + "selections": (v43/*: any*/) }, (v27/*: any*/) ] @@ -1955,8 +2128,8 @@ return { "kind": "InlineFragment", "type": "MergedEvent", "selections": [ - (v42/*: any*/), - (v41/*: any*/), + (v45/*: any*/), + (v44/*: any*/), { "kind": "ScalarField", "alias": null, @@ -1967,8 +2140,8 @@ return { (v27/*: any*/) ] }, - (v28/*: any*/), - (v31/*: any*/) + (v38/*: any*/), + (v41/*: any*/) ] } ] @@ -1979,12 +2152,13 @@ return { "kind": "LinkedHandle", "alias": null, "name": "timeline", - "args": (v17/*: any*/), + "args": (v34/*: any*/), "handle": "connection", "key": "prTimelineContainer_timeline", "filters": null }, - (v33/*: any*/) + (v18/*: any*/), + (v19/*: any*/) ] } ] @@ -1997,11 +2171,11 @@ return { "operationKind": "query", "name": "issueishDetailContainerQuery", "id": null, - "text": "query issueishDetailContainerQuery(\n $repoOwner: String!\n $repoName: String!\n $issueishNumber: Int!\n $timelineCount: Int!\n $timelineCursor: String\n $commitCount: Int!\n $commitCursor: String\n $reviewCount: Int!\n $reviewCursor: String\n $commentCount: Int!\n $commentCursor: String\n) {\n repository(owner: $repoOwner, name: $repoName) {\n ...issueishDetailController_repository_y3nHF\n id\n }\n}\n\nfragment issueishDetailController_repository_y3nHF on Repository {\n ...issueDetailView_repository\n ...prDetailView_repository\n name\n owner {\n __typename\n login\n id\n }\n issue: issueOrPullRequest(number: $issueishNumber) {\n __typename\n ... on Issue {\n title\n number\n ...issueDetailView_issue_3D8CP9\n }\n ... on Node {\n id\n }\n }\n pullRequest: issueOrPullRequest(number: $issueishNumber) {\n __typename\n ... on PullRequest {\n title\n number\n headRefName\n headRepository {\n name\n owner {\n __typename\n login\n id\n }\n url\n sshUrl\n id\n }\n ...prDetailView_pullRequest_2qM2KL\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment issueDetailView_repository on Repository {\n id\n name\n owner {\n __typename\n login\n id\n }\n}\n\nfragment prDetailView_repository on Repository {\n id\n name\n owner {\n __typename\n login\n id\n }\n}\n\nfragment issueDetailView_issue_3D8CP9 on Issue {\n __typename\n ... on Node {\n id\n }\n state\n number\n title\n bodyHTML\n author {\n __typename\n login\n avatarUrl\n ... on User {\n url\n }\n ... on Bot {\n url\n }\n ... on Node {\n id\n }\n }\n ...issueTimelineController_issue_3D8CP9\n ... on UniformResourceLocatable {\n url\n }\n ... on Reactable {\n reactionGroups {\n content\n users {\n totalCount\n }\n }\n }\n}\n\nfragment prDetailView_pullRequest_2qM2KL on PullRequest {\n __typename\n ... on Node {\n id\n }\n isCrossRepository\n changedFiles\n ...prReviewsContainer_pullRequest_y4qc0\n ...prCommitsView_pullRequest_38TpXw\n countedCommits: commits {\n totalCount\n }\n ...prStatusesView_pullRequest\n state\n number\n title\n bodyHTML\n baseRefName\n headRefName\n author {\n __typename\n login\n avatarUrl\n ... on User {\n url\n }\n ... on Bot {\n url\n }\n ... on Node {\n id\n }\n }\n ...prTimelineController_pullRequest_3D8CP9\n ... on UniformResourceLocatable {\n url\n }\n ... on Reactable {\n reactionGroups {\n content\n users {\n totalCount\n }\n }\n }\n}\n\nfragment prReviewsContainer_pullRequest_y4qc0 on PullRequest {\n url\n reviews(first: $reviewCount, after: $reviewCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n body\n state\n submittedAt\n login: author {\n __typename\n login\n ... on Node {\n id\n }\n }\n author {\n __typename\n avatarUrl\n ... on Node {\n id\n }\n }\n ...prReviewCommentsContainer_review_1VbUmL\n __typename\n }\n }\n }\n}\n\nfragment prCommitsView_pullRequest_38TpXw on PullRequest {\n url\n commits(first: $commitCount, after: $commitCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n commit {\n id\n ...prCommitView_item\n }\n id\n __typename\n }\n }\n }\n}\n\nfragment prStatusesView_pullRequest on PullRequest {\n id\n recentCommits: commits(last: 1) {\n edges {\n node {\n commit {\n status {\n state\n contexts {\n id\n state\n ...prStatusContextView_context\n }\n id\n }\n id\n }\n id\n }\n }\n }\n}\n\nfragment prTimelineController_pullRequest_3D8CP9 on PullRequest {\n url\n ...headRefForcePushedEventView_issueish\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...mergedEventView_item\n ...headRefForcePushedEventView_item\n ...commitCommentThreadView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n\nfragment headRefForcePushedEventView_issueish on PullRequest {\n headRefName\n headRepositoryOwner {\n __typename\n login\n id\n }\n repository {\n owner {\n __typename\n login\n id\n }\n id\n }\n}\n\nfragment commitsView_nodes on Commit {\n id\n author {\n name\n user {\n login\n id\n }\n }\n ...commitView_commit\n}\n\nfragment issueCommentView_item on IssueComment {\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n createdAt\n url\n}\n\nfragment mergedEventView_item on MergedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n mergeRefName\n createdAt\n}\n\nfragment headRefForcePushedEventView_item on HeadRefForcePushedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n beforeCommit {\n oid\n id\n }\n afterCommit {\n oid\n id\n }\n createdAt\n}\n\nfragment commitCommentThreadView_item on CommitCommentThread {\n commit {\n oid\n id\n }\n comments(first: 100) {\n edges {\n node {\n id\n ...commitCommentView_item\n }\n }\n }\n}\n\nfragment crossReferencedEventsView_nodes on CrossReferencedEvent {\n id\n referencedAt\n isCrossRepository\n actor {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n source {\n __typename\n ... on RepositoryNode {\n repository {\n name\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n ...crossReferencedEventView_item\n}\n\nfragment crossReferencedEventView_item on CrossReferencedEvent {\n id\n isCrossRepository\n source {\n __typename\n ... on Issue {\n number\n title\n url\n issueState: state\n }\n ... on PullRequest {\n number\n title\n url\n prState: state\n }\n ... on RepositoryNode {\n repository {\n name\n isPrivate\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment commitCommentView_item on CommitComment {\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n bodyHTML\n createdAt\n path\n position\n}\n\nfragment commitView_commit on Commit {\n author {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n committer {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n authoredByCommitter\n sha: oid\n message\n messageHeadlineHTML\n commitUrl\n}\n\nfragment prStatusContextView_context on StatusContext {\n context\n description\n state\n targetUrl\n}\n\nfragment prCommitView_item on Commit {\n committer {\n avatarUrl\n name\n date\n }\n messageHeadline\n messageBody\n shortSha: abbreviatedOid\n sha: oid\n url\n}\n\nfragment prReviewCommentsContainer_review_1VbUmL on PullRequestReview {\n id\n submittedAt\n comments(first: $commentCount, after: $commentCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n isMinimized\n path\n position\n replyTo {\n id\n }\n createdAt\n url\n __typename\n }\n }\n }\n}\n\nfragment issueTimelineController_issue_3D8CP9 on Issue {\n url\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n", + "text": "query issueishDetailContainerQuery(\n $repoOwner: String!\n $repoName: String!\n $issueishNumber: Int!\n $timelineCount: Int!\n $timelineCursor: String\n $commitCount: Int!\n $commitCursor: String\n $reviewCount: Int!\n $reviewCursor: String\n $threadCount: Int!\n $threadCursor: String\n $commentCount: Int!\n $commentCursor: String\n) {\n repository(owner: $repoOwner, name: $repoName) {\n issueish: issueOrPullRequest(number: $issueishNumber) {\n __typename\n ... on PullRequest {\n ...aggregatedReviewsContainer_pullRequest_qdneZ\n }\n ... on Node {\n id\n }\n }\n ...issueishDetailController_repository_1mXVvq\n id\n }\n}\n\nfragment aggregatedReviewsContainer_pullRequest_qdneZ on PullRequest {\n id\n ...reviewSummariesAccumulator_pullRequest_2zzc96\n ...reviewThreadsAccumulator_pullRequest_CKDvj\n}\n\nfragment issueishDetailController_repository_1mXVvq on Repository {\n ...issueDetailView_repository\n ...prCheckoutController_repository\n ...prDetailView_repository\n name\n owner {\n __typename\n login\n id\n }\n issue: issueOrPullRequest(number: $issueishNumber) {\n __typename\n ... on Issue {\n title\n number\n ...issueDetailView_issue_3D8CP9\n }\n ... on Node {\n id\n }\n }\n pullRequest: issueOrPullRequest(number: $issueishNumber) {\n __typename\n ... on PullRequest {\n title\n number\n ...prCheckoutController_pullRequest\n ...prDetailView_pullRequest_1TnD8A\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment issueDetailView_repository on Repository {\n id\n name\n owner {\n __typename\n login\n id\n }\n}\n\nfragment prCheckoutController_repository on Repository {\n name\n owner {\n __typename\n login\n id\n }\n}\n\nfragment prDetailView_repository on Repository {\n id\n name\n owner {\n __typename\n login\n id\n }\n}\n\nfragment issueDetailView_issue_3D8CP9 on Issue {\n id\n __typename\n url\n state\n number\n title\n bodyHTML\n author {\n __typename\n login\n avatarUrl\n url\n ... on Node {\n id\n }\n }\n ...issueTimelineController_issue_3D8CP9\n ...emojiReactionsView_reactable\n}\n\nfragment prCheckoutController_pullRequest on PullRequest {\n number\n headRefName\n headRepository {\n name\n url\n sshUrl\n owner {\n __typename\n login\n id\n }\n id\n }\n}\n\nfragment prDetailView_pullRequest_1TnD8A on PullRequest {\n id\n __typename\n url\n isCrossRepository\n changedFiles\n state\n number\n title\n bodyHTML\n baseRefName\n headRefName\n countedCommits: commits {\n totalCount\n }\n author {\n __typename\n login\n avatarUrl\n url\n ... on Node {\n id\n }\n }\n ...prCommitsView_pullRequest_38TpXw\n ...prStatusesView_pullRequest\n ...prTimelineController_pullRequest_3D8CP9\n ...emojiReactionsController_reactable\n}\n\nfragment prCommitsView_pullRequest_38TpXw on PullRequest {\n url\n commits(first: $commitCount, after: $commitCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n commit {\n id\n ...prCommitView_item\n }\n id\n __typename\n }\n }\n }\n}\n\nfragment prStatusesView_pullRequest on PullRequest {\n id\n recentCommits: commits(last: 1) {\n edges {\n node {\n commit {\n status {\n state\n contexts {\n id\n state\n ...prStatusContextView_context\n }\n id\n }\n id\n }\n id\n }\n }\n }\n}\n\nfragment prTimelineController_pullRequest_3D8CP9 on PullRequest {\n url\n ...headRefForcePushedEventView_issueish\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...mergedEventView_item\n ...headRefForcePushedEventView_item\n ...commitCommentThreadView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n\nfragment emojiReactionsController_reactable on Reactable {\n id\n ...emojiReactionsView_reactable\n}\n\nfragment emojiReactionsView_reactable on Reactable {\n id\n reactionGroups {\n content\n viewerHasReacted\n users {\n totalCount\n }\n }\n viewerCanReact\n}\n\nfragment headRefForcePushedEventView_issueish on PullRequest {\n headRefName\n headRepositoryOwner {\n __typename\n login\n id\n }\n repository {\n owner {\n __typename\n login\n id\n }\n id\n }\n}\n\nfragment commitsView_nodes on Commit {\n id\n author {\n name\n user {\n login\n id\n }\n }\n ...commitView_commit\n}\n\nfragment issueCommentView_item on IssueComment {\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n createdAt\n url\n}\n\nfragment mergedEventView_item on MergedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n mergeRefName\n createdAt\n}\n\nfragment headRefForcePushedEventView_item on HeadRefForcePushedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n beforeCommit {\n oid\n id\n }\n afterCommit {\n oid\n id\n }\n createdAt\n}\n\nfragment commitCommentThreadView_item on CommitCommentThread {\n commit {\n oid\n id\n }\n comments(first: 100) {\n edges {\n node {\n id\n ...commitCommentView_item\n }\n }\n }\n}\n\nfragment crossReferencedEventsView_nodes on CrossReferencedEvent {\n id\n referencedAt\n isCrossRepository\n actor {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n source {\n __typename\n ... on RepositoryNode {\n repository {\n name\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n ...crossReferencedEventView_item\n}\n\nfragment crossReferencedEventView_item on CrossReferencedEvent {\n id\n isCrossRepository\n source {\n __typename\n ... on Issue {\n number\n title\n url\n issueState: state\n }\n ... on PullRequest {\n number\n title\n url\n prState: state\n }\n ... on RepositoryNode {\n repository {\n name\n isPrivate\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment commitCommentView_item on CommitComment {\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n bodyHTML\n createdAt\n path\n position\n}\n\nfragment commitView_commit on Commit {\n author {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n committer {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n authoredByCommitter\n sha: oid\n message\n messageHeadlineHTML\n commitUrl\n}\n\nfragment prStatusContextView_context on StatusContext {\n context\n description\n state\n targetUrl\n}\n\nfragment prCommitView_item on Commit {\n committer {\n avatarUrl\n name\n date\n }\n messageHeadline\n messageBody\n shortSha: abbreviatedOid\n sha: oid\n url\n}\n\nfragment issueTimelineController_issue_3D8CP9 on Issue {\n url\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n\nfragment reviewSummariesAccumulator_pullRequest_2zzc96 on PullRequest {\n url\n reviews(first: $reviewCount, after: $reviewCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n bodyHTML\n state\n submittedAt\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n ...emojiReactionsController_reactable\n __typename\n }\n }\n }\n}\n\nfragment reviewThreadsAccumulator_pullRequest_CKDvj on PullRequest {\n url\n reviewThreads(first: $threadCount, after: $threadCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n isResolved\n resolvedBy {\n login\n id\n }\n viewerCanResolve\n viewerCanUnresolve\n ...reviewCommentsAccumulator_reviewThread_1VbUmL\n __typename\n }\n }\n }\n}\n\nfragment reviewCommentsAccumulator_reviewThread_1VbUmL on PullRequestReviewThread {\n id\n comments(first: $commentCount, after: $commentCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n isMinimized\n state\n viewerCanReact\n path\n position\n createdAt\n url\n ...emojiReactionsController_reactable\n __typename\n }\n }\n }\n}\n", "metadata": {} } }; })(); // prettier-ignore -(node/*: any*/).hash = '6a16db513a3cd8bf14fafb3c8b269643'; +(node/*: any*/).hash = 'f0f0b983d5648d6435cb0a818dcdf081'; module.exports = node; diff --git a/lib/containers/__generated__/issueishSearchContainerQuery.graphql.js b/lib/containers/__generated__/issueishSearchContainerQuery.graphql.js index 5da39bd72d..49b0b8275f 100644 --- a/lib/containers/__generated__/issueishSearchContainerQuery.graphql.js +++ b/lib/containers/__generated__/issueishSearchContainerQuery.graphql.js @@ -1,6 +1,6 @@ /** * @flow - * @relayHash 2976f0b1cbe75dfca74d37fc0f14877b + * @relayHash 7ae07a432c87b6ee4dc8a9fb7acaf412 */ /* eslint-disable */ @@ -62,6 +62,12 @@ fragment issueishListController_results on PullRequest { headRefName repository { id + name + owner { + __typename + login + id + } } commits(last: 1) { nodes { @@ -136,6 +142,13 @@ v4 = { "name": "id", "args": null, "storageKey": null +}, +v5 = { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null }; return { "kind": "Request", @@ -237,13 +250,7 @@ return { "plural": false, "selections": [ (v3/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "login", - "args": null, - "storageKey": null - }, + (v5/*: any*/), { "kind": "ScalarField", "alias": null, @@ -277,7 +284,28 @@ return { "concreteType": "Repository", "plural": false, "selections": [ - (v4/*: any*/) + (v4/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "name", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "owner", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v3/*: any*/), + (v5/*: any*/), + (v4/*: any*/) + ] + } ] }, { @@ -365,7 +393,7 @@ return { "operationKind": "query", "name": "issueishSearchContainerQuery", "id": null, - "text": "query issueishSearchContainerQuery(\n $query: String!\n $first: Int!\n) {\n search(first: $first, query: $query, type: ISSUE) {\n issueCount\n nodes {\n __typename\n ...issueishListController_results\n ... on Node {\n id\n }\n }\n }\n}\n\nfragment issueishListController_results on PullRequest {\n number\n title\n url\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n createdAt\n headRefName\n repository {\n id\n }\n commits(last: 1) {\n nodes {\n commit {\n status {\n contexts {\n state\n id\n }\n id\n }\n id\n }\n id\n }\n }\n}\n", + "text": "query issueishSearchContainerQuery(\n $query: String!\n $first: Int!\n) {\n search(first: $first, query: $query, type: ISSUE) {\n issueCount\n nodes {\n __typename\n ...issueishListController_results\n ... on Node {\n id\n }\n }\n }\n}\n\nfragment issueishListController_results on PullRequest {\n number\n title\n url\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n createdAt\n headRefName\n repository {\n id\n name\n owner {\n __typename\n login\n id\n }\n }\n commits(last: 1) {\n nodes {\n commit {\n status {\n contexts {\n state\n id\n }\n id\n }\n id\n }\n id\n }\n }\n}\n", "metadata": {} } }; diff --git a/lib/containers/__generated__/reviewsContainerQuery.graphql.js b/lib/containers/__generated__/reviewsContainerQuery.graphql.js new file mode 100644 index 0000000000..3250f39226 --- /dev/null +++ b/lib/containers/__generated__/reviewsContainerQuery.graphql.js @@ -0,0 +1,950 @@ +/** + * @flow + * @relayHash 355ceb5683604e00779a908b661ad662 + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest } from 'relay-runtime'; +type aggregatedReviewsContainer_pullRequest$ref = any; +type reviewsController_pullRequest$ref = any; +type reviewsController_repository$ref = any; +type reviewsController_viewer$ref = any; +export type reviewsContainerQueryVariables = {| + repoOwner: string, + repoName: string, + prNumber: number, + reviewCount: number, + reviewCursor?: ?string, + threadCount: number, + threadCursor?: ?string, + commentCount: number, + commentCursor?: ?string, +|}; +export type reviewsContainerQueryResponse = {| + +repository: ?{| + +pullRequest: ?{| + +headRefOid: any, + +$fragmentRefs: aggregatedReviewsContainer_pullRequest$ref & reviewsController_pullRequest$ref, + |}, + +$fragmentRefs: reviewsController_repository$ref, + |}, + +viewer: {| + +$fragmentRefs: reviewsController_viewer$ref + |}, +|}; +export type reviewsContainerQuery = {| + variables: reviewsContainerQueryVariables, + response: reviewsContainerQueryResponse, +|}; +*/ + + +/* +query reviewsContainerQuery( + $repoOwner: String! + $repoName: String! + $prNumber: Int! + $reviewCount: Int! + $reviewCursor: String + $threadCount: Int! + $threadCursor: String + $commentCount: Int! + $commentCursor: String +) { + repository(owner: $repoOwner, name: $repoName) { + ...reviewsController_repository + pullRequest(number: $prNumber) { + headRefOid + ...aggregatedReviewsContainer_pullRequest_qdneZ + ...reviewsController_pullRequest + id + } + id + } + viewer { + ...reviewsController_viewer + id + } +} + +fragment reviewsController_repository on Repository { + ...prCheckoutController_repository +} + +fragment aggregatedReviewsContainer_pullRequest_qdneZ on PullRequest { + id + ...reviewSummariesAccumulator_pullRequest_2zzc96 + ...reviewThreadsAccumulator_pullRequest_CKDvj +} + +fragment reviewsController_pullRequest on PullRequest { + id + ...prCheckoutController_pullRequest +} + +fragment reviewsController_viewer on User { + id + login + avatarUrl +} + +fragment prCheckoutController_pullRequest on PullRequest { + number + headRefName + headRepository { + name + url + sshUrl + owner { + __typename + login + id + } + id + } +} + +fragment reviewSummariesAccumulator_pullRequest_2zzc96 on PullRequest { + url + reviews(first: $reviewCount, after: $reviewCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + bodyHTML + state + submittedAt + author { + __typename + login + avatarUrl + ... on Node { + id + } + } + ...emojiReactionsController_reactable + __typename + } + } + } +} + +fragment reviewThreadsAccumulator_pullRequest_CKDvj on PullRequest { + url + reviewThreads(first: $threadCount, after: $threadCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + isResolved + resolvedBy { + login + id + } + viewerCanResolve + viewerCanUnresolve + ...reviewCommentsAccumulator_reviewThread_1VbUmL + __typename + } + } + } +} + +fragment reviewCommentsAccumulator_reviewThread_1VbUmL on PullRequestReviewThread { + id + comments(first: $commentCount, after: $commentCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + author { + __typename + avatarUrl + login + ... on Node { + id + } + } + bodyHTML + isMinimized + state + viewerCanReact + path + position + createdAt + url + ...emojiReactionsController_reactable + __typename + } + } + } +} + +fragment emojiReactionsController_reactable on Reactable { + id + ...emojiReactionsView_reactable +} + +fragment emojiReactionsView_reactable on Reactable { + id + reactionGroups { + content + viewerHasReacted + users { + totalCount + } + } + viewerCanReact +} + +fragment prCheckoutController_repository on Repository { + name + owner { + __typename + login + id + } +} +*/ + +const node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "kind": "LocalArgument", + "name": "repoOwner", + "type": "String!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "repoName", + "type": "String!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "prNumber", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "reviewCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "reviewCursor", + "type": "String", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "threadCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "threadCursor", + "type": "String", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "commentCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "commentCursor", + "type": "String", + "defaultValue": null + } +], +v1 = [ + { + "kind": "Variable", + "name": "name", + "variableName": "repoName", + "type": "String!" + }, + { + "kind": "Variable", + "name": "owner", + "variableName": "repoOwner", + "type": "String!" + } +], +v2 = [ + { + "kind": "Variable", + "name": "number", + "variableName": "prNumber", + "type": "Int!" + } +], +v3 = { + "kind": "ScalarField", + "alias": null, + "name": "headRefOid", + "args": null, + "storageKey": null +}, +v4 = { + "kind": "ScalarField", + "alias": null, + "name": "name", + "args": null, + "storageKey": null +}, +v5 = { + "kind": "ScalarField", + "alias": null, + "name": "__typename", + "args": null, + "storageKey": null +}, +v6 = { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null +}, +v7 = { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null +}, +v8 = { + "kind": "LinkedField", + "alias": null, + "name": "owner", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v5/*: any*/), + (v6/*: any*/), + (v7/*: any*/) + ] +}, +v9 = { + "kind": "ScalarField", + "alias": null, + "name": "url", + "args": null, + "storageKey": null +}, +v10 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "reviewCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "reviewCount", + "type": "Int" + } +], +v11 = { + "kind": "LinkedField", + "alias": null, + "name": "pageInfo", + "storageKey": null, + "args": null, + "concreteType": "PageInfo", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "hasNextPage", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "endCursor", + "args": null, + "storageKey": null + } + ] +}, +v12 = { + "kind": "ScalarField", + "alias": null, + "name": "cursor", + "args": null, + "storageKey": null +}, +v13 = { + "kind": "ScalarField", + "alias": null, + "name": "bodyHTML", + "args": null, + "storageKey": null +}, +v14 = { + "kind": "ScalarField", + "alias": null, + "name": "state", + "args": null, + "storageKey": null +}, +v15 = { + "kind": "ScalarField", + "alias": null, + "name": "avatarUrl", + "args": null, + "storageKey": null +}, +v16 = { + "kind": "LinkedField", + "alias": null, + "name": "reactionGroups", + "storageKey": null, + "args": null, + "concreteType": "ReactionGroup", + "plural": true, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "content", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerHasReacted", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "users", + "storageKey": null, + "args": null, + "concreteType": "ReactingUserConnection", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "totalCount", + "args": null, + "storageKey": null + } + ] + } + ] +}, +v17 = { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanReact", + "args": null, + "storageKey": null +}, +v18 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "threadCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "threadCount", + "type": "Int" + } +], +v19 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "commentCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "commentCount", + "type": "Int" + } +]; +return { + "kind": "Request", + "fragment": { + "kind": "Fragment", + "name": "reviewsContainerQuery", + "type": "Query", + "metadata": null, + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "repository", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": "Repository", + "plural": false, + "selections": [ + { + "kind": "FragmentSpread", + "name": "reviewsController_repository", + "args": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "pullRequest", + "storageKey": null, + "args": (v2/*: any*/), + "concreteType": "PullRequest", + "plural": false, + "selections": [ + (v3/*: any*/), + { + "kind": "FragmentSpread", + "name": "aggregatedReviewsContainer_pullRequest", + "args": [ + { + "kind": "Variable", + "name": "commentCount", + "variableName": "commentCount", + "type": null + }, + { + "kind": "Variable", + "name": "commentCursor", + "variableName": "commentCursor", + "type": null + }, + { + "kind": "Variable", + "name": "reviewCount", + "variableName": "reviewCount", + "type": null + }, + { + "kind": "Variable", + "name": "reviewCursor", + "variableName": "reviewCursor", + "type": null + }, + { + "kind": "Variable", + "name": "threadCount", + "variableName": "threadCount", + "type": null + }, + { + "kind": "Variable", + "name": "threadCursor", + "variableName": "threadCursor", + "type": null + } + ] + }, + { + "kind": "FragmentSpread", + "name": "reviewsController_pullRequest", + "args": null + } + ] + } + ] + }, + { + "kind": "LinkedField", + "alias": null, + "name": "viewer", + "storageKey": null, + "args": null, + "concreteType": "User", + "plural": false, + "selections": [ + { + "kind": "FragmentSpread", + "name": "reviewsController_viewer", + "args": null + } + ] + } + ] + }, + "operation": { + "kind": "Operation", + "name": "reviewsContainerQuery", + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "repository", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": "Repository", + "plural": false, + "selections": [ + (v4/*: any*/), + (v8/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "pullRequest", + "storageKey": null, + "args": (v2/*: any*/), + "concreteType": "PullRequest", + "plural": false, + "selections": [ + (v3/*: any*/), + (v7/*: any*/), + (v9/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "reviews", + "storageKey": null, + "args": (v10/*: any*/), + "concreteType": "PullRequestReviewConnection", + "plural": false, + "selections": [ + (v11/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewEdge", + "plural": true, + "selections": [ + (v12/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReview", + "plural": false, + "selections": [ + (v7/*: any*/), + (v13/*: any*/), + (v14/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "submittedAt", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "author", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v5/*: any*/), + (v6/*: any*/), + (v15/*: any*/), + (v7/*: any*/) + ] + }, + (v16/*: any*/), + (v17/*: any*/), + (v5/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "reviews", + "args": (v10/*: any*/), + "handle": "connection", + "key": "ReviewSummariesAccumulator_reviews", + "filters": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "reviewThreads", + "storageKey": null, + "args": (v18/*: any*/), + "concreteType": "PullRequestReviewThreadConnection", + "plural": false, + "selections": [ + (v11/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewThreadEdge", + "plural": true, + "selections": [ + (v12/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewThread", + "plural": false, + "selections": [ + (v7/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "isResolved", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "resolvedBy", + "storageKey": null, + "args": null, + "concreteType": "User", + "plural": false, + "selections": [ + (v6/*: any*/), + (v7/*: any*/) + ] + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanResolve", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanUnresolve", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "comments", + "storageKey": null, + "args": (v19/*: any*/), + "concreteType": "PullRequestReviewCommentConnection", + "plural": false, + "selections": [ + (v11/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewCommentEdge", + "plural": true, + "selections": [ + (v12/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewComment", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "path", + "args": null, + "storageKey": null + }, + (v7/*: any*/), + (v13/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "isMinimized", + "args": null, + "storageKey": null + }, + (v14/*: any*/), + (v17/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "author", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v5/*: any*/), + (v15/*: any*/), + (v6/*: any*/), + (v7/*: any*/) + ] + }, + { + "kind": "ScalarField", + "alias": null, + "name": "position", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "createdAt", + "args": null, + "storageKey": null + }, + (v9/*: any*/), + (v16/*: any*/), + (v5/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "comments", + "args": (v19/*: any*/), + "handle": "connection", + "key": "ReviewCommentsAccumulator_comments", + "filters": null + }, + (v5/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "reviewThreads", + "args": (v18/*: any*/), + "handle": "connection", + "key": "ReviewThreadsAccumulator_reviewThreads", + "filters": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "number", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "headRefName", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "headRepository", + "storageKey": null, + "args": null, + "concreteType": "Repository", + "plural": false, + "selections": [ + (v4/*: any*/), + (v9/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "sshUrl", + "args": null, + "storageKey": null + }, + (v8/*: any*/), + (v7/*: any*/) + ] + } + ] + }, + (v7/*: any*/) + ] + }, + { + "kind": "LinkedField", + "alias": null, + "name": "viewer", + "storageKey": null, + "args": null, + "concreteType": "User", + "plural": false, + "selections": [ + (v7/*: any*/), + (v6/*: any*/), + (v15/*: any*/) + ] + } + ] + }, + "params": { + "operationKind": "query", + "name": "reviewsContainerQuery", + "id": null, + "text": "query reviewsContainerQuery(\n $repoOwner: String!\n $repoName: String!\n $prNumber: Int!\n $reviewCount: Int!\n $reviewCursor: String\n $threadCount: Int!\n $threadCursor: String\n $commentCount: Int!\n $commentCursor: String\n) {\n repository(owner: $repoOwner, name: $repoName) {\n ...reviewsController_repository\n pullRequest(number: $prNumber) {\n headRefOid\n ...aggregatedReviewsContainer_pullRequest_qdneZ\n ...reviewsController_pullRequest\n id\n }\n id\n }\n viewer {\n ...reviewsController_viewer\n id\n }\n}\n\nfragment reviewsController_repository on Repository {\n ...prCheckoutController_repository\n}\n\nfragment aggregatedReviewsContainer_pullRequest_qdneZ on PullRequest {\n id\n ...reviewSummariesAccumulator_pullRequest_2zzc96\n ...reviewThreadsAccumulator_pullRequest_CKDvj\n}\n\nfragment reviewsController_pullRequest on PullRequest {\n id\n ...prCheckoutController_pullRequest\n}\n\nfragment reviewsController_viewer on User {\n id\n login\n avatarUrl\n}\n\nfragment prCheckoutController_pullRequest on PullRequest {\n number\n headRefName\n headRepository {\n name\n url\n sshUrl\n owner {\n __typename\n login\n id\n }\n id\n }\n}\n\nfragment reviewSummariesAccumulator_pullRequest_2zzc96 on PullRequest {\n url\n reviews(first: $reviewCount, after: $reviewCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n bodyHTML\n state\n submittedAt\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n ...emojiReactionsController_reactable\n __typename\n }\n }\n }\n}\n\nfragment reviewThreadsAccumulator_pullRequest_CKDvj on PullRequest {\n url\n reviewThreads(first: $threadCount, after: $threadCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n isResolved\n resolvedBy {\n login\n id\n }\n viewerCanResolve\n viewerCanUnresolve\n ...reviewCommentsAccumulator_reviewThread_1VbUmL\n __typename\n }\n }\n }\n}\n\nfragment reviewCommentsAccumulator_reviewThread_1VbUmL on PullRequestReviewThread {\n id\n comments(first: $commentCount, after: $commentCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n isMinimized\n state\n viewerCanReact\n path\n position\n createdAt\n url\n ...emojiReactionsController_reactable\n __typename\n }\n }\n }\n}\n\nfragment emojiReactionsController_reactable on Reactable {\n id\n ...emojiReactionsView_reactable\n}\n\nfragment emojiReactionsView_reactable on Reactable {\n id\n reactionGroups {\n content\n viewerHasReacted\n users {\n totalCount\n }\n }\n viewerCanReact\n}\n\nfragment prCheckoutController_repository on Repository {\n name\n owner {\n __typename\n login\n id\n }\n}\n", + "metadata": {} + } +}; +})(); +// prettier-ignore +(node/*: any*/).hash = 'b05cc30cb078003afba9bd8c2de989fa'; +module.exports = node; diff --git a/lib/containers/__generated__/prReviewCommentsContainerQuery.graphql.js b/lib/containers/accumulators/__generated__/reviewCommentsAccumulatorQuery.graphql.js similarity index 65% rename from lib/containers/__generated__/prReviewCommentsContainerQuery.graphql.js rename to lib/containers/accumulators/__generated__/reviewCommentsAccumulatorQuery.graphql.js index b5fec8befa..256d1cf54b 100644 --- a/lib/containers/__generated__/prReviewCommentsContainerQuery.graphql.js +++ b/lib/containers/accumulators/__generated__/reviewCommentsAccumulatorQuery.graphql.js @@ -1,6 +1,6 @@ /** * @flow - * @relayHash 64864427d1bd48cdc524cbec06af166c + * @relayHash c409978f3cddca5654e502573335ffd9 */ /* eslint-disable */ @@ -9,42 +9,41 @@ /*:: import type { ConcreteRequest } from 'relay-runtime'; -type prReviewCommentsContainer_review$ref = any; -export type prReviewCommentsContainerQueryVariables = {| +type reviewCommentsAccumulator_reviewThread$ref = any; +export type reviewCommentsAccumulatorQueryVariables = {| + id: string, commentCount: number, commentCursor?: ?string, - id: string, |}; -export type prReviewCommentsContainerQueryResponse = {| +export type reviewCommentsAccumulatorQueryResponse = {| +node: ?{| - +$fragmentRefs: prReviewCommentsContainer_review$ref + +$fragmentRefs: reviewCommentsAccumulator_reviewThread$ref |} |}; -export type prReviewCommentsContainerQuery = {| - variables: prReviewCommentsContainerQueryVariables, - response: prReviewCommentsContainerQueryResponse, +export type reviewCommentsAccumulatorQuery = {| + variables: reviewCommentsAccumulatorQueryVariables, + response: reviewCommentsAccumulatorQueryResponse, |}; */ /* -query prReviewCommentsContainerQuery( +query reviewCommentsAccumulatorQuery( + $id: ID! $commentCount: Int! $commentCursor: String - $id: ID! ) { node(id: $id) { __typename - ... on PullRequestReview { - ...prReviewCommentsContainer_review_1VbUmL + ... on PullRequestReviewThread { + ...reviewCommentsAccumulator_reviewThread_1VbUmL } id } } -fragment prReviewCommentsContainer_review_1VbUmL on PullRequestReview { +fragment reviewCommentsAccumulator_reviewThread_1VbUmL on PullRequestReviewThread { id - submittedAt comments(first: $commentCount, after: $commentCursor) { pageInfo { hasNextPage @@ -64,38 +63,55 @@ fragment prReviewCommentsContainer_review_1VbUmL on PullRequestReview { } bodyHTML isMinimized + state + viewerCanReact path position - replyTo { - id - } createdAt url + ...emojiReactionsController_reactable __typename } } } } + +fragment emojiReactionsController_reactable on Reactable { + id + ...emojiReactionsView_reactable +} + +fragment emojiReactionsView_reactable on Reactable { + id + reactionGroups { + content + viewerHasReacted + users { + totalCount + } + } + viewerCanReact +} */ const node/*: ConcreteRequest*/ = (function(){ var v0 = [ { "kind": "LocalArgument", - "name": "commentCount", - "type": "Int!", + "name": "id", + "type": "ID!", "defaultValue": null }, { "kind": "LocalArgument", - "name": "commentCursor", - "type": "String", + "name": "commentCount", + "type": "Int!", "defaultValue": null }, { "kind": "LocalArgument", - "name": "id", - "type": "ID!", + "name": "commentCursor", + "type": "String", "defaultValue": null } ], @@ -139,7 +155,7 @@ return { "kind": "Request", "fragment": { "kind": "Fragment", - "name": "prReviewCommentsContainerQuery", + "name": "reviewCommentsAccumulatorQuery", "type": "Query", "metadata": null, "argumentDefinitions": (v0/*: any*/), @@ -155,11 +171,11 @@ return { "selections": [ { "kind": "InlineFragment", - "type": "PullRequestReview", + "type": "PullRequestReviewThread", "selections": [ { "kind": "FragmentSpread", - "name": "prReviewCommentsContainer_review", + "name": "reviewCommentsAccumulator_reviewThread", "args": [ { "kind": "Variable", @@ -183,7 +199,7 @@ return { }, "operation": { "kind": "Operation", - "name": "prReviewCommentsContainerQuery", + "name": "reviewCommentsAccumulatorQuery", "argumentDefinitions": (v0/*: any*/), "selections": [ { @@ -199,15 +215,8 @@ return { (v3/*: any*/), { "kind": "InlineFragment", - "type": "PullRequestReview", + "type": "PullRequestReviewThread", "selections": [ - { - "kind": "ScalarField", - "alias": null, - "name": "submittedAt", - "args": null, - "storageKey": null - }, { "kind": "LinkedField", "alias": null, @@ -267,34 +276,14 @@ return { "concreteType": "PullRequestReviewComment", "plural": false, "selections": [ - (v3/*: any*/), { - "kind": "LinkedField", + "kind": "ScalarField", "alias": null, - "name": "author", - "storageKey": null, + "name": "path", "args": null, - "concreteType": null, - "plural": false, - "selections": [ - (v2/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "avatarUrl", - "args": null, - "storageKey": null - }, - { - "kind": "ScalarField", - "alias": null, - "name": "login", - "args": null, - "storageKey": null - }, - (v3/*: any*/) - ] + "storageKey": null }, + (v3/*: any*/), { "kind": "ScalarField", "alias": null, @@ -312,29 +301,51 @@ return { { "kind": "ScalarField", "alias": null, - "name": "path", + "name": "state", "args": null, "storageKey": null }, { "kind": "ScalarField", "alias": null, - "name": "position", + "name": "viewerCanReact", "args": null, "storageKey": null }, { "kind": "LinkedField", "alias": null, - "name": "replyTo", + "name": "author", "storageKey": null, "args": null, - "concreteType": "PullRequestReviewComment", + "concreteType": null, "plural": false, "selections": [ + (v2/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "avatarUrl", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null + }, (v3/*: any*/) ] }, + { + "kind": "ScalarField", + "alias": null, + "name": "position", + "args": null, + "storageKey": null + }, { "kind": "ScalarField", "alias": null, @@ -349,6 +360,49 @@ return { "args": null, "storageKey": null }, + { + "kind": "LinkedField", + "alias": null, + "name": "reactionGroups", + "storageKey": null, + "args": null, + "concreteType": "ReactionGroup", + "plural": true, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "content", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerHasReacted", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "users", + "storageKey": null, + "args": null, + "concreteType": "ReactingUserConnection", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "totalCount", + "args": null, + "storageKey": null + } + ] + } + ] + }, (v2/*: any*/) ] } @@ -362,7 +416,7 @@ return { "name": "comments", "args": (v4/*: any*/), "handle": "connection", - "key": "PrReviewCommentsContainer_comments", + "key": "ReviewCommentsAccumulator_comments", "filters": null } ] @@ -373,13 +427,13 @@ return { }, "params": { "operationKind": "query", - "name": "prReviewCommentsContainerQuery", + "name": "reviewCommentsAccumulatorQuery", "id": null, - "text": "query prReviewCommentsContainerQuery(\n $commentCount: Int!\n $commentCursor: String\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ... on PullRequestReview {\n ...prReviewCommentsContainer_review_1VbUmL\n }\n id\n }\n}\n\nfragment prReviewCommentsContainer_review_1VbUmL on PullRequestReview {\n id\n submittedAt\n comments(first: $commentCount, after: $commentCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n isMinimized\n path\n position\n replyTo {\n id\n }\n createdAt\n url\n __typename\n }\n }\n }\n}\n", + "text": "query reviewCommentsAccumulatorQuery(\n $id: ID!\n $commentCount: Int!\n $commentCursor: String\n) {\n node(id: $id) {\n __typename\n ... on PullRequestReviewThread {\n ...reviewCommentsAccumulator_reviewThread_1VbUmL\n }\n id\n }\n}\n\nfragment reviewCommentsAccumulator_reviewThread_1VbUmL on PullRequestReviewThread {\n id\n comments(first: $commentCount, after: $commentCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n isMinimized\n state\n viewerCanReact\n path\n position\n createdAt\n url\n ...emojiReactionsController_reactable\n __typename\n }\n }\n }\n}\n\nfragment emojiReactionsController_reactable on Reactable {\n id\n ...emojiReactionsView_reactable\n}\n\nfragment emojiReactionsView_reactable on Reactable {\n id\n reactionGroups {\n content\n viewerHasReacted\n users {\n totalCount\n }\n }\n viewerCanReact\n}\n", "metadata": {} } }; })(); // prettier-ignore -(node/*: any*/).hash = 'd48507a6296f84357e94000010c34713'; +(node/*: any*/).hash = '25bc4376239d278025fc1f353900572a'; module.exports = node; diff --git a/lib/containers/__generated__/prReviewCommentsContainer_review.graphql.js b/lib/containers/accumulators/__generated__/reviewCommentsAccumulator_reviewThread.graphql.js similarity index 82% rename from lib/containers/__generated__/prReviewCommentsContainer_review.graphql.js rename to lib/containers/accumulators/__generated__/reviewCommentsAccumulator_reviewThread.graphql.js index 078224f564..92ebd27226 100644 --- a/lib/containers/__generated__/prReviewCommentsContainer_review.graphql.js +++ b/lib/containers/accumulators/__generated__/reviewCommentsAccumulator_reviewThread.graphql.js @@ -8,11 +8,12 @@ /*:: import type { ReaderFragment } from 'relay-runtime'; +type emojiReactionsController_reactable$ref = any; +export type PullRequestReviewCommentState = "PENDING" | "SUBMITTED" | "%future added value"; import type { FragmentReference } from "relay-runtime"; -declare export opaque type prReviewCommentsContainer_review$ref: FragmentReference; -export type prReviewCommentsContainer_review = {| +declare export opaque type reviewCommentsAccumulator_reviewThread$ref: FragmentReference; +export type reviewCommentsAccumulator_reviewThread = {| +id: string, - +submittedAt: ?any, +comments: {| +pageInfo: {| +hasNextPage: boolean, @@ -28,17 +29,17 @@ export type prReviewCommentsContainer_review = {| |}, +bodyHTML: any, +isMinimized: boolean, + +state: PullRequestReviewCommentState, + +viewerCanReact: boolean, +path: string, +position: ?number, - +replyTo: ?{| - +id: string - |}, +createdAt: any, +url: any, + +$fragmentRefs: emojiReactionsController_reactable$ref, |}, |}>, |}, - +$refType: prReviewCommentsContainer_review$ref, + +$refType: reviewCommentsAccumulator_reviewThread$ref, |}; */ @@ -53,8 +54,8 @@ var v0 = { }; return { "kind": "Fragment", - "name": "prReviewCommentsContainer_review", - "type": "PullRequestReview", + "name": "reviewCommentsAccumulator_reviewThread", + "type": "PullRequestReviewThread", "metadata": { "connection": [ { @@ -83,17 +84,10 @@ return { ], "selections": [ (v0/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "submittedAt", - "args": null, - "storageKey": null - }, { "kind": "LinkedField", "alias": "comments", - "name": "__PrReviewCommentsContainer_comments_connection", + "name": "__ReviewCommentsAccumulator_comments_connection", "storageKey": null, "args": null, "concreteType": "PullRequestReviewCommentConnection", @@ -149,32 +143,14 @@ return { "concreteType": "PullRequestReviewComment", "plural": false, "selections": [ - (v0/*: any*/), { - "kind": "LinkedField", + "kind": "ScalarField", "alias": null, - "name": "author", - "storageKey": null, + "name": "path", "args": null, - "concreteType": null, - "plural": false, - "selections": [ - { - "kind": "ScalarField", - "alias": null, - "name": "avatarUrl", - "args": null, - "storageKey": null - }, - { - "kind": "ScalarField", - "alias": null, - "name": "login", - "args": null, - "storageKey": null - } - ] + "storageKey": null }, + (v0/*: any*/), { "kind": "ScalarField", "alias": null, @@ -192,29 +168,49 @@ return { { "kind": "ScalarField", "alias": null, - "name": "path", + "name": "state", "args": null, "storageKey": null }, { "kind": "ScalarField", "alias": null, - "name": "position", + "name": "viewerCanReact", "args": null, "storageKey": null }, { "kind": "LinkedField", "alias": null, - "name": "replyTo", + "name": "author", "storageKey": null, "args": null, - "concreteType": "PullRequestReviewComment", + "concreteType": null, "plural": false, "selections": [ - (v0/*: any*/) + { + "kind": "ScalarField", + "alias": null, + "name": "avatarUrl", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null + } ] }, + { + "kind": "ScalarField", + "alias": null, + "name": "position", + "args": null, + "storageKey": null + }, { "kind": "ScalarField", "alias": null, @@ -229,6 +225,11 @@ return { "args": null, "storageKey": null }, + { + "kind": "FragmentSpread", + "name": "emojiReactionsController_reactable", + "args": null + }, { "kind": "ScalarField", "alias": null, @@ -246,5 +247,5 @@ return { }; })(); // prettier-ignore -(node/*: any*/).hash = 'ccea6475d7b22690b4aa61757b705968'; +(node/*: any*/).hash = '8bb0ae25941d9a3b1f4aaa5cbddf89f0'; module.exports = node; diff --git a/lib/containers/accumulators/__generated__/reviewSummariesAccumulatorQuery.graphql.js b/lib/containers/accumulators/__generated__/reviewSummariesAccumulatorQuery.graphql.js new file mode 100644 index 0000000000..fb6ac52636 --- /dev/null +++ b/lib/containers/accumulators/__generated__/reviewSummariesAccumulatorQuery.graphql.js @@ -0,0 +1,415 @@ +/** + * @flow + * @relayHash a29994c80b3e7d4d9deaee2718fcb51c + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest } from 'relay-runtime'; +type reviewSummariesAccumulator_pullRequest$ref = any; +export type reviewSummariesAccumulatorQueryVariables = {| + url: any, + reviewCount: number, + reviewCursor?: ?string, +|}; +export type reviewSummariesAccumulatorQueryResponse = {| + +resource: ?{| + +$fragmentRefs: reviewSummariesAccumulator_pullRequest$ref + |} +|}; +export type reviewSummariesAccumulatorQuery = {| + variables: reviewSummariesAccumulatorQueryVariables, + response: reviewSummariesAccumulatorQueryResponse, +|}; +*/ + + +/* +query reviewSummariesAccumulatorQuery( + $url: URI! + $reviewCount: Int! + $reviewCursor: String +) { + resource(url: $url) { + __typename + ... on PullRequest { + ...reviewSummariesAccumulator_pullRequest_2zzc96 + } + ... on Node { + id + } + } +} + +fragment reviewSummariesAccumulator_pullRequest_2zzc96 on PullRequest { + url + reviews(first: $reviewCount, after: $reviewCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + bodyHTML + state + submittedAt + author { + __typename + login + avatarUrl + ... on Node { + id + } + } + ...emojiReactionsController_reactable + __typename + } + } + } +} + +fragment emojiReactionsController_reactable on Reactable { + id + ...emojiReactionsView_reactable +} + +fragment emojiReactionsView_reactable on Reactable { + id + reactionGroups { + content + viewerHasReacted + users { + totalCount + } + } + viewerCanReact +} +*/ + +const node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "kind": "LocalArgument", + "name": "url", + "type": "URI!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "reviewCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "reviewCursor", + "type": "String", + "defaultValue": null + } +], +v1 = [ + { + "kind": "Variable", + "name": "url", + "variableName": "url", + "type": "URI!" + } +], +v2 = { + "kind": "ScalarField", + "alias": null, + "name": "__typename", + "args": null, + "storageKey": null +}, +v3 = { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null +}, +v4 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "reviewCursor", + "type": "String" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "reviewCount", + "type": "Int" + } +]; +return { + "kind": "Request", + "fragment": { + "kind": "Fragment", + "name": "reviewSummariesAccumulatorQuery", + "type": "Query", + "metadata": null, + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "resource", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": null, + "plural": false, + "selections": [ + { + "kind": "InlineFragment", + "type": "PullRequest", + "selections": [ + { + "kind": "FragmentSpread", + "name": "reviewSummariesAccumulator_pullRequest", + "args": [ + { + "kind": "Variable", + "name": "reviewCount", + "variableName": "reviewCount", + "type": null + }, + { + "kind": "Variable", + "name": "reviewCursor", + "variableName": "reviewCursor", + "type": null + } + ] + } + ] + } + ] + } + ] + }, + "operation": { + "kind": "Operation", + "name": "reviewSummariesAccumulatorQuery", + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "resource", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": null, + "plural": false, + "selections": [ + (v2/*: any*/), + (v3/*: any*/), + { + "kind": "InlineFragment", + "type": "PullRequest", + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "url", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "reviews", + "storageKey": null, + "args": (v4/*: any*/), + "concreteType": "PullRequestReviewConnection", + "plural": false, + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "pageInfo", + "storageKey": null, + "args": null, + "concreteType": "PageInfo", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "hasNextPage", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "endCursor", + "args": null, + "storageKey": null + } + ] + }, + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewEdge", + "plural": true, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "cursor", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReview", + "plural": false, + "selections": [ + (v3/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "bodyHTML", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "state", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "submittedAt", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "author", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + (v2/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "avatarUrl", + "args": null, + "storageKey": null + }, + (v3/*: any*/) + ] + }, + { + "kind": "LinkedField", + "alias": null, + "name": "reactionGroups", + "storageKey": null, + "args": null, + "concreteType": "ReactionGroup", + "plural": true, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "content", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerHasReacted", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "users", + "storageKey": null, + "args": null, + "concreteType": "ReactingUserConnection", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "totalCount", + "args": null, + "storageKey": null + } + ] + } + ] + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanReact", + "args": null, + "storageKey": null + }, + (v2/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "reviews", + "args": (v4/*: any*/), + "handle": "connection", + "key": "ReviewSummariesAccumulator_reviews", + "filters": null + } + ] + } + ] + } + ] + }, + "params": { + "operationKind": "query", + "name": "reviewSummariesAccumulatorQuery", + "id": null, + "text": "query reviewSummariesAccumulatorQuery(\n $url: URI!\n $reviewCount: Int!\n $reviewCursor: String\n) {\n resource(url: $url) {\n __typename\n ... on PullRequest {\n ...reviewSummariesAccumulator_pullRequest_2zzc96\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment reviewSummariesAccumulator_pullRequest_2zzc96 on PullRequest {\n url\n reviews(first: $reviewCount, after: $reviewCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n bodyHTML\n state\n submittedAt\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n ...emojiReactionsController_reactable\n __typename\n }\n }\n }\n}\n\nfragment emojiReactionsController_reactable on Reactable {\n id\n ...emojiReactionsView_reactable\n}\n\nfragment emojiReactionsView_reactable on Reactable {\n id\n reactionGroups {\n content\n viewerHasReacted\n users {\n totalCount\n }\n }\n viewerCanReact\n}\n", + "metadata": {} + } +}; +})(); +// prettier-ignore +(node/*: any*/).hash = '74bb2a56369e3c54b76c4ce7c17f328e'; +module.exports = node; diff --git a/lib/containers/__generated__/prReviewsContainer_pullRequest.graphql.js b/lib/containers/accumulators/__generated__/reviewSummariesAccumulator_pullRequest.graphql.js similarity index 74% rename from lib/containers/__generated__/prReviewsContainer_pullRequest.graphql.js rename to lib/containers/accumulators/__generated__/reviewSummariesAccumulator_pullRequest.graphql.js index 1a484c9a61..b5732e3de3 100644 --- a/lib/containers/__generated__/prReviewsContainer_pullRequest.graphql.js +++ b/lib/containers/accumulators/__generated__/reviewSummariesAccumulator_pullRequest.graphql.js @@ -8,11 +8,11 @@ /*:: import type { ReaderFragment } from 'relay-runtime'; -type prReviewCommentsContainer_review$ref = any; +type emojiReactionsController_reactable$ref = any; export type PullRequestReviewState = "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED" | "PENDING" | "%future added value"; import type { FragmentReference } from "relay-runtime"; -declare export opaque type prReviewsContainer_pullRequest$ref: FragmentReference; -export type prReviewsContainer_pullRequest = {| +declare export opaque type reviewSummariesAccumulator_pullRequest$ref: FragmentReference; +export type reviewSummariesAccumulator_pullRequest = {| +url: any, +reviews: ?{| +pageInfo: {| @@ -23,27 +23,25 @@ export type prReviewsContainer_pullRequest = {| +cursor: string, +node: ?{| +id: string, - +body: string, + +bodyHTML: any, +state: PullRequestReviewState, +submittedAt: ?any, - +login: ?{| - +login: string - |}, +author: ?{| - +avatarUrl: any + +login: string, + +avatarUrl: any, |}, - +$fragmentRefs: prReviewCommentsContainer_review$ref, + +$fragmentRefs: emojiReactionsController_reactable$ref, |}, |}>, |}, - +$refType: prReviewsContainer_pullRequest$ref, + +$refType: reviewSummariesAccumulator_pullRequest$ref, |}; */ const node/*: ReaderFragment*/ = { "kind": "Fragment", - "name": "prReviewsContainer_pullRequest", + "name": "reviewSummariesAccumulator_pullRequest", "type": "PullRequest", "metadata": { "connection": [ @@ -69,18 +67,6 @@ const node/*: ReaderFragment*/ = { "name": "reviewCursor", "type": "String", "defaultValue": null - }, - { - "kind": "LocalArgument", - "name": "commentCount", - "type": "Int!", - "defaultValue": null - }, - { - "kind": "LocalArgument", - "name": "commentCursor", - "type": "String", - "defaultValue": null } ], "selections": [ @@ -94,7 +80,7 @@ const node/*: ReaderFragment*/ = { { "kind": "LinkedField", "alias": "reviews", - "name": "__PrReviewsContainer_reviews_connection", + "name": "__ReviewSummariesAccumulator_reviews_connection", "storageKey": null, "args": null, "concreteType": "PullRequestReviewConnection", @@ -160,7 +146,7 @@ const node/*: ReaderFragment*/ = { { "kind": "ScalarField", "alias": null, - "name": "body", + "name": "bodyHTML", "args": null, "storageKey": null }, @@ -180,7 +166,7 @@ const node/*: ReaderFragment*/ = { }, { "kind": "LinkedField", - "alias": "login", + "alias": null, "name": "author", "storageKey": null, "args": null, @@ -193,18 +179,7 @@ const node/*: ReaderFragment*/ = { "name": "login", "args": null, "storageKey": null - } - ] - }, - { - "kind": "LinkedField", - "alias": null, - "name": "author", - "storageKey": null, - "args": null, - "concreteType": null, - "plural": false, - "selections": [ + }, { "kind": "ScalarField", "alias": null, @@ -216,21 +191,8 @@ const node/*: ReaderFragment*/ = { }, { "kind": "FragmentSpread", - "name": "prReviewCommentsContainer_review", - "args": [ - { - "kind": "Variable", - "name": "commentCount", - "variableName": "commentCount", - "type": null - }, - { - "kind": "Variable", - "name": "commentCursor", - "variableName": "commentCursor", - "type": null - } - ] + "name": "emojiReactionsController_reactable", + "args": null }, { "kind": "ScalarField", @@ -248,5 +210,5 @@ const node/*: ReaderFragment*/ = { ] }; // prettier-ignore -(node/*: any*/).hash = '924352ca8f5ccf61cd9fd718d2941fa2'; +(node/*: any*/).hash = '08f32f28800f3ddd075e6be89d54f2ad'; module.exports = node; diff --git a/lib/containers/__generated__/prReviewsContainerQuery.graphql.js b/lib/containers/accumulators/__generated__/reviewThreadsAccumulatorQuery.graphql.js similarity index 61% rename from lib/containers/__generated__/prReviewsContainerQuery.graphql.js rename to lib/containers/accumulators/__generated__/reviewThreadsAccumulatorQuery.graphql.js index 7aab59da44..6756192b3c 100644 --- a/lib/containers/__generated__/prReviewsContainerQuery.graphql.js +++ b/lib/containers/accumulators/__generated__/reviewThreadsAccumulatorQuery.graphql.js @@ -1,6 +1,6 @@ /** * @flow - * @relayHash 36f275cbd20ba5097efaea0f4cc9681f + * @relayHash f0af3447e7179f4aea01607011f88420 */ /* eslint-disable */ @@ -9,38 +9,36 @@ /*:: import type { ConcreteRequest } from 'relay-runtime'; -type prReviewsContainer_pullRequest$ref = any; -export type prReviewsContainerQueryVariables = {| - reviewCount: number, - reviewCursor?: ?string, - commentCount: number, - commentCursor?: ?string, +type reviewThreadsAccumulator_pullRequest$ref = any; +export type reviewThreadsAccumulatorQueryVariables = {| url: any, + threadCount: number, + threadCursor?: ?string, + commentCount: number, |}; -export type prReviewsContainerQueryResponse = {| +export type reviewThreadsAccumulatorQueryResponse = {| +resource: ?{| - +$fragmentRefs: prReviewsContainer_pullRequest$ref + +$fragmentRefs: reviewThreadsAccumulator_pullRequest$ref |} |}; -export type prReviewsContainerQuery = {| - variables: prReviewsContainerQueryVariables, - response: prReviewsContainerQueryResponse, +export type reviewThreadsAccumulatorQuery = {| + variables: reviewThreadsAccumulatorQueryVariables, + response: reviewThreadsAccumulatorQueryResponse, |}; */ /* -query prReviewsContainerQuery( - $reviewCount: Int! - $reviewCursor: String - $commentCount: Int! - $commentCursor: String +query reviewThreadsAccumulatorQuery( $url: URI! + $threadCount: Int! + $threadCursor: String + $commentCount: Int! ) { resource(url: $url) { __typename ... on PullRequest { - ...prReviewsContainer_pullRequest_y4qc0 + ...reviewThreadsAccumulator_pullRequest_3dVVow } ... on Node { id @@ -48,9 +46,9 @@ query prReviewsContainerQuery( } } -fragment prReviewsContainer_pullRequest_y4qc0 on PullRequest { +fragment reviewThreadsAccumulator_pullRequest_3dVVow on PullRequest { url - reviews(first: $reviewCount, after: $reviewCursor) { + reviewThreads(first: $threadCount, after: $threadCursor) { pageInfo { hasNextPage endCursor @@ -59,34 +57,23 @@ fragment prReviewsContainer_pullRequest_y4qc0 on PullRequest { cursor node { id - body - state - submittedAt - login: author { - __typename + isResolved + resolvedBy { login - ... on Node { - id - } - } - author { - __typename - avatarUrl - ... on Node { - id - } + id } - ...prReviewCommentsContainer_review_1VbUmL + viewerCanResolve + viewerCanUnresolve + ...reviewCommentsAccumulator_reviewThread_1UlnwR __typename } } } } -fragment prReviewCommentsContainer_review_1VbUmL on PullRequestReview { +fragment reviewCommentsAccumulator_reviewThread_1UlnwR on PullRequestReviewThread { id - submittedAt - comments(first: $commentCount, after: $commentCursor) { + comments(first: $commentCount) { pageInfo { hasNextPage endCursor @@ -105,50 +92,61 @@ fragment prReviewCommentsContainer_review_1VbUmL on PullRequestReview { } bodyHTML isMinimized + state + viewerCanReact path position - replyTo { - id - } createdAt url + ...emojiReactionsController_reactable __typename } } } } + +fragment emojiReactionsController_reactable on Reactable { + id + ...emojiReactionsView_reactable +} + +fragment emojiReactionsView_reactable on Reactable { + id + reactionGroups { + content + viewerHasReacted + users { + totalCount + } + } + viewerCanReact +} */ const node/*: ConcreteRequest*/ = (function(){ var v0 = [ { "kind": "LocalArgument", - "name": "reviewCount", - "type": "Int!", - "defaultValue": null - }, - { - "kind": "LocalArgument", - "name": "reviewCursor", - "type": "String", + "name": "url", + "type": "URI!", "defaultValue": null }, { "kind": "LocalArgument", - "name": "commentCount", + "name": "threadCount", "type": "Int!", "defaultValue": null }, { "kind": "LocalArgument", - "name": "commentCursor", + "name": "threadCursor", "type": "String", "defaultValue": null }, { "kind": "LocalArgument", - "name": "url", - "type": "URI!", + "name": "commentCount", + "type": "Int!", "defaultValue": null } ], @@ -185,13 +183,13 @@ v5 = [ { "kind": "Variable", "name": "after", - "variableName": "reviewCursor", + "variableName": "threadCursor", "type": "String" }, { "kind": "Variable", "name": "first", - "variableName": "reviewCount", + "variableName": "threadCount", "type": "Int" } ], @@ -234,20 +232,7 @@ v8 = { "args": null, "storageKey": null }, -v9 = { - "kind": "ScalarField", - "alias": null, - "name": "avatarUrl", - "args": null, - "storageKey": null -}, -v10 = [ - { - "kind": "Variable", - "name": "after", - "variableName": "commentCursor", - "type": "String" - }, +v9 = [ { "kind": "Variable", "name": "first", @@ -259,7 +244,7 @@ return { "kind": "Request", "fragment": { "kind": "Fragment", - "name": "prReviewsContainerQuery", + "name": "reviewThreadsAccumulatorQuery", "type": "Query", "metadata": null, "argumentDefinitions": (v0/*: any*/), @@ -279,7 +264,7 @@ return { "selections": [ { "kind": "FragmentSpread", - "name": "prReviewsContainer_pullRequest", + "name": "reviewThreadsAccumulator_pullRequest", "args": [ { "kind": "Variable", @@ -289,20 +274,14 @@ return { }, { "kind": "Variable", - "name": "commentCursor", - "variableName": "commentCursor", - "type": null - }, - { - "kind": "Variable", - "name": "reviewCount", - "variableName": "reviewCount", + "name": "threadCount", + "variableName": "threadCount", "type": null }, { "kind": "Variable", - "name": "reviewCursor", - "variableName": "reviewCursor", + "name": "threadCursor", + "variableName": "threadCursor", "type": null } ] @@ -315,7 +294,7 @@ return { }, "operation": { "kind": "Operation", - "name": "prReviewsContainerQuery", + "name": "reviewThreadsAccumulatorQuery", "argumentDefinitions": (v0/*: any*/), "selections": [ { @@ -337,10 +316,10 @@ return { { "kind": "LinkedField", "alias": null, - "name": "reviews", + "name": "reviewThreads", "storageKey": null, "args": (v5/*: any*/), - "concreteType": "PullRequestReviewConnection", + "concreteType": "PullRequestReviewThreadConnection", "plural": false, "selections": [ (v6/*: any*/), @@ -350,7 +329,7 @@ return { "name": "edges", "storageKey": null, "args": null, - "concreteType": "PullRequestReviewEdge", + "concreteType": "PullRequestReviewThreadEdge", "plural": true, "selections": [ (v7/*: any*/), @@ -360,65 +339,50 @@ return { "name": "node", "storageKey": null, "args": null, - "concreteType": "PullRequestReview", + "concreteType": "PullRequestReviewThread", "plural": false, "selections": [ (v3/*: any*/), { "kind": "ScalarField", "alias": null, - "name": "body", - "args": null, - "storageKey": null - }, - { - "kind": "ScalarField", - "alias": null, - "name": "state", - "args": null, - "storageKey": null - }, - { - "kind": "ScalarField", - "alias": null, - "name": "submittedAt", + "name": "isResolved", "args": null, "storageKey": null }, { "kind": "LinkedField", - "alias": "login", - "name": "author", + "alias": null, + "name": "resolvedBy", "storageKey": null, "args": null, - "concreteType": null, + "concreteType": "User", "plural": false, "selections": [ - (v2/*: any*/), (v8/*: any*/), (v3/*: any*/) ] }, { - "kind": "LinkedField", + "kind": "ScalarField", "alias": null, - "name": "author", - "storageKey": null, + "name": "viewerCanResolve", "args": null, - "concreteType": null, - "plural": false, - "selections": [ - (v2/*: any*/), - (v9/*: any*/), - (v3/*: any*/) - ] + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanUnresolve", + "args": null, + "storageKey": null }, { "kind": "LinkedField", "alias": null, "name": "comments", "storageKey": null, - "args": (v10/*: any*/), + "args": (v9/*: any*/), "concreteType": "PullRequestReviewCommentConnection", "plural": false, "selections": [ @@ -442,22 +406,14 @@ return { "concreteType": "PullRequestReviewComment", "plural": false, "selections": [ - (v3/*: any*/), { - "kind": "LinkedField", + "kind": "ScalarField", "alias": null, - "name": "author", - "storageKey": null, + "name": "path", "args": null, - "concreteType": null, - "plural": false, - "selections": [ - (v2/*: any*/), - (v9/*: any*/), - (v8/*: any*/), - (v3/*: any*/) - ] + "storageKey": null }, + (v3/*: any*/), { "kind": "ScalarField", "alias": null, @@ -475,29 +431,45 @@ return { { "kind": "ScalarField", "alias": null, - "name": "path", + "name": "state", "args": null, "storageKey": null }, { "kind": "ScalarField", "alias": null, - "name": "position", + "name": "viewerCanReact", "args": null, "storageKey": null }, { "kind": "LinkedField", "alias": null, - "name": "replyTo", + "name": "author", "storageKey": null, "args": null, - "concreteType": "PullRequestReviewComment", + "concreteType": null, "plural": false, "selections": [ + (v2/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "avatarUrl", + "args": null, + "storageKey": null + }, + (v8/*: any*/), (v3/*: any*/) ] }, + { + "kind": "ScalarField", + "alias": null, + "name": "position", + "args": null, + "storageKey": null + }, { "kind": "ScalarField", "alias": null, @@ -506,6 +478,49 @@ return { "storageKey": null }, (v4/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "reactionGroups", + "storageKey": null, + "args": null, + "concreteType": "ReactionGroup", + "plural": true, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "content", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerHasReacted", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "users", + "storageKey": null, + "args": null, + "concreteType": "ReactingUserConnection", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "totalCount", + "args": null, + "storageKey": null + } + ] + } + ] + }, (v2/*: any*/) ] } @@ -517,9 +532,9 @@ return { "kind": "LinkedHandle", "alias": null, "name": "comments", - "args": (v10/*: any*/), + "args": (v9/*: any*/), "handle": "connection", - "key": "PrReviewCommentsContainer_comments", + "key": "ReviewCommentsAccumulator_comments", "filters": null }, (v2/*: any*/) @@ -532,10 +547,10 @@ return { { "kind": "LinkedHandle", "alias": null, - "name": "reviews", + "name": "reviewThreads", "args": (v5/*: any*/), "handle": "connection", - "key": "PrReviewsContainer_reviews", + "key": "ReviewThreadsAccumulator_reviewThreads", "filters": null } ] @@ -546,13 +561,13 @@ return { }, "params": { "operationKind": "query", - "name": "prReviewsContainerQuery", + "name": "reviewThreadsAccumulatorQuery", "id": null, - "text": "query prReviewsContainerQuery(\n $reviewCount: Int!\n $reviewCursor: String\n $commentCount: Int!\n $commentCursor: String\n $url: URI!\n) {\n resource(url: $url) {\n __typename\n ... on PullRequest {\n ...prReviewsContainer_pullRequest_y4qc0\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment prReviewsContainer_pullRequest_y4qc0 on PullRequest {\n url\n reviews(first: $reviewCount, after: $reviewCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n body\n state\n submittedAt\n login: author {\n __typename\n login\n ... on Node {\n id\n }\n }\n author {\n __typename\n avatarUrl\n ... on Node {\n id\n }\n }\n ...prReviewCommentsContainer_review_1VbUmL\n __typename\n }\n }\n }\n}\n\nfragment prReviewCommentsContainer_review_1VbUmL on PullRequestReview {\n id\n submittedAt\n comments(first: $commentCount, after: $commentCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n isMinimized\n path\n position\n replyTo {\n id\n }\n createdAt\n url\n __typename\n }\n }\n }\n}\n", + "text": "query reviewThreadsAccumulatorQuery(\n $url: URI!\n $threadCount: Int!\n $threadCursor: String\n $commentCount: Int!\n) {\n resource(url: $url) {\n __typename\n ... on PullRequest {\n ...reviewThreadsAccumulator_pullRequest_3dVVow\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment reviewThreadsAccumulator_pullRequest_3dVVow on PullRequest {\n url\n reviewThreads(first: $threadCount, after: $threadCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n isResolved\n resolvedBy {\n login\n id\n }\n viewerCanResolve\n viewerCanUnresolve\n ...reviewCommentsAccumulator_reviewThread_1UlnwR\n __typename\n }\n }\n }\n}\n\nfragment reviewCommentsAccumulator_reviewThread_1UlnwR on PullRequestReviewThread {\n id\n comments(first: $commentCount) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n isMinimized\n state\n viewerCanReact\n path\n position\n createdAt\n url\n ...emojiReactionsController_reactable\n __typename\n }\n }\n }\n}\n\nfragment emojiReactionsController_reactable on Reactable {\n id\n ...emojiReactionsView_reactable\n}\n\nfragment emojiReactionsView_reactable on Reactable {\n id\n reactionGroups {\n content\n viewerHasReacted\n users {\n totalCount\n }\n }\n viewerCanReact\n}\n", "metadata": {} } }; })(); // prettier-ignore -(node/*: any*/).hash = 'a84a1ddfd0a7a0667a57d94d5db110cf'; +(node/*: any*/).hash = 'e79afa42892ad508af3b22ca911cd7c5'; module.exports = node; diff --git a/lib/containers/accumulators/__generated__/reviewThreadsAccumulator_pullRequest.graphql.js b/lib/containers/accumulators/__generated__/reviewThreadsAccumulator_pullRequest.graphql.js new file mode 100644 index 0000000000..54d3e792cf --- /dev/null +++ b/lib/containers/accumulators/__generated__/reviewThreadsAccumulator_pullRequest.graphql.js @@ -0,0 +1,230 @@ +/** + * @flow + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ReaderFragment } from 'relay-runtime'; +type reviewCommentsAccumulator_reviewThread$ref = any; +import type { FragmentReference } from "relay-runtime"; +declare export opaque type reviewThreadsAccumulator_pullRequest$ref: FragmentReference; +export type reviewThreadsAccumulator_pullRequest = {| + +url: any, + +reviewThreads: {| + +pageInfo: {| + +hasNextPage: boolean, + +endCursor: ?string, + |}, + +edges: ?$ReadOnlyArray, + |}, + +$refType: reviewThreadsAccumulator_pullRequest$ref, +|}; +*/ + + +const node/*: ReaderFragment*/ = { + "kind": "Fragment", + "name": "reviewThreadsAccumulator_pullRequest", + "type": "PullRequest", + "metadata": { + "connection": [ + { + "count": "threadCount", + "cursor": "threadCursor", + "direction": "forward", + "path": [ + "reviewThreads" + ] + } + ] + }, + "argumentDefinitions": [ + { + "kind": "LocalArgument", + "name": "threadCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "threadCursor", + "type": "String", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "commentCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "commentCursor", + "type": "String", + "defaultValue": null + } + ], + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "url", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": "reviewThreads", + "name": "__ReviewThreadsAccumulator_reviewThreads_connection", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewThreadConnection", + "plural": false, + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "pageInfo", + "storageKey": null, + "args": null, + "concreteType": "PageInfo", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "hasNextPage", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "endCursor", + "args": null, + "storageKey": null + } + ] + }, + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewThreadEdge", + "plural": true, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "cursor", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "PullRequestReviewThread", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "isResolved", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "resolvedBy", + "storageKey": null, + "args": null, + "concreteType": "User", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null + } + ] + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanResolve", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanUnresolve", + "args": null, + "storageKey": null + }, + { + "kind": "FragmentSpread", + "name": "reviewCommentsAccumulator_reviewThread", + "args": [ + { + "kind": "Variable", + "name": "commentCount", + "variableName": "commentCount", + "type": null + }, + { + "kind": "Variable", + "name": "commentCursor", + "variableName": "commentCursor", + "type": null + } + ] + }, + { + "kind": "ScalarField", + "alias": null, + "name": "__typename", + "args": null, + "storageKey": null + } + ] + } + ] + } + ] + } + ] +}; +// prettier-ignore +(node/*: any*/).hash = '15785e7c291c2dc79dbf6e534bcb7e76'; +module.exports = node; diff --git a/lib/containers/accumulators/accumulator.js b/lib/containers/accumulators/accumulator.js new file mode 100644 index 0000000000..dfbbaffa59 --- /dev/null +++ b/lib/containers/accumulators/accumulator.js @@ -0,0 +1,80 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {Disposable} from 'event-kit'; + +export default class Accumulator extends React.Component { + static propTypes = { + // Relay props + relay: PropTypes.shape({ + hasMore: PropTypes.func.isRequired, + loadMore: PropTypes.func.isRequired, + isLoading: PropTypes.func.isRequired, + }).isRequired, + resultBatch: PropTypes.arrayOf(PropTypes.any).isRequired, + + // Control props + pageSize: PropTypes.number.isRequired, + waitTimeMs: PropTypes.number.isRequired, + + // Render prop. Called with (error, full result list, loading) each time more results arrive. Return value is + // rendered as a child element. + children: PropTypes.func, + + // Called right after refetch happens + onDidRefetch: PropTypes.func.isRequired, + } + + constructor(props) { + super(props); + + this.refetchSub = new Disposable(); + this.loadMoreSub = new Disposable(); + this.nextUpdateSub = new Disposable(); + + this.nextUpdateID = null; + this.state = {error: null}; + } + + componentDidMount() { + this.refetchSub = this.props.onDidRefetch(this.attemptToLoadMore); + this.attemptToLoadMore(); + } + + componentWillUnmount() { + this.refetchSub.dispose(); + this.loadMoreSub.dispose(); + this.nextUpdateSub.dispose(); + } + + render() { + return this.props.children(this.state.error, this.props.resultBatch, this.props.relay.hasMore()); + } + + attemptToLoadMore = () => { + this.loadMoreSub.dispose(); + this.nextUpdateID = null; + + /* istanbul ignore if */ + if (!this.props.relay.hasMore() || this.props.relay.isLoading()) { + return; + } + + this.loadMoreSub = this.props.relay.loadMore(this.props.pageSize, this.accumulate); + } + + accumulate = error => { + if (error) { + this.setState({error}); + } else { + if (this.props.waitTimeMs > 0 && this.nextUpdateID === null) { + this.nextUpdateID = setTimeout(this.attemptToLoadMore, this.props.waitTimeMs); + this.nextUpdateSub = new Disposable(() => { + clearTimeout(this.nextUpdateID); + this.nextUpdateID = null; + }); + } else { + this.attemptToLoadMore(); + } + } + } +} diff --git a/lib/containers/accumulators/review-comments-accumulator.js b/lib/containers/accumulators/review-comments-accumulator.js new file mode 100644 index 0000000000..4eccf8e223 --- /dev/null +++ b/lib/containers/accumulators/review-comments-accumulator.js @@ -0,0 +1,119 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {graphql, createPaginationContainer} from 'react-relay'; + +import {PAGE_SIZE, PAGINATION_WAIT_TIME_MS} from '../../helpers'; +import {RelayConnectionPropType} from '../../prop-types'; +import Accumulator from './accumulator'; + +export class BareReviewCommentsAccumulator extends React.Component { + static propTypes = { + // Relay props + relay: PropTypes.shape({ + hasMore: PropTypes.func.isRequired, + loadMore: PropTypes.func.isRequired, + isLoading: PropTypes.func.isRequired, + }).isRequired, + reviewThread: PropTypes.shape({ + comments: RelayConnectionPropType( + PropTypes.object, + ), + }), + + // Render prop. Called with (error or null, array of all review comments, loading) + children: PropTypes.func, + + // Called right after refetch happens + onDidRefetch: PropTypes.func.isRequired, + } + + render() { + const resultBatch = this.props.reviewThread.comments.edges.map(edge => edge.node); + + return ( + + {(error, comments, loading) => this.props.children({error, comments, loading})} + + ); + } +} + +export default createPaginationContainer(BareReviewCommentsAccumulator, { + reviewThread: graphql` + fragment reviewCommentsAccumulator_reviewThread on PullRequestReviewThread + @argumentDefinitions( + commentCount: {type: "Int!"} + commentCursor: {type: "String"}, + ) { + id + comments( + first: $commentCount + after: $commentCursor + ) @connection(key: "ReviewCommentsAccumulator_comments") { + pageInfo { + hasNextPage + endCursor + } + + edges { + cursor + node { + id + author { + avatarUrl + login + } + bodyHTML + isMinimized + state + viewerCanReact + path + position + createdAt + url + ...emojiReactionsController_reactable + } + } + } + } + `, +}, { + direction: 'forward', + /* istanbul ignore next */ + getConnectionFromProps(props) { + return props.reviewThread.comments; + }, + /* istanbul ignore next */ + getFragmentVariables(prevVars, totalCount) { + return {...prevVars, totalCount}; + }, + /* istanbul ignore next */ + getVariables(props, {count, cursor}) { + return { + id: props.reviewThread.id, + commentCount: count, + commentCursor: cursor, + }; + }, + query: graphql` + query reviewCommentsAccumulatorQuery( + $id: ID! + $commentCount: Int! + $commentCursor: String + ) { + node(id: $id) { + ... on PullRequestReviewThread { + ...reviewCommentsAccumulator_reviewThread @arguments( + commentCount: $commentCount + commentCursor: $commentCursor + ) + } + } + } + `, +}); diff --git a/lib/containers/accumulators/review-summaries-accumulator.js b/lib/containers/accumulators/review-summaries-accumulator.js new file mode 100644 index 0000000000..248c3ff6fe --- /dev/null +++ b/lib/containers/accumulators/review-summaries-accumulator.js @@ -0,0 +1,120 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import {graphql, createPaginationContainer} from 'react-relay'; + +import {PAGE_SIZE, PAGINATION_WAIT_TIME_MS} from '../../helpers'; +import {RelayConnectionPropType} from '../../prop-types'; +import Accumulator from './accumulator'; + +export class BareReviewSummariesAccumulator extends React.Component { + static propTypes = { + // Relay props + relay: PropTypes.shape({ + hasMore: PropTypes.func.isRequired, + loadMore: PropTypes.func.isRequired, + isLoading: PropTypes.func.isRequired, + }).isRequired, + pullRequest: PropTypes.shape({ + reviews: RelayConnectionPropType( + PropTypes.object, + ), + }), + + // Render prop. Called with {error: error or null, summaries: array of all reviews, loading} + children: PropTypes.func.isRequired, + + // Called right after refetch happens + onDidRefetch: PropTypes.func.isRequired, + } + + render() { + const resultBatch = this.props.pullRequest.reviews.edges.map(edge => edge.node); + + return ( + + {(error, results, loading) => { + const summaries = results.sort((a, b) => + moment(a.submittedAt, moment.ISO_8601) - moment(b.submittedAt, moment.ISO_8601), + ); + return this.props.children({error, summaries, loading}); + }} + + ); + } +} + +export default createPaginationContainer(BareReviewSummariesAccumulator, { + pullRequest: graphql` + fragment reviewSummariesAccumulator_pullRequest on PullRequest + @argumentDefinitions( + reviewCount: {type: "Int!"} + reviewCursor: {type: "String"}, + ) { + url + reviews( + first: $reviewCount + after: $reviewCursor + ) @connection(key: "ReviewSummariesAccumulator_reviews") { + pageInfo { + hasNextPage + endCursor + } + + edges { + cursor + node { + id + bodyHTML + state + submittedAt + author { + login + avatarUrl + } + ...emojiReactionsController_reactable + } + } + } + } + `, +}, { + direction: 'forward', + /* istanbul ignore next */ + getConnectionFromProps(props) { + return props.pullRequest.reviews; + }, + /* istanbul ignore next */ + getFragmentVariables(prevVars, totalCount) { + return {...prevVars, totalCount}; + }, + /* istanbul ignore next */ + getVariables(props, {count, cursor}) { + return { + url: props.pullRequest.url, + reviewCount: count, + reviewCursor: cursor, + }; + }, + query: graphql` + query reviewSummariesAccumulatorQuery( + $url: URI! + $reviewCount: Int! + $reviewCursor: String + ) { + resource(url: $url) { + ... on PullRequest { + ...reviewSummariesAccumulator_pullRequest @arguments( + reviewCount: $reviewCount + reviewCursor: $reviewCursor + ) + } + } + } + `, +}); diff --git a/lib/containers/accumulators/review-threads-accumulator.js b/lib/containers/accumulators/review-threads-accumulator.js new file mode 100644 index 0000000000..a2db4102cd --- /dev/null +++ b/lib/containers/accumulators/review-threads-accumulator.js @@ -0,0 +1,164 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {graphql, createPaginationContainer} from 'react-relay'; + +import {PAGE_SIZE, PAGINATION_WAIT_TIME_MS} from '../../helpers'; +import {RelayConnectionPropType} from '../../prop-types'; +import Accumulator from './accumulator'; +import ReviewCommentsAccumulator from './review-comments-accumulator'; + +export class BareReviewThreadsAccumulator extends React.Component { + static propTypes = { + // Relay props + relay: PropTypes.shape({ + hasMore: PropTypes.func.isRequired, + loadMore: PropTypes.func.isRequired, + isLoading: PropTypes.func.isRequired, + }).isRequired, + pullRequest: PropTypes.shape({ + reviewThreads: RelayConnectionPropType( + PropTypes.object, + ), + }), + + // Render prop. Called with (array of errors, array of threads, map of comments per thread, loading) + children: PropTypes.func.isRequired, + + // Called right after refetch happens + onDidRefetch: PropTypes.func.isRequired, + } + + render() { + const resultBatch = this.props.pullRequest.reviewThreads.edges.map(edge => edge.node); + return ( + + {this.renderReviewThreads} + + ); + } + + renderReviewThreads = (err, threads, loading) => { + if (err) { + return this.props.children({ + errors: [err], + commentThreads: [], + loading, + }); + } + + return this.renderReviewThread({errors: [], commentsByThread: new Map(), loading}, threads); + } + + renderReviewThread = (payload, threads) => { + if (threads.length === 0) { + const commentThreads = []; + payload.commentsByThread.forEach((comments, thread) => { + commentThreads.push({thread, comments}); + }); + return this.props.children({ + commentThreads, + errors: payload.errors, + loading: payload.loading, + }); + } + + const [thread] = threads; + return ( + + {({error, comments, loading: threadLoading}) => { + if (error) { + payload.errors.push(error); + } + payload.commentsByThread.set(thread, comments); + payload.loading = payload.loading || threadLoading; + return this.renderReviewThread(payload, threads.slice(1)); + }} + + ); + } +} + +export default createPaginationContainer(BareReviewThreadsAccumulator, { + pullRequest: graphql` + fragment reviewThreadsAccumulator_pullRequest on PullRequest + @argumentDefinitions( + threadCount: {type: "Int!"} + threadCursor: {type: "String"} + commentCount: {type: "Int!"} + commentCursor: {type: "String"} + ) { + url + reviewThreads( + first: $threadCount + after: $threadCursor + ) @connection(key: "ReviewThreadsAccumulator_reviewThreads") { + pageInfo { + hasNextPage + endCursor + } + + edges { + cursor + node { + id + isResolved + resolvedBy { + login + } + viewerCanResolve + viewerCanUnresolve + + ...reviewCommentsAccumulator_reviewThread @arguments( + commentCount: $commentCount + commentCursor: $commentCursor + ) + } + } + } + } + `, +}, { + direction: 'forward', + /* istanbul ignore next */ + getConnectionFromProps(props) { + return props.pullRequest.reviewThreads; + }, + /* istanbul ignore next */ + getFragmentVariables(prevVars, totalCount) { + return {...prevVars, totalCount}; + }, + /* istanbul ignore next */ + getVariables(props, {count, cursor}, fragmentVariables) { + return { + url: props.pullRequest.url, + threadCount: count, + threadCursor: cursor, + commentCount: fragmentVariables.commentCount, + }; + }, + query: graphql` + query reviewThreadsAccumulatorQuery( + $url: URI! + $threadCount: Int! + $threadCursor: String + $commentCount: Int! + ) { + resource(url: $url) { + ... on PullRequest { + ...reviewThreadsAccumulator_pullRequest @arguments( + threadCount: $threadCount + threadCursor: $threadCursor + commentCount: $commentCount + ) + } + } + } + `, +}); diff --git a/lib/containers/aggregated-reviews-container.js b/lib/containers/aggregated-reviews-container.js new file mode 100644 index 0000000000..975e69c5d6 --- /dev/null +++ b/lib/containers/aggregated-reviews-container.js @@ -0,0 +1,136 @@ +import React from 'react'; +import {Emitter} from 'event-kit'; +import {graphql, createRefetchContainer} from 'react-relay'; +import PropTypes from 'prop-types'; + +import {PAGE_SIZE} from '../helpers'; +import ReviewSummariesAccumulator from './accumulators/review-summaries-accumulator'; +import ReviewThreadsAccumulator from './accumulators/review-threads-accumulator'; + +export class BareAggregatedReviewsContainer extends React.Component { + static propTypes = { + // Relay response + relay: PropTypes.shape({ + refetch: PropTypes.func.isRequired, + }), + + // Relay results. + pullRequest: PropTypes.shape({ + id: PropTypes.string.isRequired, + }).isRequired, + + // Render prop. Called with {errors, summaries, commentThreads, loading}. + children: PropTypes.func.isRequired, + + // only fetch summaries when we specify a summariesRenderer + summariesRenderer: PropTypes.func, + } + + constructor(props) { + super(props); + this.emitter = new Emitter(); + } + + render() { + return ( + + {({error: summaryError, summaries, loading: summariesLoading}) => { + return ( + + {payload => { + const result = { + errors: [], + refetch: this.refetch, + summaries, + commentThreads: payload.commentThreads, + loading: payload.loading || summariesLoading, + }; + + if (summaryError) { + result.errors.push(summaryError); + } + result.errors.push(...payload.errors); + + return this.props.children(result); + }} + + ); + }} + + ); + } + + + refetch = callback => this.props.relay.refetch( + { + prId: this.props.pullRequest.id, + reviewCount: PAGE_SIZE, + reviewCursor: null, + threadCount: PAGE_SIZE, + threadCursor: null, + commentCount: PAGE_SIZE, + commentCursor: null, + }, + null, + () => { + this.emitter.emit('did-refetch'); + callback(); + }, + {force: true}, + ); + + onDidRefetch = callback => this.emitter.on('did-refetch', callback); +} + +export default createRefetchContainer(BareAggregatedReviewsContainer, { + pullRequest: graphql` + fragment aggregatedReviewsContainer_pullRequest on PullRequest + @argumentDefinitions( + reviewCount: {type: "Int!"} + reviewCursor: {type: "String"} + threadCount: {type: "Int!"} + threadCursor: {type: "String"} + commentCount: {type: "Int!"} + commentCursor: {type: "String"} + ) { + id + ...reviewSummariesAccumulator_pullRequest @arguments( + reviewCount: $reviewCount + reviewCursor: $reviewCursor + ) + ...reviewThreadsAccumulator_pullRequest @arguments( + threadCount: $threadCount + threadCursor: $threadCursor + commentCount: $commentCount + commentCursor: $commentCursor + ) + } + `, +}, graphql` + query aggregatedReviewsContainerRefetchQuery + ( + $prId: ID! + $reviewCount: Int! + $reviewCursor: String + $threadCount: Int! + $threadCursor: String + $commentCount: Int! + $commentCursor: String + ) { + pullRequest: node(id: $prId) { + ...prCheckoutController_pullRequest + ...aggregatedReviewsContainer_pullRequest @arguments( + reviewCount: $reviewCount + reviewCursor: $reviewCursor + threadCount: $threadCount + threadCursor: $threadCursor + commentCount: $commentCount + commentCursor: $commentCursor + ) + } + } +`); diff --git a/lib/containers/comment-decorations-container.js b/lib/containers/comment-decorations-container.js new file mode 100644 index 0000000000..0e9fb37ed9 --- /dev/null +++ b/lib/containers/comment-decorations-container.js @@ -0,0 +1,249 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import yubikiri from 'yubikiri'; +import {QueryRenderer, graphql} from 'react-relay'; + +import CommentDecorationsController from '../controllers/comment-decorations-controller'; +import ObserveModel from '../views/observe-model'; +import RelayEnvironment from '../views/relay-environment'; +import {GithubLoginModelPropType} from '../prop-types'; +import {UNAUTHENTICATED, INSUFFICIENT} from '../shared/keytar-strategy'; +import RelayNetworkLayerManager from '../relay-network-layer-manager'; +import {PAGE_SIZE} from '../helpers'; +import AggregatedReviewsContainer from './aggregated-reviews-container'; +import CommentPositioningContainer from './comment-positioning-container'; +import PullRequestPatchContainer from './pr-patch-container'; + +export default class CommentDecorationsContainer extends React.Component { + static propTypes = { + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + localRepository: PropTypes.object.isRequired, + loginModel: GithubLoginModelPropType.isRequired, + }; + + render() { + return ( + + {this.renderWithLocalRepositoryData} + + ); + } + + renderWithLocalRepositoryData = repoData => { + if (!repoData) { + return null; + } + + return ( + + {token => this.renderWithToken(token, {repoData})} + + ); + } + + renderWithToken(token, {repoData}) { + if (!token || token === UNAUTHENTICATED || token === INSUFFICIENT) { + // we're not going to prompt users to log in to render decorations for comments + // just let it go and move on with our lives. + return null; + } + + const head = repoData.branches.getHeadBranch(); + if (!head.isPresent()) { + return null; + } + + const push = head.getPush(); + if (!push.isPresent() || !push.isRemoteTracking()) { + return null; + } + + const pushRemote = repoData.remotes.withName(push.getRemoteName()); + if (!pushRemote.isPresent() || !pushRemote.isGithubRepo()) { + return null; + } + + const endpoint = repoData.currentRemote.getEndpoint(); + const environment = RelayNetworkLayerManager.getEnvironmentForHost(endpoint, token); + const query = graphql` + query commentDecorationsContainerQuery( + $headOwner: String! + $headName: String! + $headRef: String! + $reviewCount: Int! + $reviewCursor: String + $threadCount: Int! + $threadCursor: String + $commentCount: Int! + $commentCursor: String + $first: Int! + ) { + repository(owner: $headOwner, name: $headName) { + ref(qualifiedName: $headRef) { + associatedPullRequests(first: $first, states: [OPEN]) { + totalCount + nodes { + number + headRefOid + + ...commentDecorationsController_pullRequests + ...aggregatedReviewsContainer_pullRequest @arguments( + reviewCount: $reviewCount + reviewCursor: $reviewCursor + threadCount: $threadCount + threadCursor: $threadCursor + commentCount: $commentCount + commentCursor: $commentCursor + ) + } + } + } + } + } + `; + const variables = { + headOwner: pushRemote.getOwner(), + headName: pushRemote.getRepo(), + headRef: push.getRemoteRef(), + first: 1, + reviewCount: PAGE_SIZE, + reviewCursor: null, + threadCount: PAGE_SIZE, + threadCursor: null, + commentCount: PAGE_SIZE, + commentCursor: null, + }; + + return ( + + this.renderWithGraphQLData({ + endpoint, + owner: variables.headOwner, + repo: variables.headName, + ...queryResult, + }, {repoData, token})} + /> + + ); + } + + renderWithGraphQLData({error, props, endpoint, owner, repo}, {repoData, token}) { + if (error) { + // eslint-disable-next-line no-console + console.warn(`error fetching CommentDecorationsContainer data: ${error}`); + return null; + } + + if ( + !props || !props.repository || !props.repository.ref || + props.repository.ref.associatedPullRequests.totalCount === 0 + ) { + // no loading spinner for you + // just fetch silently behind the scenes like a good little container + return null; + } + + const currentPullRequest = props.repository.ref.associatedPullRequests.nodes[0]; + const queryProps = {currentPullRequest, ...props}; + + return ( + + {(patchError, patch) => this.renderWithPatch( + {error: patchError, patch}, + {queryProps, endpoint, owner, repo, repoData}, + )} + + ); + } + + renderWithPatch({error, patch}, {queryProps, endpoint, owner, repo, repoData}) { + if (error) { + // eslint-disable-next-line no-console + console.warn('Error fetching patch for current pull request', error); + return null; + } + + return ( + + {({errors, summaries, commentThreads}) => { + if (errors && errors.length > 0) { + // eslint-disable-next-line no-console + console.warn('Errors aggregating reviews and comments for current pull request', ...errors); + return null; + } + + const aggregationResult = {summaries, commentThreads}; + return this.renderWithResult(aggregationResult, { + queryProps, endpoint, owner, repo, repoData, patch, + }); + }} + + ); + } + + renderWithResult(aggregationResult, {queryProps, endpoint, owner, repo, repoData, patch}) { + if (!patch) { + return null; + } + + return ( + + {commentTranslations => { + if (!commentTranslations) { + return null; + } + + return ( + + ); + }} + + ); + } + + fetchRepositoryData = repository => { + return yubikiri({ + branches: repository.getBranches(), + remotes: repository.getRemotes(), + currentRemote: repository.getCurrentGitHubRemote(), + workingDirectoryPath: repository.getWorkingDirectoryPath(), + }); + } + + fetchToken = (loginModel, repoData) => { + const endpoint = repoData.currentRemote.getEndpoint(); + if (!endpoint) { + return null; + } + + return loginModel.getToken(endpoint.getLoginAccount()); + } +} diff --git a/lib/containers/comment-positioning-container.js b/lib/containers/comment-positioning-container.js new file mode 100644 index 0000000000..20c4ed866a --- /dev/null +++ b/lib/containers/comment-positioning-container.js @@ -0,0 +1,181 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import crypto from 'crypto'; +import {CompositeDisposable} from 'event-kit'; +import yubikiri from 'yubikiri'; +import {translateLinesGivenDiff, diffPositionToFilePosition} from 'whats-my-line'; + +import File from '../models/patch/file'; +import ObserveModel from '../views/observe-model'; +import {toNativePathSep} from '../helpers'; + +export default class CommentPositioningContainer extends React.Component { + static propTypes = { + localRepository: PropTypes.object.isRequired, + multiFilePatch: PropTypes.object.isRequired, + commentThreads: PropTypes.arrayOf(PropTypes.shape({ + comments: PropTypes.arrayOf(PropTypes.shape({ + position: PropTypes.number, + path: PropTypes.string.isRequired, + })).isRequired, + })), + prCommitSha: PropTypes.string.isRequired, + children: PropTypes.func.isRequired, + + // For unit test injection + translateLinesGivenDiff: PropTypes.func, + diffPositionToFilePosition: PropTypes.func, + } + + static defaultProps = { + translateLinesGivenDiff, + diffPositionToFilePosition, + didTranslate: /* istanbul ignore next */ () => {}, + } + + constructor(props) { + super(props); + + this.state = {translationsByFile: new Map()}; + this.subs = new CompositeDisposable(); + } + + static getDerivedStateFromProps(props, state) { + const prevPaths = new Set(state.translationsByFile.keys()); + let changed = false; + + for (const thread of props.commentThreads) { + const relPath = thread.comments[0].path; + const commentPath = toNativePathSep(relPath); + + let existing = state.translationsByFile.get(commentPath); + if (!existing) { + existing = new FileTranslation(relPath); + state.translationsByFile.set(commentPath, existing); + changed = true; + } + existing.addCommentThread(thread); + + prevPaths.delete(commentPath); + } + + for (const oldPath of prevPaths) { + state.translationsByFile.deleted(oldPath); + changed = true; + } + + if (changed) { + return {translationsByFile: state.translationsByFile}; + } else { + return null; + } + } + + componentWillUnmount() { + this.subs.dispose(); + } + + render() { + const commentPaths = [...this.state.translationsByFile.keys()]; + + return ( + + + {diffsByPath => { + if (diffsByPath === null) { + return this.props.children(null); + } + + for (const commentPath of commentPaths) { + this.state.translationsByFile.get(commentPath).updateIfNecessary({ + multiFilePatch: this.props.multiFilePatch, + diffs: diffsByPath[commentPath] || [], + diffPositionFn: this.props.diffPositionToFilePosition, + translatePositionFn: this.props.translateLinesGivenDiff, + }); + } + + return this.props.children(this.state.translationsByFile); + }} + + + ); + } + + fetchData = (localRepository, commentPaths, prCommitSha) => { + const promises = {}; + for (const commentPath of commentPaths) { + promises[commentPath] = localRepository.getDiffsForFilePath(commentPath, prCommitSha).catch(() => []); + } + return yubikiri(promises); + } +} + +class FileTranslation { + constructor(relPath) { + this.relPath = relPath; + this.nativeRelPath = toNativePathSep(relPath); + + this.rawPositions = new Set(); + this.diffToFilePosition = new Map(); + this.fileTranslations = null; + this.digest = null; + + this.last = {multiFilePatch: null, diffs: null}; + } + + addCommentThread(thread) { + this.rawPositions.add(thread.comments[0].position); + } + + updateIfNecessary({multiFilePatch, diffs, diffPositionFn, translatePositionFn}) { + if ( + this.last.multiFilePatch === multiFilePatch && + this.last.diffs === diffs + ) { + return false; + } + + this.last.multiFilePatch = multiFilePatch; + this.last.diffs = diffs; + + return this.update({multiFilePatch, diffs, diffPositionFn, translatePositionFn}); + } + + update({multiFilePatch, diffs, diffPositionFn, translatePositionFn}) { + const filePatch = multiFilePatch.getPatchForPath(this.nativeRelPath); + // if there's a comment on a file that used to exist in a pr but does not exist, + // the filePatch won't exist. Don't even try. + if (!filePatch) { + return; + } + this.diffToFilePosition = diffPositionFn(this.rawPositions, filePatch.getRawContentPatch()); + + let contentChangeDiff; + if (diffs.length === 1) { + contentChangeDiff = diffs[0]; + } else if (diffs.length === 2) { + const [diff1, diff2] = diffs; + if (diff1.oldMode === File.modes.SYMLINK || diff1.newMode === File.modes.SYMLINK) { + contentChangeDiff = diff2; + } else { + contentChangeDiff = diff1; + } + } + + if (contentChangeDiff) { + const filePositions = [...this.diffToFilePosition.values()]; + this.fileTranslations = translatePositionFn(filePositions, contentChangeDiff); + + const hash = crypto.createHash('sha256'); + hash.update(JSON.stringify(Array.from(this.fileTranslations.entries()))); + this.digest = hash.digest('hex'); + } else { + this.fileTranslations = null; + this.digest = null; + } + } +} diff --git a/lib/containers/current-pull-request-container.js b/lib/containers/current-pull-request-container.js index 9930f52180..4878222f79 100644 --- a/lib/containers/current-pull-request-container.js +++ b/lib/containers/current-pull-request-container.js @@ -37,6 +37,9 @@ export default class CurrentPullRequestContainer extends React.Component { aheadCount: PropTypes.number, pushInProgress: PropTypes.bool.isRequired, + workspace: PropTypes.object.isRequired, + workingDirectory: PropTypes.string.isRequired, + // Actions onOpenIssueish: PropTypes.func.isRequired, onCreatePr: PropTypes.func.isRequired, @@ -143,6 +146,9 @@ export default class CurrentPullRequestContainer extends React.Component { total={associatedPullRequests.totalCount} results={associatedPullRequests.nodes} isLoading={false} + workspace={this.props.workspace} + workingDirectory={this.props.workingDirectory} + endpoint={this.props.endpoint} resultFilter={issueish => issueish.getHeadRepositoryID() === this.props.repository.id} {...this.controllerProps()} /> @@ -171,6 +177,8 @@ export default class CurrentPullRequestContainer extends React.Component { title: 'Checked out pull request', onOpenIssueish: this.props.onOpenIssueish, emptyComponent: this.renderEmptyTile, + needReviewsButton: true, + workingDirectory: this.props.workingDirectory, }; } } diff --git a/lib/containers/github-tab-container.js b/lib/containers/github-tab-container.js index 75ddad0b14..c68b3324cd 100644 --- a/lib/containers/github-tab-container.js +++ b/lib/containers/github-tab-container.js @@ -66,7 +66,6 @@ export default class GitHubTabContainer extends React.Component { {...this.props} remoteOperationObserver={this.state.remoteOperationObserver} - workingDirectory="" allRemotes={new RemoteSet()} branches={new BranchSet()} aheadCount={0} diff --git a/lib/containers/issueish-detail-container.js b/lib/containers/issueish-detail-container.js index d387abf10f..03290bc2cb 100644 --- a/lib/containers/issueish-detail-container.js +++ b/lib/containers/issueish-detail-container.js @@ -3,15 +3,17 @@ import PropTypes from 'prop-types'; import yubikiri from 'yubikiri'; import {QueryRenderer, graphql} from 'react-relay'; -import {autobind, PAGE_SIZE} from '../helpers'; +import {PAGE_SIZE} from '../helpers'; import RelayNetworkLayerManager from '../relay-network-layer-manager'; import {GithubLoginModelPropType, ItemTypePropType, EndpointPropType, RefHolderPropType} from '../prop-types'; import {UNAUTHENTICATED, INSUFFICIENT} from '../shared/keytar-strategy'; import GithubLoginView from '../views/github-login-view'; import LoadingView from '../views/loading-view'; import QueryErrorView from '../views/query-error-view'; +import ErrorView from '../views/error-view'; import ObserveModel from '../views/observe-model'; import RelayEnvironment from '../views/relay-environment'; +import AggregatedReviewsContainer from './aggregated-reviews-container'; import IssueishDetailController from '../controllers/issueish-detail-controller'; export default class IssueishDetailContainer extends React.Component { @@ -24,6 +26,13 @@ export default class IssueishDetailContainer extends React.Component { repo: PropTypes.string.isRequired, issueishNumber: PropTypes.number.isRequired, + // For opening files changed tab + initChangedFilePath: PropTypes.string, + initChangedFilePosition: PropTypes.number, + selectedTab: PropTypes.number.isRequired, + onTabSelected: PropTypes.func.isRequired, + onOpenFilesTab: PropTypes.func.isRequired, + // Package models repository: PropTypes.object.isRequired, loginModel: GithubLoginModelPropType.isRequired, @@ -39,28 +48,13 @@ export default class IssueishDetailContainer extends React.Component { switchToIssueish: PropTypes.func.isRequired, onTitleChange: PropTypes.func.isRequired, destroy: PropTypes.func.isRequired, + reportMutationErrors: PropTypes.func.isRequired, // Item context itemType: ItemTypePropType.isRequired, refEditor: RefHolderPropType.isRequired, } - constructor(props) { - super(props); - autobind(this, - 'fetchToken', 'renderWithToken', - 'fetchRepositoryData', 'renderWithRepositoryData', - 'renderWithResult', - 'handleLogin', 'handleLogout', - ); - } - - fetchToken(loginModel) { - return yubikiri({ - token: loginModel.getToken(this.props.endpoint.getLoginAccount()), - }); - } - render() { return ( @@ -69,28 +63,14 @@ export default class IssueishDetailContainer extends React.Component { ); } - fetchRepositoryData(repository) { - return yubikiri({ - branches: repository.getBranches(), - remotes: repository.getRemotes(), - isMerging: repository.isMerging(), - isRebasing: repository.isRebasing(), - isAbsent: repository.isAbsent(), - isLoading: repository.isLoading(), - isPresent: repository.isPresent(), - }); - } - - renderWithToken(tokenData) { - if (!tokenData) { - return ; - } + renderWithToken = tokenData => { + const token = tokenData && tokenData.token; - if (tokenData.token === UNAUTHENTICATED) { + if (token === UNAUTHENTICATED) { return ; } - if (tokenData.token === INSUFFICIENT) { + if (token === INSUFFICIENT) { return (

@@ -102,13 +82,13 @@ export default class IssueishDetailContainer extends React.Component { return ( - {repoData => this.renderWithRepositoryData(repoData, tokenData.token)} + {repoData => this.renderWithRepositoryData(token, repoData)} ); } - renderWithRepositoryData(repoData, token) { - if (!repoData) { + renderWithRepositoryData(token, repoData) { + if (!token) { return ; } @@ -122,23 +102,35 @@ export default class IssueishDetailContainer extends React.Component { $timelineCount: Int! $timelineCursor: String $commitCount: Int! - $commitCursor: String, - $reviewCount: Int!, - $reviewCursor: String, - $commentCount: Int!, - $commentCursor: String, + $commitCursor: String + $reviewCount: Int! + $reviewCursor: String + $threadCount: Int! + $threadCursor: String + $commentCount: Int! + $commentCursor: String ) { repository(owner: $repoOwner, name: $repoName) { + issueish: issueOrPullRequest(number: $issueishNumber) { + __typename + ... on PullRequest { + ...aggregatedReviewsContainer_pullRequest @arguments( + reviewCount: $reviewCount + reviewCursor: $reviewCursor + threadCount: $threadCount + threadCursor: $threadCursor + commentCount: $commentCount + commentCursor: $commentCursor + ) + } + } + ...issueishDetailController_repository @arguments( - issueishNumber: $issueishNumber, - timelineCount: $timelineCount, - timelineCursor: $timelineCursor, - commitCount: $commitCount, - commitCursor: $commitCursor, - reviewCount: $reviewCount, - reviewCursor: $reviewCursor, - commentCount: $commentCount, - commentCursor: $commentCursor, + issueishNumber: $issueishNumber + timelineCount: $timelineCount + timelineCursor: $timelineCursor + commitCount: $commitCount + commitCursor: $commitCursor ) } } @@ -153,6 +145,8 @@ export default class IssueishDetailContainer extends React.Component { commitCursor: null, reviewCount: PAGE_SIZE, reviewCursor: null, + threadCount: PAGE_SIZE, + threadCursor: null, commentCount: PAGE_SIZE, commentCursor: null, }; @@ -163,13 +157,13 @@ export default class IssueishDetailContainer extends React.Component { environment={environment} query={query} variables={variables} - render={queryResult => this.renderWithResult(queryResult, repoData, token)} + render={queryResult => this.renderWithQueryResult(token, repoData, queryResult)} /> ); } - renderWithResult({error, props, retry}, repoData, token) { + renderWithQueryResult(token, repoData, {error, props, retry}) { if (error) { return ( ; } - const {repository} = this.props; + if (props.repository.issueish.__typename === 'PullRequest') { + return ( + + {aggregatedReviews => this.renderWithCommentResult(token, repoData, {props, retry}, aggregatedReviews)} + + ); + } else { + return this.renderWithCommentResult( + token, + repoData, + {props, retry}, + {errors: [], commentThreads: [], loading: false}, + ); + } + } + + renderWithCommentResult(token, repoData, {props, retry}, {errors, commentThreads, loading}) { + const nonEmptyThreads = commentThreads.filter(each => each.comments && each.comments.length > 0); + const totalCount = nonEmptyThreads.length; + const resolvedCount = nonEmptyThreads.filter(each => each.thread.isResolved).length; + + if (errors && errors.length > 0) { + const descriptions = errors.map(error => error.toString()); + + return ( + + ); + } return ( { + return yubikiri({ + token: loginModel.getToken(this.props.endpoint.getLoginAccount()), + }); } - handleLogout() { - return this.props.loginModel.removeToken(this.props.endpoint.getLoginAccount()); + fetchRepositoryData = repository => { + return yubikiri({ + branches: repository.getBranches(), + remotes: repository.getRemotes(), + isMerging: repository.isMerging(), + isRebasing: repository.isRebasing(), + isAbsent: repository.isAbsent(), + isLoading: repository.isLoading(), + isPresent: repository.isPresent(), + }); } + + handleLogin = token => this.props.loginModel.setToken(this.props.endpoint.getLoginAccount(), token); + + handleLogout = () => this.props.loginModel.removeToken(this.props.endpoint.getLoginAccount()); } diff --git a/lib/containers/pr-changed-files-container.js b/lib/containers/pr-changed-files-container.js index 4c6ab46923..2f6888d658 100644 --- a/lib/containers/pr-changed-files-container.js +++ b/lib/containers/pr-changed-files-container.js @@ -1,14 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {parse as parseDiff} from 'what-the-diff'; import {CompositeDisposable} from 'event-kit'; import {ItemTypePropType, EndpointPropType} from '../prop-types'; -import {toNativePathSep} from '../helpers'; +import PullRequestPatchContainer from './pr-patch-container'; import MultiFilePatchController from '../controllers/multi-file-patch-controller'; import LoadingView from '../views/loading-view'; import ErrorView from '../views/error-view'; -import {buildMultiFilePatch} from '../models/patch'; export default class PullRequestChangedFilesContainer extends React.Component { static propTypes = { @@ -36,105 +34,80 @@ export default class PullRequestChangedFilesContainer extends React.Component { // local repo as opposed to pull request repo localRepository: PropTypes.object.isRequired, + workdirPath: PropTypes.string, + + // Review comment threads + reviewCommentsLoading: PropTypes.bool.isRequired, + reviewCommentThreads: PropTypes.arrayOf(PropTypes.shape({ + thread: PropTypes.object.isRequired, + comments: PropTypes.arrayOf(PropTypes.object).isRequired, + })).isRequired, // refetch diff on refresh shouldRefetch: PropTypes.bool.isRequired, + + // For opening files changed tab + initChangedFilePath: PropTypes.string, + initChangedFilePosition: PropTypes.number, + onOpenFilesTab: PropTypes.func.isRequired, } constructor(props) { super(props); - this.mfpSubs = new CompositeDisposable(); - - this.state = {isLoading: true, error: null}; - this.fetchDiff(); - } - - componentDidUpdate(prevProps) { - if (this.props.shouldRefetch && !prevProps.shouldRefetch) { - this.setState({isLoading: true, error: null}); - this.fetchDiff(); - } + this.lastPatch = { + patch: null, + subs: new CompositeDisposable(), + }; } componentWillUnmount() { - this.mfpSubs.dispose(); + this.lastPatch.subs.dispose(); } - // Generate a v3 GitHub API REST URL for the pull request resource. - // Example: https://api.github.com/repos/atom/github/pulls/1829 - getDiffURL() { - return this.props.endpoint.getRestURI('repos', this.props.owner, this.props.repo, 'pulls', this.props.number); - } + render() { + const patchProps = { + owner: this.props.owner, + repo: this.props.repo, + number: this.props.number, + endpoint: this.props.endpoint, + token: this.props.token, + refetch: this.props.shouldRefetch, + }; - buildPatch(rawDiff) { - const diffs = parseDiff(rawDiff).map(diff => { - // diff coming from API will have the defaul git diff prefixes a/ and b/ and use *nix-style / path separators. - // e.g. a/dir/file1.js and b/dir/file2.js - // see https://git-scm.com/docs/git-diff#_generating_patches_with_p - return { - ...diff, - newPath: diff.newPath ? toNativePathSep(diff.newPath.replace(/^[a|b]\//, '')) : diff.newPath, - oldPath: diff.oldPath ? toNativePathSep(diff.oldPath.replace(/^[a|b]\//, '')) : diff.oldPath, - }; - }); - return buildMultiFilePatch(diffs); + return ( + + {this.renderPatchResult} + + ); } - async fetchDiff() { - const diffError = (message, err = null) => new Promise(resolve => { - if (err) { - // eslint-disable-next-line no-console - console.error(err); - } - this.setState({isLoading: false, error: message}, resolve); - }); - const url = this.getDiffURL(); - - const response = await fetch(url, { - headers: { - Accept: 'application/vnd.github.v3.diff', - Authorization: `bearer ${this.props.token}`, - }, - }).catch(err => { - diffError(`Network error encountered at fetching ${url}`, err); - }); - if (this.state.error) { - return; - } - try { - if (response && response.ok) { - const rawDiff = await response.text(); - const multiFilePatch = this.buildPatch(rawDiff); - - this.mfpSubs.dispose(); - this.mfpSubs = new CompositeDisposable(); - for (const fp of multiFilePatch.getFilePatches()) { - this.mfpSubs.add(fp.onDidChangeRenderStatus(() => this.forceUpdate())); - } - - await new Promise(resolve => this.setState({isLoading: false, multiFilePatch}, resolve)); - } else { - diffError(`Unable to fetch diff for this pull request${response ? ': ' + response.statusText : ''}.`); - } - } catch (err) { - diffError('Unable to parse diff for this pull request.', err); + renderPatchResult = (error, multiFilePatch) => { + if (error === null && multiFilePatch === null) { + return ; } - } - render() { - if (this.state.isLoading) { - return ; + if (error !== null) { + return ; } - if (this.state.error) { - return ; + if (multiFilePatch !== this.lastPatch.patch) { + this.lastPatch.subs.dispose(); + + this.lastPatch = { + subs: new CompositeDisposable( + ...multiFilePatch.getFilePatches().map(fp => fp.onDidChangeRenderStatus(() => this.forceUpdate())), + ), + patch: multiFilePatch, + }; } return ( ); diff --git a/lib/containers/pr-patch-container.js b/lib/containers/pr-patch-container.js new file mode 100644 index 0000000000..67f3810382 --- /dev/null +++ b/lib/containers/pr-patch-container.js @@ -0,0 +1,119 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {parse as parseDiff} from 'what-the-diff'; + +import {toNativePathSep} from '../helpers'; +import {EndpointPropType} from '../prop-types'; +import {buildMultiFilePatch} from '../models/patch'; + +export default class PullRequestPatchContainer extends React.Component { + static propTypes = { + // Pull request properties + owner: PropTypes.string.isRequired, + repo: PropTypes.string.isRequired, + number: PropTypes.number.isRequired, + + // Connection properties + endpoint: EndpointPropType.isRequired, + token: PropTypes.string.isRequired, + + // Refetch diff on next component update + refetch: PropTypes.bool, + + // Render prop. Called with (error or null, multiFilePatch or null) + children: PropTypes.func.isRequired, + } + + state = { + multiFilePatch: null, + error: null, + } + + componentDidMount() { + this.mounted = true; + this.fetchDiff(); + } + + componentDidUpdate(prevProps) { + if (this.props.refetch && !prevProps.refetch) { + this.setState({multiFilePatch: null, error: null}); + this.fetchDiff(); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + render() { + return this.props.children(this.state.error, this.state.multiFilePatch); + } + + // Generate a v3 GitHub API REST URL for the pull request resource. + // Example: https://api.github.com/repos/atom/github/pulls/1829 + getDiffURL() { + return this.props.endpoint.getRestURI('repos', this.props.owner, this.props.repo, 'pulls', this.props.number); + } + + buildPatch(rawDiff) { + const diffs = parseDiff(rawDiff).map(diff => { + // diff coming from API will have the defaul git diff prefixes a/ and b/ and use *nix-style / path separators. + // e.g. a/dir/file1.js and b/dir/file2.js + // see https://git-scm.com/docs/git-diff#_generating_patches_with_p + return { + ...diff, + newPath: diff.newPath ? toNativePathSep(diff.newPath.replace(/^[a|b]\//, '')) : diff.newPath, + oldPath: diff.oldPath ? toNativePathSep(diff.oldPath.replace(/^[a|b]\//, '')) : diff.oldPath, + }; + }); + return buildMultiFilePatch(diffs, {preserveOriginal: true}); + } + + async fetchDiff() { + const url = this.getDiffURL(); + let response; + + try { + response = await fetch(url, { + headers: { + Accept: 'application/vnd.github.v3.diff', + Authorization: `bearer ${this.props.token}`, + }, + }); + } catch (err) { + return this.reportDiffError(`Network error encountered fetching the patch: ${err.message}.`, err); + } + + if (!response.ok) { + return this.reportDiffError(`Unable to fetch the diff for this pull request: ${response.statusText}.`); + } + + try { + const rawDiff = await response.text(); + if (!this.mounted) { + return null; + } + + const multiFilePatch = this.buildPatch(rawDiff); + return new Promise(resolve => this.setState({multiFilePatch}, resolve)); + } catch (err) { + return this.reportDiffError('Unable to parse the diff for this pull request.', err); + } + } + + reportDiffError(message, error) { + return new Promise(resolve => { + if (error) { + // eslint-disable-next-line no-console + console.error(error); + } + + if (!this.mounted) { + resolve(); + return; + } + + this.setState({error: message}, resolve); + }); + } +} diff --git a/lib/containers/pr-review-comments-container.js b/lib/containers/pr-review-comments-container.js deleted file mode 100644 index 16051026bd..0000000000 --- a/lib/containers/pr-review-comments-container.js +++ /dev/null @@ -1,140 +0,0 @@ -import {graphql, createPaginationContainer} from 'react-relay'; -import PropTypes from 'prop-types'; -import React from 'react'; - -import {PAGE_SIZE, PAGINATION_WAIT_TIME_MS} from '../helpers'; - -export class BarePullRequestReviewCommentsContainer extends React.Component { - - static propTypes = { - collectComments: PropTypes.func.isRequired, - review: PropTypes.shape({ - id: PropTypes.string.isRequired, - submittedAt: PropTypes.string.isRequired, - comments: PropTypes.object.isRequired, - }), - relay: PropTypes.shape({ - hasMore: PropTypes.func.isRequired, - loadMore: PropTypes.func.isRequired, - isLoading: PropTypes.func.isRequired, - }).isRequired, - } - - componentDidMount() { - this.accumulateComments(); - } - - accumulateComments = error => { - /* istanbul ignore if */ - if (error) { - // eslint-disable-next-line no-console - console.error(error); - return; - } - - const {submittedAt, comments, id} = this.props.review; - this.props.collectComments({ - reviewId: id, - submittedAt, - comments, - fetchingMoreComments: this.props.relay.hasMore(), - }); - - this._attemptToLoadMoreComments(); - } - - _attemptToLoadMoreComments = () => { - if (!this.props.relay.hasMore()) { - return; - } - - if (this.props.relay.isLoading()) { - setTimeout(this._loadMoreComments, PAGINATION_WAIT_TIME_MS); - } else { - this._loadMoreComments(); - } - } - - _loadMoreComments = () => { - this.props.relay.loadMore( - PAGE_SIZE, - this.accumulateComments, - ); - } - - render() { - return null; - } -} - -export default createPaginationContainer(BarePullRequestReviewCommentsContainer, { - review: graphql` - fragment prReviewCommentsContainer_review on PullRequestReview - @argumentDefinitions( - commentCount: {type: "Int!"}, - commentCursor: {type: "String"} - ) { - id - submittedAt - comments( - first: $commentCount, - after: $commentCursor - ) @connection(key: "PrReviewCommentsContainer_comments") { - pageInfo { - hasNextPage - endCursor - } - - edges { - cursor - node { - id - author { - avatarUrl - login - } - bodyHTML - isMinimized - path - position - replyTo { - id - } - createdAt - url - } - } - } - } - `, -}, { - direction: 'forward', - getConnectionFromProps(props) { - return props.review.comments; - }, - getFragmentVariables(prevVars, totalCount) { - return { - ...prevVars, - commentCount: totalCount, - }; - }, - getVariables(props, {count, cursor}, fragmentVariables) { - return { - id: props.review.id, - commentCount: count, - commentCursor: cursor, - }; - }, - query: graphql` - query prReviewCommentsContainerQuery($commentCount: Int!, $commentCursor: String, $id: ID!) { - node(id: $id) { - ... on PullRequestReview { - ...prReviewCommentsContainer_review @arguments( - commentCount: $commentCount, - commentCursor: $commentCursor - ) - } - } - } - `, -}); diff --git a/lib/containers/pr-reviews-container.js b/lib/containers/pr-reviews-container.js deleted file mode 100644 index 45ad85fa3e..0000000000 --- a/lib/containers/pr-reviews-container.js +++ /dev/null @@ -1,86 +0,0 @@ -import {graphql, createPaginationContainer} from 'react-relay'; - -import PullRequestReviewsController from '../controllers/pr-reviews-controller'; - -export default createPaginationContainer(PullRequestReviewsController, { - pullRequest: graphql` - fragment prReviewsContainer_pullRequest on PullRequest - @argumentDefinitions( - reviewCount: {type: "Int!"}, - reviewCursor: {type: "String"}, - commentCount: {type: "Int!"}, - commentCursor: {type: "String"} - ) { - url - reviews( - first: $reviewCount, - after: $reviewCursor - ) @connection(key: "PrReviewsContainer_reviews") { - pageInfo { - hasNextPage - endCursor - } - - edges { - cursor - node { - id - body - state - submittedAt - login: author { - login - } - author { - avatarUrl - } - ...prReviewCommentsContainer_review @arguments( - commentCount: $commentCount, - commentCursor: $commentCursor - ) - } - } - } - } - `, -}, { - direction: 'forward', - getConnectionFromProps(props) { - return props.pullRequest.reviews; - }, - getFragmentVariables(prevVars, totalCount) { - return { - ...prevVars, - reviewCount: totalCount, - }; - }, - getVariables(props, {count, cursor}, fragmentVariables) { - return { - url: props.pullRequest.url, - reviewCount: count, - reviewCursor: cursor, - commentCount: fragmentVariables.commentCount, - commentCursor: fragmentVariables.commentCursor, - }; - }, - query: graphql` - query prReviewsContainerQuery( - $reviewCount: Int!, - $reviewCursor: String, - $commentCount: Int!, - $commentCursor: String, - $url: URI! - ) { - resource(url: $url) { - ... on PullRequest { - ...prReviewsContainer_pullRequest @arguments( - reviewCount: $reviewCount, - reviewCursor: $reviewCursor, - commentCount: $commentCount, - commentCursor: $commentCursor - ) - } - } - } - `, -}); diff --git a/lib/containers/remote-container.js b/lib/containers/remote-container.js index e4aec30e9a..5b8a155b57 100644 --- a/lib/containers/remote-container.js +++ b/lib/containers/remote-container.js @@ -24,7 +24,7 @@ export default class RemoteContainer extends React.Component { // Repository attributes remoteOperationObserver: OperationStateObserverPropType.isRequired, pushInProgress: PropTypes.bool.isRequired, - workingDirectory: PropTypes.string.isRequired, + workingDirectory: PropTypes.string, workspace: PropTypes.object.isRequired, remote: RemotePropType.isRequired, remotes: RemoteSetPropType.isRequired, diff --git a/lib/containers/reviews-container.js b/lib/containers/reviews-container.js new file mode 100644 index 0000000000..45b62d6afa --- /dev/null +++ b/lib/containers/reviews-container.js @@ -0,0 +1,244 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import yubikiri from 'yubikiri'; +import {QueryRenderer, graphql} from 'react-relay'; + +import {PAGE_SIZE} from '../helpers'; +import {GithubLoginModelPropType, EndpointPropType, WorkdirContextPoolPropType} from '../prop-types'; +import {UNAUTHENTICATED, INSUFFICIENT} from '../shared/keytar-strategy'; +import PullRequestPatchContainer from './pr-patch-container'; +import ObserveModel from '../views/observe-model'; +import LoadingView from '../views/loading-view'; +import GithubLoginView from '../views/github-login-view'; +import ErrorView from '../views/error-view'; +import QueryErrorView from '../views/query-error-view'; +import RelayNetworkLayerManager from '../relay-network-layer-manager'; +import RelayEnvironment from '../views/relay-environment'; +import ReviewsController from '../controllers/reviews-controller'; +import AggregatedReviewsContainer from './aggregated-reviews-container'; +import CommentPositioningContainer from './comment-positioning-container'; + +export default class ReviewsContainer extends React.Component { + static propTypes = { + // Connection + endpoint: EndpointPropType.isRequired, + + // Pull request selection criteria + owner: PropTypes.string.isRequired, + repo: PropTypes.string.isRequired, + number: PropTypes.number.isRequired, + workdir: PropTypes.string.isRequired, + + // Package models + repository: PropTypes.object.isRequired, + loginModel: GithubLoginModelPropType.isRequired, + workdirContextPool: WorkdirContextPoolPropType.isRequired, + initThreadID: PropTypes.string, + + // Atom environment + workspace: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + tooltips: PropTypes.object.isRequired, + + // Action methods + reportMutationErrors: PropTypes.func.isRequired, + } + + render() { + return ( + + {this.renderWithToken} + + ); + } + + renderWithToken = token => { + if (!token) { + return ; + } + + if (token === UNAUTHENTICATED) { + return ; + } + + if (token === INSUFFICIENT) { + return ( + +

+ Your token no longer has sufficient authorizations. Please re-authenticate and generate a new one. +

+
+ ); + } + + return ( + + {(error, patch) => this.renderWithPatch(error, {token, patch})} + + ); + } + + renderWithPatch(error, {token, patch}) { + if (error) { + return ; + } + + return ( + + {repoData => this.renderWithRepositoryData(repoData, {token, patch})} + + ); + } + + renderWithRepositoryData(repoData, {token, patch}) { + const environment = RelayNetworkLayerManager.getEnvironmentForHost(this.props.endpoint, token); + const query = graphql` + query reviewsContainerQuery + ( + $repoOwner: String! + $repoName: String! + $prNumber: Int! + $reviewCount: Int! + $reviewCursor: String + $threadCount: Int! + $threadCursor: String + $commentCount: Int! + $commentCursor: String + ) { + repository(owner: $repoOwner, name: $repoName) { + ...reviewsController_repository + pullRequest(number: $prNumber) { + headRefOid + ...aggregatedReviewsContainer_pullRequest @arguments( + reviewCount: $reviewCount + reviewCursor: $reviewCursor + threadCount: $threadCount + threadCursor: $threadCursor + commentCount: $commentCount + commentCursor: $commentCursor + ) + ...reviewsController_pullRequest + } + } + + viewer { + ...reviewsController_viewer + } + } + `; + const variables = { + repoOwner: this.props.owner, + repoName: this.props.repo, + prNumber: this.props.number, + reviewCount: PAGE_SIZE, + reviewCursor: null, + threadCount: PAGE_SIZE, + threadCursor: null, + commentCount: PAGE_SIZE, + commentCursor: null, + }; + + return ( + + this.renderWithQuery(queryResult, {repoData, patch})} + /> + + ); + } + + renderWithQuery({error, props, retry}, {repoData, patch}) { + if (error) { + return ( + + ); + } + + if (!props || !repoData || !patch) { + return ; + } + + return ( + + {({errors, summaries, commentThreads, refetch}) => { + if (errors && errors.length > 0) { + return errors.map((err, i) => ( + + )); + } + const aggregationResult = {summaries, commentThreads, refetch}; + + return this.renderWithResult({ + aggregationResult, + queryProps: props, + repoData, + patch, + refetch, + }); + }} + + ); + } + + renderWithResult({aggregationResult, queryProps, repoData, patch}) { + return ( + + {commentTranslations => { + return ( + + ); + }} + + ); + } + + fetchToken = loginModel => loginModel.getToken(this.props.endpoint.getLoginAccount()); + + fetchRepositoryData = repository => { + return yubikiri({ + branches: repository.getBranches(), + remotes: repository.getRemotes(), + isAbsent: repository.isAbsent(), + isLoading: repository.isLoading(), + isPresent: repository.isPresent(), + isMerging: repository.isMerging(), + isRebasing: repository.isRebasing(), + }); + } + + handleLogin = token => this.props.loginModel.setToken(this.props.endpoint.getLoginAccount(), token); + + handleLogout = () => this.props.loginModel.removeToken(this.props.endpoint.getLoginAccount()); +} diff --git a/lib/controllers/__generated__/commentDecorationsController_pullRequests.graphql.js b/lib/controllers/__generated__/commentDecorationsController_pullRequests.graphql.js new file mode 100644 index 0000000000..c0a42421d5 --- /dev/null +++ b/lib/controllers/__generated__/commentDecorationsController_pullRequests.graphql.js @@ -0,0 +1,117 @@ +/** + * @flow + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ReaderFragment } from 'relay-runtime'; +import type { FragmentReference } from "relay-runtime"; +declare export opaque type commentDecorationsController_pullRequests$ref: FragmentReference; +export type commentDecorationsController_pullRequests = $ReadOnlyArray<{| + +number: number, + +headRefName: string, + +headRefOid: any, + +headRepository: ?{| + +name: string, + +owner: {| + +login: string + |}, + |}, + +repository: {| + +name: string, + +owner: {| + +login: string + |}, + |}, + +$refType: commentDecorationsController_pullRequests$ref, +|}>; +*/ + + +const node/*: ReaderFragment*/ = (function(){ +var v0 = [ + { + "kind": "ScalarField", + "alias": null, + "name": "name", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "owner", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null + } + ] + } +]; +return { + "kind": "Fragment", + "name": "commentDecorationsController_pullRequests", + "type": "PullRequest", + "metadata": { + "plural": true + }, + "argumentDefinitions": [], + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "number", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "headRefName", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "headRefOid", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "headRepository", + "storageKey": null, + "args": null, + "concreteType": "Repository", + "plural": false, + "selections": (v0/*: any*/) + }, + { + "kind": "LinkedField", + "alias": null, + "name": "repository", + "storageKey": null, + "args": null, + "concreteType": "Repository", + "plural": false, + "selections": (v0/*: any*/) + } + ] +}; +})(); +// prettier-ignore +(node/*: any*/).hash = '62f96ccd13dfc2649112a7b4afaf4ba2'; +module.exports = node; diff --git a/lib/controllers/__generated__/emojiReactionsController_reactable.graphql.js b/lib/controllers/__generated__/emojiReactionsController_reactable.graphql.js new file mode 100644 index 0000000000..149a69aea9 --- /dev/null +++ b/lib/controllers/__generated__/emojiReactionsController_reactable.graphql.js @@ -0,0 +1,45 @@ +/** + * @flow + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ReaderFragment } from 'relay-runtime'; +type emojiReactionsView_reactable$ref = any; +import type { FragmentReference } from "relay-runtime"; +declare export opaque type emojiReactionsController_reactable$ref: FragmentReference; +export type emojiReactionsController_reactable = {| + +id: string, + +$fragmentRefs: emojiReactionsView_reactable$ref, + +$refType: emojiReactionsController_reactable$ref, +|}; +*/ + + +const node/*: ReaderFragment*/ = { + "kind": "Fragment", + "name": "emojiReactionsController_reactable", + "type": "Reactable", + "metadata": null, + "argumentDefinitions": [], + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null + }, + { + "kind": "FragmentSpread", + "name": "emojiReactionsView_reactable", + "args": null + } + ] +}; +// prettier-ignore +(node/*: any*/).hash = 'cfdd39cd7aa02bce0bdcd52bc0154223'; +module.exports = node; diff --git a/lib/controllers/__generated__/issueishDetailController_repository.graphql.js b/lib/controllers/__generated__/issueishDetailController_repository.graphql.js index d2d232f883..b33386d3b5 100644 --- a/lib/controllers/__generated__/issueishDetailController_repository.graphql.js +++ b/lib/controllers/__generated__/issueishDetailController_repository.graphql.js @@ -10,6 +10,8 @@ import type { ReaderFragment } from 'relay-runtime'; type issueDetailView_issue$ref = any; type issueDetailView_repository$ref = any; +type prCheckoutController_pullRequest$ref = any; +type prCheckoutController_repository$ref = any; type prDetailView_pullRequest$ref = any; type prDetailView_repository$ref = any; import type { FragmentReference } from "relay-runtime"; @@ -33,54 +35,20 @@ export type issueishDetailController_repository = {| +__typename: "PullRequest", +title: string, +number: number, - +headRefName: string, - +headRepository: ?{| - +name: string, - +owner: {| - +login: string - |}, - +url: any, - +sshUrl: any, - |}, - +$fragmentRefs: prDetailView_pullRequest$ref, + +$fragmentRefs: prCheckoutController_pullRequest$ref & prDetailView_pullRequest$ref, |} | {| // This will never be '%other', but we need some // value in case none of the concrete values match. +__typename: "%other" |}), - +$fragmentRefs: issueDetailView_repository$ref & prDetailView_repository$ref, + +$fragmentRefs: issueDetailView_repository$ref & prCheckoutController_repository$ref & prDetailView_repository$ref, +$refType: issueishDetailController_repository$ref, |}; */ const node/*: ReaderFragment*/ = (function(){ -var v0 = { - "kind": "ScalarField", - "alias": null, - "name": "name", - "args": null, - "storageKey": null -}, -v1 = { - "kind": "LinkedField", - "alias": null, - "name": "owner", - "storageKey": null, - "args": null, - "concreteType": null, - "plural": false, - "selections": [ - { - "kind": "ScalarField", - "alias": null, - "name": "login", - "args": null, - "storageKey": null - } - ] -}, -v2 = [ +var v0 = [ { "kind": "Variable", "name": "number", @@ -88,34 +56,34 @@ v2 = [ "type": "Int!" } ], -v3 = { +v1 = { "kind": "ScalarField", "alias": null, "name": "__typename", "args": null, "storageKey": null }, -v4 = { +v2 = { "kind": "ScalarField", "alias": null, "name": "title", "args": null, "storageKey": null }, -v5 = { +v3 = { "kind": "ScalarField", "alias": null, "name": "number", "args": null, "storageKey": null }, -v6 = { +v4 = { "kind": "Variable", "name": "timelineCount", "variableName": "timelineCount", "type": null }, -v7 = { +v5 = { "kind": "Variable", "name": "timelineCursor", "variableName": "timelineCursor", @@ -158,28 +126,34 @@ return { "defaultValue": null }, { - "kind": "LocalArgument", + "kind": "RootArgument", "name": "reviewCount", - "type": "Int!", - "defaultValue": null + "type": null }, { - "kind": "LocalArgument", + "kind": "RootArgument", "name": "reviewCursor", - "type": "String", - "defaultValue": null + "type": null }, { - "kind": "LocalArgument", + "kind": "RootArgument", + "name": "threadCount", + "type": null + }, + { + "kind": "RootArgument", + "name": "threadCursor", + "type": null + }, + { + "kind": "RootArgument", "name": "commentCount", - "type": "Int!", - "defaultValue": null + "type": null }, { - "kind": "LocalArgument", + "kind": "RootArgument", "name": "commentCursor", - "type": "String", - "defaultValue": null + "type": null } ], "selections": [ @@ -188,35 +162,63 @@ return { "name": "issueDetailView_repository", "args": null }, + { + "kind": "FragmentSpread", + "name": "prCheckoutController_repository", + "args": null + }, { "kind": "FragmentSpread", "name": "prDetailView_repository", "args": null }, - (v0/*: any*/), - (v1/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "name", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "owner", + "storageKey": null, + "args": null, + "concreteType": null, + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null + } + ] + }, { "kind": "LinkedField", "alias": "issue", "name": "issueOrPullRequest", "storageKey": null, - "args": (v2/*: any*/), + "args": (v0/*: any*/), "concreteType": null, "plural": false, "selections": [ - (v3/*: any*/), + (v1/*: any*/), { "kind": "InlineFragment", "type": "Issue", "selections": [ - (v4/*: any*/), - (v5/*: any*/), + (v2/*: any*/), + (v3/*: any*/), { "kind": "FragmentSpread", "name": "issueDetailView_issue", "args": [ - (v6/*: any*/), - (v7/*: any*/) + (v4/*: any*/), + (v5/*: any*/) ] } ] @@ -228,50 +230,21 @@ return { "alias": "pullRequest", "name": "issueOrPullRequest", "storageKey": null, - "args": (v2/*: any*/), + "args": (v0/*: any*/), "concreteType": null, "plural": false, "selections": [ - (v3/*: any*/), + (v1/*: any*/), { "kind": "InlineFragment", "type": "PullRequest", "selections": [ - (v4/*: any*/), - (v5/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "headRefName", - "args": null, - "storageKey": null - }, + (v2/*: any*/), + (v3/*: any*/), { - "kind": "LinkedField", - "alias": null, - "name": "headRepository", - "storageKey": null, - "args": null, - "concreteType": "Repository", - "plural": false, - "selections": [ - (v0/*: any*/), - (v1/*: any*/), - { - "kind": "ScalarField", - "alias": null, - "name": "url", - "args": null, - "storageKey": null - }, - { - "kind": "ScalarField", - "alias": null, - "name": "sshUrl", - "args": null, - "storageKey": null - } - ] + "kind": "FragmentSpread", + "name": "prCheckoutController_pullRequest", + "args": null }, { "kind": "FragmentSpread", @@ -313,8 +286,20 @@ return { "variableName": "reviewCursor", "type": null }, - (v6/*: any*/), - (v7/*: any*/) + { + "kind": "Variable", + "name": "threadCount", + "variableName": "threadCount", + "type": null + }, + { + "kind": "Variable", + "name": "threadCursor", + "variableName": "threadCursor", + "type": null + }, + (v4/*: any*/), + (v5/*: any*/) ] } ] @@ -325,5 +310,5 @@ return { }; })(); // prettier-ignore -(node/*: any*/).hash = 'c06dfbb4f1cc1c45187449da61fd7328'; +(node/*: any*/).hash = 'b280de21fe28a74a5cbf1c93d3585955'; module.exports = node; diff --git a/lib/controllers/__generated__/issueishListController_results.graphql.js b/lib/controllers/__generated__/issueishListController_results.graphql.js index 79ba552a04..94d7e75348 100644 --- a/lib/controllers/__generated__/issueishListController_results.graphql.js +++ b/lib/controllers/__generated__/issueishListController_results.graphql.js @@ -22,7 +22,11 @@ export type issueishListController_results = $ReadOnlyArray<{| +createdAt: any, +headRefName: string, +repository: {| - +id: string + +id: string, + +name: string, + +owner: {| + +login: string + |}, |}, +commits: {| +nodes: ?$ReadOnlyArray comment.isMinimized)) { + continue; + } + + // There may be multiple comments in the thread, but we really only care about the root comment when rendering + // decorations. + const threadData = { + rootCommentID: comments[0].id, + threadID: thread.id, + position: comments[0].position, + nativeRelPath: toNativePathSep(comments[0].path), + fullPath: path.join(workdirPath, toNativePathSep(comments[0].path)), + }; + + if (threadDataByPath.get(threadData.fullPath)) { + threadDataByPath.get(threadData.fullPath).push(threadData); + } else { + threadDataByPath.set(threadData.fullPath, [threadData]); + } + } + + const openEditorsWithCommentThreads = this.getOpenEditorsWithCommentThreads(threadDataByPath); + return ( + + + + + {openEditorsWithCommentThreads.map(editor => { + const threadData = threadDataByPath.get(editor.getPath()); + const translations = this.props.commentTranslations.get(threadData[0].nativeRelPath); + + return ( + + + + + ); + })} + ); + } + + getOpenEditorsWithCommentThreads(threadDataByPath) { + const haveThreads = []; + for (const editor of this.state.openEditors) { + if (threadDataByPath.has(editor.getPath())) { + haveThreads.push(editor); + } + } + return haveThreads; + } + + // Determine if we already have this PR checked out. + // todo: if this is similar enough to pr-checkout-controller, extract a single + // helper function to do this check. + isCheckedOutPullRequest(branches, remotes, pullRequest) { + // determine if pullRequest.headRepository is null + // this can happen if a repository has been deleted. + if (!pullRequest.headRepository) { + return false; + } + + const {repository} = pullRequest; + + const headPush = branches.getHeadBranch().getPush(); + const headRemote = remotes.withName(headPush.getRemoteName()); + + // (detect checkout from pull/### refspec) + const fromPullRefspec = + headRemote.getOwner() === repository.owner.login && + headRemote.getRepo() === repository.name && + headPush.getShortRemoteRef() === `pull/${pullRequest.number}/head`; + + // (detect checkout from head repository) + const fromHeadRepo = + headRemote.getOwner() === pullRequest.headRepository.owner.login && + headRemote.getRepo() === pullRequest.headRepository.name && + headPush.getShortRemoteRef() === pullRequest.headRefName; + + if (fromPullRefspec || fromHeadRepo) { + return true; + } + return false; + } + + updateOpenEditors = () => { + return new Promise(resolve => { + this.setState({openEditors: this.props.workspace.getTextEditors()}, resolve); + }); + } + + openReviewsTab = () => { + const [pullRequest] = this.props.pullRequests; + /* istanbul ignore if */ + if (!pullRequest) { + return null; + } + + const uri = ReviewsItem.buildURI({ + host: this.props.endpoint.getHost(), + owner: this.props.owner, + repo: this.props.repo, + number: pullRequest.number, + workdir: this.props.repoData.workingDirectoryPath, + }); + return this.props.workspace.open(uri, {searchAllPanes: true}); + } +} + +export default createFragmentContainer(BareCommentDecorationsController, { + pullRequests: graphql` + fragment commentDecorationsController_pullRequests on PullRequest + @relay(plural: true) + { + number + headRefName + headRefOid + headRepository { + name + owner { + login + } + } + repository { + name + owner { + login + } + } + } + `, +}); diff --git a/lib/controllers/comment-gutter-decoration-controller.js b/lib/controllers/comment-gutter-decoration-controller.js new file mode 100644 index 0000000000..452dbed908 --- /dev/null +++ b/lib/controllers/comment-gutter-decoration-controller.js @@ -0,0 +1,66 @@ +import React from 'react'; +import {Range} from 'atom'; +import PropTypes from 'prop-types'; +import {EndpointPropType} from '../prop-types'; +import Decoration from '../atom/decoration'; +import Marker from '../atom/marker'; +import ReviewsItem from '../items/reviews-item'; +import {addEvent} from '../reporter-proxy'; + +export default class CommentGutterDecorationController extends React.Component { + static propTypes = { + commentRow: PropTypes.number.isRequired, + threadId: PropTypes.string.isRequired, + extraClasses: PropTypes.array, + + workspace: PropTypes.object.isRequired, + endpoint: EndpointPropType.isRequired, + owner: PropTypes.string.isRequired, + repo: PropTypes.string.isRequired, + number: PropTypes.number.isRequired, + workdir: PropTypes.string.isRequired, + editor: PropTypes.object, + + // For metric reporting + parent: PropTypes.string.isRequired, + }; + + static defaultProps = { + extraClasses: [], + } + + render() { + const range = Range.fromObject([[this.props.commentRow, 0], [this.props.commentRow, Infinity]]); + return ( + + + + ); + } + +} diff --git a/lib/views/emoji-reactions-view.js b/lib/views/emoji-reactions-view.js index 729566fd4a..f530b78f8f 100644 --- a/lib/views/emoji-reactions-view.js +++ b/lib/views/emoji-reactions-view.js @@ -1,47 +1,123 @@ import PropTypes from 'prop-types'; import React from 'react'; +import {createFragmentContainer, graphql} from 'react-relay'; import cx from 'classnames'; -const reactionTypeToEmoji = { - THUMBS_UP: '👍', - THUMBS_DOWN: '👎', - LAUGH: '😆', - HOORAY: '🎉', - CONFUSED: '😕', - HEART: '❤️', - ROCKET: '🚀', - EYES: '👀', -}; - -export default class EmojiReactionsView extends React.Component { +import ReactionPickerController from '../controllers/reaction-picker-controller'; +import Tooltip from '../atom/tooltip'; +import RefHolder from '../models/ref-holder'; +import {reactionTypeToEmoji} from '../helpers'; + +export class BareEmojiReactionsView extends React.Component { static propTypes = { - reactionGroups: PropTypes.arrayOf( - PropTypes.shape({ - content: PropTypes.string.isRequired, - users: PropTypes.shape({ - totalCount: PropTypes.number.isRequired, - }).isRequired, - }), - ).isRequired, + // Relay response + reactable: PropTypes.shape({ + id: PropTypes.string.isRequired, + reactionGroups: PropTypes.arrayOf( + PropTypes.shape({ + content: PropTypes.string.isRequired, + viewerHasReacted: PropTypes.bool.isRequired, + users: PropTypes.shape({ + totalCount: PropTypes.number.isRequired, + }).isRequired, + }), + ).isRequired, + viewerCanReact: PropTypes.bool.isRequired, + }).isRequired, + + // Atom environment + tooltips: PropTypes.object.isRequired, + + // Action methods + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + } + + constructor(props) { + super(props); + + this.refAddButton = new RefHolder(); + this.refTooltip = new RefHolder(); } render() { + const viewerReacted = this.props.reactable.reactionGroups + .filter(group => group.viewerHasReacted) + .map(group => group.content); + const {reactionGroups} = this.props.reactable; + const showAddButton = reactionGroups.length === 0 || reactionGroups.some(g => g.users.totalCount === 0); + return ( -
- {this.props.reactionGroups.map(group => { - const emoji = reactionTypeToEmoji[group.content]; - if (!emoji) { - return null; - } - return ( - group.users.totalCount > 0 - ? +
+ {showAddButton && ( +
+
+ )} +
+ {this.props.reactable.reactionGroups.map(group => { + const emoji = reactionTypeToEmoji[group.content]; + if (!emoji) { + return null; + } + if (group.users.totalCount === 0) { + return null; + } + + const className = cx( + 'github-EmojiReactions-group', + 'btn', + group.content.toLowerCase(), + {selected: group.viewerHasReacted}, + ); + + const toggle = !group.viewerHasReacted + ? () => this.props.addReaction(group.content) + : () => this.props.removeReaction(group.content); + + const disabled = !this.props.reactable.viewerCanReact; + + return ( + + ); + })} +
); } } + +export default createFragmentContainer(BareEmojiReactionsView, { + reactable: graphql` + fragment emojiReactionsView_reactable on Reactable { + id + reactionGroups { + content + viewerHasReacted + users { + totalCount + } + } + viewerCanReact + } + `, +}); diff --git a/lib/views/github-dotcom-markdown.js b/lib/views/github-dotcom-markdown.js index 5af7dfb7bd..6678b12f8f 100644 --- a/lib/views/github-dotcom-markdown.js +++ b/lib/views/github-dotcom-markdown.js @@ -89,7 +89,8 @@ export class BareGithubDotcomMarkdown extends React.Component { render() { return (
{ this.component = c; }} dangerouslySetInnerHTML={{__html: this.props.html}} /> diff --git a/lib/views/github-tab-view.js b/lib/views/github-tab-view.js index 48dbd17119..6ce0dbddd2 100644 --- a/lib/views/github-tab-view.js +++ b/lib/views/github-tab-view.js @@ -16,7 +16,7 @@ export default class GitHubTabView extends React.Component { loginModel: GithubLoginModelPropType.isRequired, rootHolder: RefHolderPropType.isRequired, - workingDirectory: PropTypes.string.isRequired, + workingDirectory: PropTypes.string, branches: BranchSetPropType.isRequired, currentBranch: BranchPropType.isRequired, remotes: RemoteSetPropType.isRequired, diff --git a/lib/views/issue-detail-view.js b/lib/views/issue-detail-view.js index 54d07e74cb..34f512b335 100644 --- a/lib/views/issue-detail-view.js +++ b/lib/views/issue-detail-view.js @@ -4,15 +4,16 @@ import PropTypes from 'prop-types'; import cx from 'classnames'; import IssueTimelineController from '../controllers/issue-timeline-controller'; +import EmojiReactionsController from '../controllers/emoji-reactions-controller'; import Octicon from '../atom/octicon'; import IssueishBadge from '../views/issueish-badge'; import GithubDotcomMarkdown from '../views/github-dotcom-markdown'; -import EmojiReactionsView from '../views/emoji-reactions-view'; import PeriodicRefresher from '../periodic-refresher'; import {addEvent} from '../reporter-proxy'; export class BareIssueDetailView extends React.Component { static propTypes = { + // Relay response relay: PropTypes.shape({ refetch: PropTypes.func.isRequired, }), @@ -48,6 +49,12 @@ export class BareIssueDetailView extends React.Component { }), ).isRequired, }).isRequired, + + // Atom environment + tooltips: PropTypes.object.isRequired, + + // Action methods + reportMutationErrors: PropTypes.func.isRequired, } state = { @@ -76,7 +83,11 @@ export class BareIssueDetailView extends React.Component { html={issue.bodyHTML || 'No description provided.'} switchToIssueish={this.props.switchToIssueish} /> - + {this.renderIssueish} ); } + renderReviewsButton = () => { + if (!this.props.needReviewsButton || this.props.total < 1) { + return null; + } + return ( + + ); + } + + openReviews = e => { + e.stopPropagation(); + this.props.openReviews(); + } + renderIssueish(issueish) { return ( diff --git a/lib/views/multi-file-patch-view.js b/lib/views/multi-file-patch-view.js index 76fa7e199f..d8798444e4 100644 --- a/lib/views/multi-file-patch-view.js +++ b/lib/views/multi-file-patch-view.js @@ -4,9 +4,9 @@ import cx from 'classnames'; import {Range} from 'atom'; import {CompositeDisposable, Disposable} from 'event-kit'; -import {autobind} from '../helpers'; +import {autobind, NBSP_CHARACTER, blankLabel} from '../helpers'; import {addEvent} from '../reporter-proxy'; -import {RefHolderPropType, MultiFilePatchPropType, ItemTypePropType} from '../prop-types'; +import {RefHolderPropType, MultiFilePatchPropType, ItemTypePropType, EndpointPropType} from '../prop-types'; import AtomTextEditor from '../atom/atom-text-editor'; import Marker from '../atom/marker'; import MarkerLayer from '../atom/marker-layer'; @@ -16,10 +16,10 @@ import Commands, {Command} from '../atom/commands'; import FilePatchHeaderView from './file-patch-header-view'; import FilePatchMetaView from './file-patch-meta-view'; import HunkHeaderView from './hunk-header-view'; -import PullRequestsReviewsContainer from '../containers/pr-reviews-container'; import RefHolder from '../models/ref-holder'; import ChangedFileItem from '../items/changed-file-item'; import CommitDetailItem from '../items/commit-detail-item'; +import CommentGutterDecorationController from '../controllers/comment-gutter-decoration-controller'; import IssueishDetailItem from '../items/issueish-detail-item'; import File from '../models/patch/file'; @@ -30,21 +30,29 @@ const executableText = { [File.modes.EXECUTABLE]: 'executable', }; -const NBSP_CHARACTER = '\u00a0'; - -const BLANK_LABEL = () => NBSP_CHARACTER; - export default class MultiFilePatchView extends React.Component { static propTypes = { + // Behavior controls stagingStatus: PropTypes.oneOf(['staged', 'unstaged']), isPartiallyStaged: PropTypes.bool, + itemType: ItemTypePropType.isRequired, + + // Models + repository: PropTypes.object.isRequired, multiFilePatch: MultiFilePatchPropType.isRequired, selectionMode: PropTypes.oneOf(['hunk', 'line']).isRequired, selectedRows: PropTypes.object.isRequired, hasMultipleFileSelections: PropTypes.bool.isRequired, - repository: PropTypes.object.isRequired, hasUndoHistory: PropTypes.bool, + // Review comments + reviewCommentsLoading: PropTypes.bool, + reviewCommentThreads: PropTypes.arrayOf(PropTypes.shape({ + thread: PropTypes.object.isRequired, + comments: PropTypes.arrayOf(PropTypes.object).isRequired, + })), + + // Atom environment workspace: PropTypes.object.isRequired, commands: PropTypes.object.isRequired, keymaps: PropTypes.object.isRequired, @@ -52,9 +60,11 @@ export default class MultiFilePatchView extends React.Component { config: PropTypes.object.isRequired, pullRequest: PropTypes.object, + // Callbacks selectedRowsChanged: PropTypes.func, - switchToIssueish: PropTypes.func, + // Action methods + switchToIssueish: PropTypes.func, diveIntoMirrorPatch: PropTypes.func, surface: PropTypes.func, openFile: PropTypes.func, @@ -66,14 +76,28 @@ export default class MultiFilePatchView extends React.Component { discardRows: PropTypes.func, onWillUpdatePatch: PropTypes.func, onDidUpdatePatch: PropTypes.func, + + // External refs refEditor: RefHolderPropType, refInitialFocus: RefHolderPropType, - itemType: ItemTypePropType.isRequired, + + // for navigating the PR changed files tab + onOpenFilesTab: PropTypes.func, + initChangedFilePath: PropTypes.string, initChangedFilePosition: PropTypes.number, + + // for opening the reviews dock item + endpoint: EndpointPropType, + owner: PropTypes.string, + repo: PropTypes.string, + number: PropTypes.number, + workdirPath: PropTypes.string, } static defaultProps = { onWillUpdatePatch: () => new Disposable(), onDidUpdatePatch: () => new Disposable(), + reviewCommentsLoading: false, + reviewCommentThreads: [], } constructor(props) { @@ -181,6 +205,23 @@ export default class MultiFilePatchView extends React.Component { this.subs.add( this.props.config.onDidChange('github.showDiffIconGutter', () => this.forceUpdate()), ); + + const {initChangedFilePath, initChangedFilePosition} = this.props; + + /* istanbul ignore next */ + if (initChangedFilePath && initChangedFilePosition >= 0) { + this.scrollToFile({ + changedFilePath: initChangedFilePath, + changedFilePosition: initChangedFilePosition, + }); + } + + /* istanbul ignore if */ + if (this.props.onOpenFilesTab) { + this.subs.add( + this.props.onOpenFilesTab(this.scrollToFile), + ); + } } componentDidUpdate(prevProps) { @@ -338,19 +379,25 @@ export default class MultiFilePatchView extends React.Component { onMouseDown={this.didMouseDownOnLineNumber} onMouseMove={this.didMouseMoveOnLineNumber} /> + {this.props.config.get('github.showDiffIconGutter') && ( )} - {this.renderPullRequestReviews()} + {this.renderPRCommentIcons()} {this.props.multiFilePatch.getFilePatches().map(this.renderFilePatchDecorations)} @@ -380,22 +427,40 @@ export default class MultiFilePatchView extends React.Component { ); } - renderPullRequestReviews() { - if (this.props.itemType === IssueishDetailItem) { - // "forceRerender" ensures that the PullRequestCommentsView re-renders each time that the MultiFilePatchView does. - // It doesn't re-query for reviews, but it does re-check patch visibility. + renderPRCommentIcons() { + if (this.props.itemType !== IssueishDetailItem || + this.props.reviewCommentsLoading) { + return null; + } + + return this.props.reviewCommentThreads.map(({comments, thread}) => { + const {path, position} = comments[0]; + if (!this.props.multiFilePatch.getPatchForPath(path)) { + return null; + } + + const row = this.props.multiFilePatch.getBufferRowForDiffPosition(path, position); + if (row === null) { + return null; + } + + const isRowSelected = this.props.selectedRows.has(row); return ( - ); - } else { - return null; - } + }); } renderFilePatchDecorations = (filePatch, index) => { @@ -699,6 +764,12 @@ export default class MultiFilePatchView extends React.Component { className={lineClass} omitEmptyLastRow={false} /> + )} {icon && ( @@ -1048,7 +1119,7 @@ export default class MultiFilePatchView extends React.Component { const filePatchesWithCursors = new Set(cursorsByFilePatch.keys()); if (selectedFilePatch && !filePatchesWithCursors.has(selectedFilePatch)) { const [firstHunk] = selectedFilePatch.getHunks(); - const cursorRow = firstHunk ? firstHunk.getNewStartRow() - 1 : 0; + const cursorRow = firstHunk ? firstHunk.getNewStartRow() - 1 : /* istanbul ignore next */ 0; return this.props.openFile(selectedFilePatch, [[cursorRow, 0]], true); } else { const pending = cursorsByFilePatch.size === 1; @@ -1215,6 +1286,20 @@ export default class MultiFilePatchView extends React.Component { } } + scrollToFile = ({changedFilePath, changedFilePosition}) => { + /* istanbul ignore next */ + this.refEditor.map(e => { + const row = this.props.multiFilePatch.getBufferRowForDiffPosition(changedFilePath, changedFilePosition); + if (row === null) { + return null; + } + + e.scrollToBufferPosition({row, column: 0}, {center: true}); + e.setCursorBufferPosition({row, column: 0}); + return null; + }); + } + measurePerformance(action) { /* istanbul ignore else */ if ((action === 'update' || action === 'mount') diff --git a/lib/views/observe-model.js b/lib/views/observe-model.js index 5bd8eb5e21..19d696d8a6 100644 --- a/lib/views/observe-model.js +++ b/lib/views/observe-model.js @@ -2,7 +2,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import ModelObserver from '../models/model-observer'; -import {autobind} from '../helpers'; export default class ObserveModel extends React.Component { static propTypes = { @@ -10,30 +9,41 @@ export default class ObserveModel extends React.Component { onDidUpdate: PropTypes.func.isRequired, }), fetchData: PropTypes.func.isRequired, + fetchParams: PropTypes.arrayOf(PropTypes.any), children: PropTypes.func.isRequired, } + static defaultProps = { + fetchParams: [], + } + constructor(props, context) { super(props, context); - autobind(this, 'fetchData', 'didUpdate'); + this.state = {data: null}; this.modelObserver = new ModelObserver({fetchData: this.fetchData, didUpdate: this.didUpdate}); } - componentWillMount() { + componentDidMount() { this.mounted = true; this.modelObserver.setActiveModel(this.props.model); } - componentWillReceiveProps(nextProps) { - this.modelObserver.setActiveModel(nextProps.model); - } + componentDidUpdate(prevProps) { + this.modelObserver.setActiveModel(this.props.model); - fetchData(model) { - return this.props.fetchData(model); + if ( + !this.modelObserver.hasPendingUpdate() && + prevProps.fetchParams.length !== this.props.fetchParams.length || + prevProps.fetchParams.some((prevParam, i) => prevParam !== this.props.fetchParams[i]) + ) { + this.modelObserver.refreshModelData(); + } } - didUpdate(model) { + fetchData = model => this.props.fetchData(model, ...this.props.fetchParams); + + didUpdate = () => { if (this.mounted) { const data = this.modelObserver.getActiveModelData(); this.setState({data}); diff --git a/lib/views/patch-preview-view.js b/lib/views/patch-preview-view.js new file mode 100644 index 0000000000..00f24c6b49 --- /dev/null +++ b/lib/views/patch-preview-view.js @@ -0,0 +1,99 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import {blankLabel} from '../helpers'; +import AtomTextEditor from '../atom/atom-text-editor'; +import Decoration from '../atom/decoration'; +import MarkerLayer from '../atom/marker-layer'; +import Gutter from '../atom/gutter'; + +export default class PatchPreviewView extends React.Component { + static propTypes = { + multiFilePatch: PropTypes.shape({ + getPreviewPatchBuffer: PropTypes.func.isRequired, + }).isRequired, + fileName: PropTypes.string.isRequired, + diffRow: PropTypes.number.isRequired, + maxRowCount: PropTypes.number.isRequired, + + // Atom environment + config: PropTypes.shape({ + get: PropTypes.func.isRequired, + }), + } + + state = { + lastPatch: null, + lastFileName: null, + lastDiffRow: null, + lastMaxRowCount: null, + previewPatchBuffer: null, + } + + static getDerivedStateFromProps(props, state) { + if ( + props.multiFilePatch === state.lastPatch && + props.fileName === state.lastFileName && + props.diffRow === state.lastDiffRow && + props.maxRowCount === state.lastMaxRowCount + ) { + return null; + } + + const nextPreviewPatchBuffer = props.multiFilePatch.getPreviewPatchBuffer( + props.fileName, props.diffRow, props.maxRowCount, + ); + let previewPatchBuffer = null; + if (state.previewPatchBuffer !== null) { + state.previewPatchBuffer.adopt(nextPreviewPatchBuffer); + previewPatchBuffer = state.previewPatchBuffer; + } else { + previewPatchBuffer = nextPreviewPatchBuffer; + } + + return { + lastPatch: props.multiFilePatch, + lastFileName: props.fileName, + lastDiffRow: props.diffRow, + lastMaxRowCount: props.maxRowCount, + previewPatchBuffer, + }; + } + + render() { + return ( + + + {this.props.config.get('github.showDiffIconGutter') && ( + + )} + + {this.renderLayerDecorations('addition', 'github-FilePatchView-line--added')} + {this.renderLayerDecorations('deletion', 'github-FilePatchView-line--deleted')} + + + ); + } + + renderLayerDecorations(layerName, className) { + const layer = this.state.previewPatchBuffer.getLayer(layerName); + if (layer.getMarkerCount() === 0) { + return null; + } + + return ( + + + {this.props.config.get('github.showDiffIconGutter') && ( + + )} + + ); + } +} diff --git a/lib/views/pr-detail-view.js b/lib/views/pr-detail-view.js index 92d660b024..9aa879f279 100644 --- a/lib/views/pr-detail-view.js +++ b/lib/views/pr-detail-view.js @@ -9,39 +9,23 @@ import {addEvent} from '../reporter-proxy'; import PeriodicRefresher from '../periodic-refresher'; import Octicon from '../atom/octicon'; import PullRequestChangedFilesContainer from '../containers/pr-changed-files-container'; -import PrTimelineContainer from '../controllers/pr-timeline-controller'; +import {checkoutStates} from '../controllers/pr-checkout-controller'; +import PullRequestTimelineController from '../controllers/pr-timeline-controller'; +import EmojiReactionsController from '../controllers/emoji-reactions-controller'; import GithubDotcomMarkdown from '../views/github-dotcom-markdown'; -import EmojiReactionsView from '../views/emoji-reactions-view'; import IssueishBadge from '../views/issueish-badge'; -import PrCommitsView from '../views/pr-commits-view'; -import PrStatusesView from '../views/pr-statuses-view'; +import CheckoutButton from './checkout-button'; +import PullRequestCommitsView from '../views/pr-commits-view'; +import PullRequestStatusesView from '../views/pr-statuses-view'; +import ReviewsFooterView from '../views/reviews-footer-view'; import {PAGE_SIZE} from '../helpers'; -class CheckoutState { - constructor(name) { - this.name = name; - } - - when(cases) { - return cases[this.name] || cases.default; - } -} - -export const checkoutStates = { - HIDDEN: new CheckoutState('hidden'), - DISABLED: new CheckoutState('disabled'), - BUSY: new CheckoutState('busy'), - CURRENT: new CheckoutState('current'), -}; - export class BarePullRequestDetailView extends React.Component { static propTypes = { // Relay response relay: PropTypes.shape({ refetch: PropTypes.func.isRequired, }), - switchToIssueish: PropTypes.func.isRequired, - checkoutOp: EnableableOperationPropType.isRequired, repository: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, @@ -69,18 +53,21 @@ export class BarePullRequestDetailView extends React.Component { avatarUrl: PropTypes.string.isRequired, url: PropTypes.string.isRequired, }).isRequired, - reactionGroups: PropTypes.arrayOf( - PropTypes.shape({ - content: PropTypes.string.isRequired, - users: PropTypes.shape({ - totalCount: PropTypes.number.isRequired, - }).isRequired, - }), - ).isRequired, }).isRequired, // Local model objects localRepository: PropTypes.object.isRequired, + checkoutOp: EnableableOperationPropType.isRequired, + workdirPath: PropTypes.string, + + // Review comment threads + reviewCommentsLoading: PropTypes.bool.isRequired, + reviewCommentsTotalCount: PropTypes.number.isRequired, + reviewCommentsResolvedCount: PropTypes.number.isRequired, + reviewCommentThreads: PropTypes.arrayOf(PropTypes.shape({ + thread: PropTypes.object.isRequired, + comments: PropTypes.arrayOf(PropTypes.object).isRequired, + })).isRequired, // Connection information endpoint: EndpointPropType.isRequired, @@ -95,11 +82,21 @@ export class BarePullRequestDetailView extends React.Component { // Action functions openCommit: PropTypes.func.isRequired, + openReviews: PropTypes.func.isRequired, + switchToIssueish: PropTypes.func.isRequired, destroy: PropTypes.func.isRequired, + reportMutationErrors: PropTypes.func.isRequired, // Item context itemType: ItemTypePropType.isRequired, refEditor: RefHolderPropType.isRequired, + + // Tab management + initChangedFilePath: PropTypes.string, + initChangedFilePosition: PropTypes.number, + selectedTab: PropTypes.number.isRequired, + onTabSelected: PropTypes.func.isRequired, + onOpenFilesTab: PropTypes.func.isRequired, } state = { @@ -136,7 +133,7 @@ export class BarePullRequestDetailView extends React.Component { const onBranch = this.props.checkoutOp.why() === checkoutStates.CURRENT; return ( - + Overview @@ -167,8 +164,12 @@ export class BarePullRequestDetailView extends React.Component { html={pullRequest.bodyHTML || 'No description provided.'} switchToIssueish={this.props.switchToIssueish} /> - - +
- +
{/* commits */} - + {/* files changed */} @@ -197,15 +198,18 @@ export class BarePullRequestDetailView extends React.Component { owner={this.props.repository.owner.login} repo={this.props.repository.name} number={pullRequest.number} - endpoint={this.props.endpoint} token={this.props.token} + reviewCommentsLoading={this.props.reviewCommentsLoading} + reviewCommentThreads={this.props.reviewCommentThreads} + workspace={this.props.workspace} commands={this.props.commands} keymaps={this.props.keymaps} tooltips={this.props.tooltips} config={this.props.config} + workdirPath={this.props.workdirPath} itemType={this.props.itemType} refEditor={this.props.refEditor} @@ -215,6 +219,10 @@ export class BarePullRequestDetailView extends React.Component { switchToIssueish={this.props.switchToIssueish} pullRequest={this.props.pullRequest} + + initChangedFilePath={this.props.initChangedFilePath} + initChangedFilePosition={this.props.initChangedFilePosition} + onOpenFilesTab={this.props.onOpenFilesTab} />
@@ -260,7 +268,7 @@ export class BarePullRequestDetailView extends React.Component { {repo.owner.login}/{repo.name}#{pullRequest.number} - +
@@ -269,60 +277,27 @@ export class BarePullRequestDetailView extends React.Component {
- {this.renderCheckoutButton()} +
{this.renderPullRequestBody(pullRequest)} - - +
); } - renderCheckoutButton() { - const {checkoutOp} = this.props; - let extraClass = null; - let buttonText = 'Checkout'; - let buttonTitle = null; - - if (!checkoutOp.isEnabled()) { - buttonTitle = checkoutOp.getMessage(); - const reason = checkoutOp.why(); - if (reason === checkoutStates.HIDDEN) { - return null; - } - - buttonText = reason.when({ - current: 'Checked out', - default: 'Checkout', - }); - - extraClass = 'github-IssueishDetailView-checkoutButton--' + reason.when({ - disabled: 'disabled', - busy: 'busy', - current: 'current', - }); - } - - const classNames = cx('btn', 'btn-primary', 'github-IssueishDetailView-checkoutButton', extraClass); - return ( - - ); - } - handleRefreshClick = e => { e.preventDefault(); this.refresher.refreshNow(true); @@ -332,13 +307,14 @@ export class BarePullRequestDetailView extends React.Component { addEvent('open-pull-request-in-browser', {package: 'github', component: this.constructor.name}); } - recordOpenTabEvent = ind => { + onTabSelected = index => { + this.props.onTabSelected(index); const eventName = [ 'open-pr-tab-overview', 'open-pr-tab-build-status', 'open-pr-tab-commits', 'open-pr-tab-files-changed', - ][ind]; + ][index]; addEvent(eventName, {package: 'github', component: this.constructor.name}); } @@ -355,10 +331,6 @@ export class BarePullRequestDetailView extends React.Component { timelineCursor: null, commitCount: PAGE_SIZE, commitCursor: null, - reviewCount: PAGE_SIZE, - reviewCursor: null, - commentCount: PAGE_SIZE, - commentCursor: null, }, null, () => { this.setState({refreshing: false}); }, {force: true}); @@ -379,87 +351,60 @@ export default createRefetchContainer(BarePullRequestDetailView, { pullRequest: graphql` fragment prDetailView_pullRequest on PullRequest @argumentDefinitions( - timelineCount: {type: "Int!"}, - timelineCursor: {type: "String"}, - commitCount: {type: "Int!"}, - commitCursor: {type: "String"}, - reviewCount: {type: "Int!"}, - reviewCursor: {type: "String"}, - commentCount: {type: "Int!"}, - commentCursor: {type: "String"}, + timelineCount: {type: "Int!"} + timelineCursor: {type: "String"} + commitCount: {type: "Int!"} + commitCursor: {type: "String"} ) { + id __typename - - ... on Node { - id + url + isCrossRepository + changedFiles + state + number + title + bodyHTML + baseRefName + headRefName + countedCommits: commits { + totalCount } - - ... on PullRequest { - isCrossRepository - changedFiles - - ...prReviewsContainer_pullRequest @arguments( - reviewCount: $reviewCount, - reviewCursor: $reviewCursor, - commentCount: $commentCount, - commentCursor: $commentCursor, - ) - - ...prCommitsView_pullRequest @arguments(commitCount: $commitCount, commitCursor: $commitCursor) - countedCommits: commits { - totalCount - } - ...prStatusesView_pullRequest - state number title bodyHTML baseRefName headRefName - author { - login avatarUrl - ... on User { url } - ... on Bot { url } - } - - ...prTimelineController_pullRequest @arguments(timelineCount: $timelineCount, timelineCursor: $timelineCursor) + author { + login + avatarUrl + url } - ... on UniformResourceLocatable { url } - - ... on Reactable { - reactionGroups { - content users { totalCount } - } - } + ...prCommitsView_pullRequest @arguments(commitCount: $commitCount, commitCursor: $commitCursor) + ...prStatusesView_pullRequest + ...prTimelineController_pullRequest @arguments(timelineCount: $timelineCount, timelineCursor: $timelineCursor) + ...emojiReactionsController_reactable } `, }, graphql` query prDetailViewRefetchQuery ( - $repoId: ID!, - $issueishId: ID!, - $timelineCount: Int!, - $timelineCursor: String, - $commitCount: Int!, - $commitCursor: String, - $reviewCount: Int!, - $reviewCursor: String, - $commentCount: Int!, - $commentCursor: String, + $repoId: ID! + $issueishId: ID! + $timelineCount: Int! + $timelineCursor: String + $commitCount: Int! + $commitCursor: String ) { - repository:node(id: $repoId) { + repository: node(id: $repoId) { ...prDetailView_repository @arguments( - timelineCount: $timelineCount, + timelineCount: $timelineCount timelineCursor: $timelineCursor ) } - pullRequest:node(id: $issueishId) { + pullRequest: node(id: $issueishId) { ...prDetailView_pullRequest @arguments( - timelineCount: $timelineCount, - timelineCursor: $timelineCursor, - commitCount: $commitCount, - commitCursor: $commitCursor, - reviewCount: $reviewCount, - reviewCursor: $reviewCursor, - commentCount: $commentCount, - commentCursor: $commentCursor, + timelineCount: $timelineCount + timelineCursor: $timelineCursor + commitCount: $commitCount + commitCursor: $commitCursor ) } } diff --git a/lib/views/pr-review-comments-view.js b/lib/views/pr-review-comments-view.js deleted file mode 100644 index 636b88d4b1..0000000000 --- a/lib/views/pr-review-comments-view.js +++ /dev/null @@ -1,103 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {Point, Range} from 'atom'; - -import {toNativePathSep} from '../helpers'; -import Marker from '../atom/marker'; -import Decoration from '../atom/decoration'; -import Octicon from '../atom/octicon'; - -import GithubDotcomMarkdown from './github-dotcom-markdown'; -import Timeago from './timeago'; - -export default class PullRequestCommentsView extends React.Component { - static propTypes = { - commentThreads: PropTypes.arrayOf(PropTypes.shape({ - rootCommentId: PropTypes.string.isRequired, - comments: PropTypes.arrayOf(PropTypes.object).isRequired, - })), - getBufferRowForDiffPosition: PropTypes.func.isRequired, - switchToIssueish: PropTypes.func.isRequired, - isPatchVisible: PropTypes.func.isRequired, - } - - render() { - return [...this.props.commentThreads].map(({rootCommentId, comments}) => { - const rootComment = comments[0]; - if (!rootComment.position) { - return null; - } - - // if file patch is collapsed or too large, do not render the comments - if (!this.props.isPatchVisible(rootComment.path)) { - return null; - } - - const nativePath = toNativePathSep(rootComment.path); - const row = this.props.getBufferRowForDiffPosition(nativePath, rootComment.position); - const point = new Point(row, 0); - const range = new Range(point, point); - return ( - - - {comments.map(comment => { - return ( - - ); - })} - - - ); - }); - } -} - -export class PullRequestCommentView extends React.Component { - static propTypes = { - switchToIssueish: PropTypes.func.isRequired, - comment: PropTypes.shape({ - author: PropTypes.shape({ - avatarUrl: PropTypes.string, - login: PropTypes.string, - }), - bodyHTML: PropTypes.string, - url: PropTypes.string, - createdAt: PropTypes.string.isRequired, - isMinimized: PropTypes.bool.isRequired, - }).isRequired, - } - - render() { - if (this.props.comment.isMinimized) { - return ( -
- - - This comment was hidden - -
- ); - } else { - const author = this.props.comment.author; - const login = author ? author.login : 'someone'; - return ( -
-
- {login} - {login} commented{' '} - - - -
-
- -
-
- ); - } - } -} diff --git a/lib/views/reaction-picker-view.js b/lib/views/reaction-picker-view.js new file mode 100644 index 0000000000..fadbda904b --- /dev/null +++ b/lib/views/reaction-picker-view.js @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; + +import {reactionTypeToEmoji} from '../helpers'; + +const CONTENT_TYPES = Object.keys(reactionTypeToEmoji); +const EMOJI_COUNT = CONTENT_TYPES.length; +const EMOJI_PER_ROW = 4; +const EMOJI_ROWS = Math.ceil(EMOJI_COUNT / EMOJI_PER_ROW); + +export default class ReactionPickerView extends React.Component { + static propTypes = { + viewerReacted: PropTypes.arrayOf( + PropTypes.oneOf(Object.keys(reactionTypeToEmoji)), + ), + + // Action methods + addReactionAndClose: PropTypes.func.isRequired, + removeReactionAndClose: PropTypes.func.isRequired, + } + + render() { + const viewerReactedSet = new Set(this.props.viewerReacted); + + const emojiRows = []; + for (let row = 0; row < EMOJI_ROWS; row++) { + const emojiButtons = []; + + for (let column = 0; column < EMOJI_PER_ROW; column++) { + const emojiIndex = row * EMOJI_PER_ROW + column; + + /* istanbul ignore if */ + if (emojiIndex >= CONTENT_TYPES.length) { + break; + } + + const content = CONTENT_TYPES[emojiIndex]; + + const toggle = !viewerReactedSet.has(content) + ? () => this.props.addReactionAndClose(content) + : () => this.props.removeReactionAndClose(content); + + const className = cx( + 'github-ReactionPicker-reaction', + 'btn', + {selected: viewerReactedSet.has(content)}, + ); + + emojiButtons.push( + , + ); + } + + emojiRows.push(

{emojiButtons}

); + } + + return ( +
+ {emojiRows} +
+ ); + } +} diff --git a/lib/views/reviews-footer-view.js b/lib/views/reviews-footer-view.js new file mode 100644 index 0000000000..d727014125 --- /dev/null +++ b/lib/views/reviews-footer-view.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import {addEvent} from '../reporter-proxy'; + +export default class ReviewsFooterView extends React.Component { + static propTypes = { + commentsResolved: PropTypes.number.isRequired, + totalComments: PropTypes.number.isRequired, + pullRequestURL: PropTypes.string.isRequired, + + // Controller actions + openReviews: PropTypes.func.isRequired, + }; + + logStartReviewClick = () => { + addEvent('start-pr-review', {package: 'github', component: this.constructor.name}); + } + + render() { + return ( +
+ + Reviews + + + + Resolved{' '} + + {this.props.commentsResolved} + + {' '}of{' '} + + {this.props.totalComments} + {' '}comments + + + {' '}comments{' '} + + + + + Start a new review + +
+ ); + } +} diff --git a/lib/views/reviews-view.js b/lib/views/reviews-view.js new file mode 100644 index 0000000000..7d52746b3e --- /dev/null +++ b/lib/views/reviews-view.js @@ -0,0 +1,607 @@ +import path from 'path'; +import React, {Fragment} from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import {CompositeDisposable} from 'event-kit'; + +import {EnableableOperationPropType} from '../prop-types'; +import Tooltip from '../atom/tooltip'; +import Commands, {Command} from '../atom/commands'; +import AtomTextEditor from '../atom/atom-text-editor'; +import {getDataFromGithubUrl} from './issueish-link'; +import EmojiReactionsController from '../controllers/emoji-reactions-controller'; +import {checkoutStates} from '../controllers/pr-checkout-controller'; +import GithubDotcomMarkdown from './github-dotcom-markdown'; +import PatchPreviewView from './patch-preview-view'; +import CheckoutButton from './checkout-button'; +import Timeago from './timeago'; +import Octicon from '../atom/octicon'; +import RefHolder from '../models/ref-holder'; +import {toNativePathSep} from '../helpers'; +import {addEvent} from '../reporter-proxy'; + +export default class ReviewsView extends React.Component { + static propTypes = { + // Relay results + relay: PropTypes.shape({ + environment: PropTypes.object.isRequired, + }).isRequired, + repository: PropTypes.object.isRequired, + pullRequest: PropTypes.object.isRequired, + summaries: PropTypes.array.isRequired, + commentThreads: PropTypes.arrayOf(PropTypes.shape({ + thread: PropTypes.object.isRequired, + comments: PropTypes.arrayOf(PropTypes.object).isRequired, + })), + refetch: PropTypes.func.isRequired, + + // Package models + multiFilePatch: PropTypes.object.isRequired, + contextLines: PropTypes.number.isRequired, + checkoutOp: EnableableOperationPropType.isRequired, + summarySectionOpen: PropTypes.bool.isRequired, + commentSectionOpen: PropTypes.bool.isRequired, + threadIDsOpen: PropTypes.shape({ + has: PropTypes.func.isRequired, + }), + postingToThreadID: PropTypes.string, + scrollToThreadID: PropTypes.string, + // Structure: Map< relativePath: String, { + // rawPositions: Set, + // diffToFilePosition: Map, + // fileTranslations: null | Map, + // digest: String, + // }> + commentTranslations: PropTypes.object, + + // for the dotcom link in the empty state + number: PropTypes.number.isRequired, + repo: PropTypes.string.isRequired, + owner: PropTypes.string.isRequired, + workdir: PropTypes.string.isRequired, + + // Atom environment + workspace: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + tooltips: PropTypes.object.isRequired, + + // Action methods + openFile: PropTypes.func.isRequired, + openDiff: PropTypes.func.isRequired, + openPR: PropTypes.func.isRequired, + moreContext: PropTypes.func.isRequired, + lessContext: PropTypes.func.isRequired, + openIssueish: PropTypes.func.isRequired, + showSummaries: PropTypes.func.isRequired, + hideSummaries: PropTypes.func.isRequired, + showComments: PropTypes.func.isRequired, + hideComments: PropTypes.func.isRequired, + showThreadID: PropTypes.func.isRequired, + hideThreadID: PropTypes.func.isRequired, + resolveThread: PropTypes.func.isRequired, + unresolveThread: PropTypes.func.isRequired, + addSingleComment: PropTypes.func.isRequired, + reportMutationErrors: PropTypes.func.isRequired, + } + + constructor(props) { + super(props); + + this.rootHolder = new RefHolder(); + this.replyHolders = new Map(); + this.threadHolders = new Map(); + this.state = { + isRefreshing: false, + }; + this.subs = new CompositeDisposable(); + } + + componentDidMount() { + const {scrollToThreadID} = this.props; + if (scrollToThreadID) { + const threadHolder = this.threadHolders.get(scrollToThreadID); + if (threadHolder) { + threadHolder.map(element => { + element.scrollIntoViewIfNeeded(); + return null; // shh, eslint + }); + } + } + } + + componentDidUpdate(prevProps) { + const {scrollToThreadID} = this.props; + if (scrollToThreadID && scrollToThreadID !== prevProps.scrollToThreadID) { + const threadHolder = this.threadHolders.get(scrollToThreadID); + if (threadHolder) { + threadHolder.map(element => { + element.scrollIntoViewIfNeeded(); + return null; // shh, eslint + }); + } + } + } + + componentWillUnmount() { + this.subs.dispose(); + } + + render() { + return ( +
+ {this.renderCommands()} + {this.renderHeader()} +
+ {this.renderReviewSummaries()} + {this.renderReviewCommentThreads()} +
+
+ ); + } + + renderCommands() { + return ( + + + + + + + + + + ); + } + + renderHeader() { + const refresh = () => { + if (this.state.isRefreshing) { + return; + } + this.setState({isRefreshing: true}); + const sub = this.props.refetch(() => { + this.subs.remove(sub); + this.setState({isRefreshing: false}); + }); + this.subs.add(sub); + }; + return ( +
+ + + Reviews for  + + {this.props.owner}/{this.props.repo}#{this.props.number} + + +
+ ); + } + + logStartReviewClick = () => { + addEvent('start-pr-review', {package: 'github', component: this.constructor.name}); + } + + renderEmptyState() { + const {number, repo, owner} = this.props; + // todo: make this open the review flow in Atom instead of dotcom + const pullRequestURL = `https://www.github.com/${owner}/${repo}/pull/${number}/files/`; + return ( +
+ Mona the octocat in spaaaccee +
+ This pull request has no reviews +
+ +
+ ); + } + + renderReviewSummaries() { + if (this.props.summaries.length === 0) { + return this.renderEmptyState(); + } + + const toggle = evt => { + evt.preventDefault(); + if (this.props.summarySectionOpen) { + this.props.hideSummaries(); + } else { + this.props.showSummaries(); + } + }; + + return ( +
+ + + Summaries + +
+ {this.props.summaries.map(this.renderReviewSummary)} +
+ +
+ ); + } + + renderReviewSummary = review => { + const reviewTypes = type => { + return { + APPROVED: {icon: 'icon-check', copy: 'approved these changes'}, + COMMENTED: {icon: 'icon-comment', copy: 'commented'}, + CHANGES_REQUESTED: {icon: 'icon-alert', copy: 'requested changes'}, + }[type] || {icon: '', copy: ''}; + }; + + const {icon, copy} = reviewTypes(review.state); + + // filter non actionable empty summary comments from this view + if (review.state === 'PENDING' || (review.state === 'COMMENTED' && review.bodyHTML === '')) { + return null; + } + + const reviewAuthor = review.author ? review.author.login : ''; + return ( +
+
+ + {reviewAuthor} + {reviewAuthor} + {copy} + +
+
+ + +
+
+ ); + } + + renderReviewCommentThreads() { + const commentThreads = this.props.commentThreads; + if (commentThreads.length === 0) { + return null; + } + + const resolvedThreads = commentThreads.filter(pair => pair.thread.isResolved).length; + + const toggleComments = evt => { + evt.preventDefault(); + if (this.props.commentSectionOpen) { + this.props.hideComments(); + } else { + this.props.showComments(); + } + }; + + return ( +
+ + + Comments + + + Resolved + {' '}{resolvedThreads}{' '} + of + {' '}{commentThreads.length} + + + + +
+ {commentThreads.map(this.renderReviewCommentThread)} +
+ +
+ ); + } + + renderReviewCommentThread = commentThread => { + const {comments, thread} = commentThread; + const rootComment = comments[0]; + if (!rootComment) { + return null; + } + + let threadHolder = this.threadHolders.get(thread.id); + if (!threadHolder) { + threadHolder = new RefHolder(); + this.threadHolders.set(thread.id, threadHolder); + } + + const nativePath = toNativePathSep(rootComment.path); + const {dir, base} = path.parse(nativePath); + const {lineNumber, positionText} = this.getTranslatedPosition(rootComment); + + const refJumpToFileButton = new RefHolder(); + const jumpToFileDisabledLabel = 'Checkout this pull request to enable Jump To File.'; + + const elementId = `review-thread-${thread.id}`; + + const navButtonClasses = ['github-Review-navButton', 'icon', {outdated: !lineNumber}]; + const openFileClasses = cx('icon-code', ...navButtonClasses); + const openDiffClasses = cx('icon-diff', ...navButtonClasses); + + const isOpen = this.props.threadIDsOpen.has(thread.id); + const isHighlighted = this.props.scrollToThreadID === thread.id; + const toggle = evt => { + evt.preventDefault(); + evt.stopPropagation(); + + if (isOpen) { + this.props.hideThreadID(thread.id); + } else { + this.props.showThreadID(thread.id); + } + }; + + return ( +
+ + + {dir && {dir}} + {dir ? path.sep : ''}{base} + {positionText} + {rootComment.author.login} + + + + + {rootComment.position !== null && ( + + )} + + {this.renderThread({thread, comments})} + +
+ ); + } + + renderThread = ({thread, comments}) => { + let replyHolder = this.replyHolders.get(thread.id); + if (!replyHolder) { + replyHolder = new RefHolder(); + this.replyHolders.set(thread.id, replyHolder); + } + + const lastComment = comments[comments.length - 1]; + const isPosting = this.props.postingToThreadID !== null; + + return ( + +
+ + {comments.map(this.renderComment)} + +
+ + + +
+
+ {thread.isResolved &&
+ This conversation was marked as resolved by @{thread.resolvedBy.login} +
} +
+ + {this.renderResolveButton(thread)} +
+
+ ); + } + + renderResolveButton = thread => { + if (thread.isResolved) { + return ( + + ); + } else { + return ( + + ); + } + } + + renderComment = comment => { + if (comment.isMinimized) { + return ( +
+ + This comment was hidden +
+ ); + } + + const commentClass = cx('github-Review-comment', {'github-Review-comment--pending': comment.state === 'PENDING'}); + return ( +
+
+
+ {comment.author.login} + + {comment.author.login} + + + + + {comment.state === 'PENDING' && ( + pending + )} +
+ + + +
+
+ + +
+
+ ); + } + + openFile = evt => { + if (!this.props.checkoutOp.isEnabled()) { + const target = evt.currentTarget; + this.props.openFile(target.dataset.path, target.dataset.line); + } + } + + openDiff = evt => { + const target = evt.currentTarget; + this.props.openDiff(target.dataset.path, parseInt(target.dataset.line, 10)); + } + + openIssueishLinkInNewTab = evt => { + const {repoOwner, repoName, issueishNumber} = getDataFromGithubUrl(evt.target.dataset.url); + return this.props.openIssueish(repoOwner, repoName, issueishNumber); + } + + submitReply(replyHolder, thread, lastComment) { + const body = replyHolder.map(editor => editor.getText()).getOr(''); + const didSubmitComment = () => replyHolder.map(editor => editor.setText('', {bypassReadOnly: true})); + const didFailComment = () => replyHolder.map(editor => editor.setText(body, {bypassReadOnly: true})); + + return this.props.addSingleComment( + body, thread.id, lastComment.id, lastComment.path, lastComment.position, {didSubmitComment, didFailComment}, + ); + } + + submitCurrentComment = evt => { + const threadID = evt.currentTarget.dataset.threadId; + /* istanbul ignore if */ + if (!threadID) { + return null; + } + + const {thread, comments} = this.props.commentThreads.find(each => each.thread.id === threadID); + const replyHolder = this.replyHolders.get(threadID); + + return this.submitReply(replyHolder, thread, comments[comments.length - 1]); + } + + getTranslatedPosition(rootComment) { + let lineNumber, positionText; + const translations = this.props.commentTranslations; + + const isCheckedOutPullRequest = this.props.checkoutOp.why() === checkoutStates.CURRENT; + if (translations === null) { + lineNumber = null; + positionText = ''; + } else if (rootComment.position === null) { + lineNumber = null; + positionText = 'outdated'; + } else { + const translationsForFile = translations.get(rootComment.path); + lineNumber = translationsForFile.diffToFilePosition.get(parseInt(rootComment.position, 10)); + if (translationsForFile.fileTranslations && isCheckedOutPullRequest) { + lineNumber = translationsForFile.fileTranslations.get(lineNumber).newPosition; + } + positionText = lineNumber; + } + + return {lineNumber, positionText}; + } +} diff --git a/menus/git.cson b/menus/git.cson index 5986cccf54..a70274779f 100644 --- a/menus/git.cson +++ b/menus/git.cson @@ -10,6 +10,10 @@ 'label': 'Toggle GitHub Tab' 'command': 'github:toggle-github-tab' } + { + 'label': 'Open Reviews Tab' + 'command': 'github:open-reviews-tab' + } ] } { @@ -26,6 +30,10 @@ 'label': 'Toggle GitHub Tab' 'command': 'github:toggle-github-tab' } + { + 'label': 'Open Reviews Tab' + 'command': 'github:open-reviews-tab' + } ] } ] diff --git a/package-lock.json b/package-lock.json index dc27f5c67b..1eeda05f4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7770,7 +7770,7 @@ }, "string-width": { "version": "1.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "requires": { "code-point-at": "^1.0.0", @@ -7857,6 +7857,14 @@ } } }, + "superstring": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/superstring/-/superstring-2.3.6.tgz", + "integrity": "sha512-kDTXCXArhHL1lRk2zBW7ByRJByqVwoLK3E3jlf8+LcwQLZgSMs9dwrDHDpBdoOm89kstSBSrGcW8OJqNkxjWrQ==", + "requires": { + "nan": "^2.10.0" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -7885,7 +7893,7 @@ }, "is-fullwidth-code-point": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "resolved": "", "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", "dev": true }, @@ -8412,6 +8420,16 @@ "split": "^1.0.0" } }, + "whats-my-line": { + "version": "0.1.1-0", + "resolved": "https://registry.npmjs.org/whats-my-line/-/whats-my-line-0.1.1-0.tgz", + "integrity": "sha512-wuHvdWG/Rlt4pqYL5ymyYnK+rolVa0fTjrCGq/dK3u+yF1qkBKVyI5zGSCaF+zeOx6VjlXrNKewStlj2iInY8A==", + "requires": { + "dugite": "^1.86.0", + "superstring": "^2.3.6", + "what-the-diff": "^0.6.0" + } + }, "whatwg-fetch": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", diff --git a/package.json b/package.json index b55044af38..017b0d8613 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "underscore-plus": "1.6.8", "what-the-diff": "0.6.0", "what-the-status": "1.0.3", + "whats-my-line": "0.1.1-0", "yubikiri": "2.0.0" }, "devDependencies": { @@ -93,6 +94,7 @@ "eslint-plugin-jsx-a11y": "6.2.1", "globby": "9.1.0", "hock": "1.3.3", + "lodash.isequal": "4.5.0", "lodash.isequalwith": "4.4.0", "mkdirp": "0.5.1", "mocha": "6.0.2", @@ -204,7 +206,8 @@ "GithubDockItem": "createDockItemStub", "FilePatchControllerStub": "createFilePatchControllerStub", "CommitPreviewStub": "createCommitPreviewStub", - "CommitDetailStub": "createCommitDetailStub" + "CommitDetailStub": "createCommitDetailStub", + "ReviewsStub": "createReviewsStub" }, "greenkeeper": { "ignore": [ diff --git a/script/azure-pipelines/macos-install.yml b/script/azure-pipelines/macos-install.yml index 31e181cfc8..afbbf2e4f2 100644 --- a/script/azure-pipelines/macos-install.yml +++ b/script/azure-pipelines/macos-install.yml @@ -4,7 +4,6 @@ parameters: steps: - bash: | - set -x curl -s -L "https://atom.io/download/mac?channel=${ATOM_CHANNEL}" \ -H 'Accept: application/octet-stream' \ -o "atom.zip" diff --git a/styles/accordion.less b/styles/accordion.less index 683b4562cf..8444b46f51 100644 --- a/styles/accordion.less +++ b/styles/accordion.less @@ -9,12 +9,18 @@ color: @text-color-subtle; padding: @component-padding/2; border-bottom: 1px solid @base-border-color; + display: flex; + align-items: center; &::-webkit-details-marker { color: @text-color-subtle; } } + &--rightTitle, &--leftTitle { + flex: 1; + } + &-content { border-bottom: 1px solid @base-border-color; background-color: @base-background-color; diff --git a/styles/editor-comment.less b/styles/editor-comment.less new file mode 100644 index 0000000000..2db98265c4 --- /dev/null +++ b/styles/editor-comment.less @@ -0,0 +1,38 @@ +@import "ui-variables.less"; +@import "syntax-variables.less"; + +@gh-comment-line: mix(@background-color-info, @syntax-background-color, 20%); +@gh-comment-line-cursor: mix(@background-color-info, @syntax-selection-color, 40%); + +atom-text-editor { + .line.github-editorCommentHighlight { + background-color: @gh-comment-line; + + &.cursor-line { + background-color: @gh-comment-line-cursor; + } + } +} + +.gutter[gutter-name="github-comment-icon"] { + width: 1.4em; +} + +.github-editorCommentGutterIcon { + &.decoration { + width: 100%; + } + + &.react-atom-decoration { + width: 100%; + text-align: center; + padding: 0 @component-padding/4; + } + + button { + padding: 0; + border: none; + background: none; + vertical-align: middle; + } +} diff --git a/styles/emoji-reactions.less b/styles/emoji-reactions.less new file mode 100644 index 0000000000..3f9984e6fb --- /dev/null +++ b/styles/emoji-reactions.less @@ -0,0 +1,21 @@ +.github-EmojiReactions { + &-add.btn.btn { + color: @text-color-subtle; + border-color: transparent; + background: none; + + &:first-child { + border-color: transparent; + } + + &:focus.btn { + border-color: transparent; + box-shadow: none; + color: @text-color-info; + } + + &:hover { + color: lighten(@text-color-subtle, 10%); + } + } +} diff --git a/styles/file-patch-view.less b/styles/file-patch-view.less index 790981752d..f35f79c005 100644 --- a/styles/file-patch-view.less +++ b/styles/file-patch-view.less @@ -310,10 +310,14 @@ .gutter { &.old .line-number, &.new .line-number, - &.icons .line-number { + &.icons .line-number, + &[gutter-name="github-comment-icon"] .github-editorCommentGutterIcon { &.github-FilePatchView-line--selected { color: @text-color-selected; background: @background-color-selected; + &.github-editorCommentGutterIcon.empty { + z-index: -1; + } } } } @@ -330,10 +334,14 @@ .gutter { &.old .line-number, &.new .line-number, - &.icons .line-number { + &.icons .line-number, + &[gutter-name="github-comment-icon"] .github-editorCommentGutterIcon { &.github-FilePatchView-line--selected { color: contrast(@button-background-color-selected); background: @button-background-color-selected; + &.github-editorCommentGutterIcon.empty { + z-index: -1; + } } } } diff --git a/styles/github-dotcom-markdown.less b/styles/github-dotcom-markdown.less index 67f8e0e8f0..d2eafebcd7 100644 --- a/styles/github-dotcom-markdown.less +++ b/styles/github-dotcom-markdown.less @@ -184,3 +184,12 @@ } } + +.github-EmojiReactions { + &:empty { + display: none; + } + &Group { + margin-right: @component-padding*2; + } +} diff --git a/styles/issueish-detail-view.less b/styles/issueish-detail-view.less index 704b4de843..7e46a6fece 100644 --- a/styles/issueish-detail-view.less +++ b/styles/issueish-detail-view.less @@ -215,14 +215,8 @@ } - &-reactions { + .github-EmojiReactions { padding: 0 @component-padding*2 @component-padding @component-padding*2; - &:empty { - display: none; - } - &Group { - margin-right: @component-padding*2; - } } // Build Status diff --git a/styles/reaction-picker.less b/styles/reaction-picker.less new file mode 100644 index 0000000000..720eabbb5d --- /dev/null +++ b/styles/reaction-picker.less @@ -0,0 +1,13 @@ +.github-ReactionPicker { + display: flex; + flex-direction: column; + + &-row { + flex-direction: row; + margin: @component-padding/4 0; + } + + &-reaction { + margin: 0 @component-padding/4; + } +} diff --git a/styles/review.less b/styles/review.less new file mode 100644 index 0000000000..28e098cf6b --- /dev/null +++ b/styles/review.less @@ -0,0 +1,463 @@ +@import "variables"; + +@avatar-size: 16px; +@font-size-default: @font-size; +@font-size-body: @font-size - 1px; +@nav-size: 2.5em; +@background-color: mix(@base-background-color, @tool-panel-background-color, 66%); + +// Reviews ---------------------------------------------- + +.github-Reviews, .github-StubItem-github-reviews { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: hidden; + height: 100%; + + // Top Header ------------------------------------------ + + &-topHeader { + display: flex; + align-items: center; + padding: 0 @component-padding; + font-weight: 600; + border-bottom: 1px solid @panel-heading-border-color; + cursor: default; + color: @text-color-subtle; + + .icon:before { + margin-right: @component-padding / 1.2; + } + + .icon.refreshing::before { + @keyframes github-Reviews-refreshButtonAnimation { + 100% { transform: rotate(360deg); } + } + animation: github-Reviews-refreshButtonAnimation 2s linear 30; // limit to 1min in case something gets stuck + } + } + + &-headerTitle { + flex: 1; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + &-clickable { + cursor: pointer; + font-weight: 400; + &:hover { + color: @text-color-highlight; + text-decoration: underline; + } + &.icon:hover:before { + color: @text-color-highlight; + } + } + + &-headerButton { + height: 3em; + border: none; + padding: 0; + font-weight: 600; + background: none; + } + + // Reviews sections ------------------------------------ + + &-list { + flex: 1 1 0; + overflow: auto; + } + + &-section { + border-bottom: 1px solid @base-border-color; + } + + &-header { + display: flex; + align-items: center; + padding: @component-padding; + cursor: default; + } + + &-title { + flex: 1; + font-size: 1em; + margin: 0; + font-weight: 600; + color: @text-color-highlight; + } + + &-count { + color: @text-color-subtle; + } + + &-countNr { + color: @text-color-highlight; + } + + &-progessBar { + width: 5em; + margin-left: @component-padding; + } + + &-container { + font-size: @font-size-default; + padding: @component-padding; + padding-top: 0; + } + + .github-EmojiReactions { + padding: @component-padding 0 0; + } + + // empty states + &-emptyImg { + padding: 1em; + } + + &-emptyText { + font-size: 1.5em; + text-align: center; + } + + &-emptyCallToActionButton { + margin-left: auto; + margin-right: auto; + margin-top: 2em; + display: block; + a:hover { + text-decoration: none; + } + } +} + +// Review Summaries ------------------------------------------ + +.github-ReviewSummary { + padding: @component-padding; + border: 1px solid @base-border-color; + border-radius: @component-border-radius; + background-color: @background-color; + + & + & { + margin-top: @component-padding; + } + + &-header { + display: flex; + } + + &-icon { + vertical-align: -3px; + &.icon-check { + color: @text-color-success; + } + &.icon-alert { + color: @text-color-warning; + } + &.icon-comment { + color: @text-color-subtle; + } + } + + &-avatar { + margin-right: .5em; + width: @avatar-size; + height: @avatar-size; + border-radius: @component-border-radius; + image-rendering: -webkit-optimize-contrast; + } + + &-username { + margin-right: .3em; + font-weight: 500; + } + + &-type { + color: @text-color-subtle; + } + + &-timeAgo { + margin-left: auto; + color: @text-color-subtle; + } + + &-comment { + position: relative; + margin-top: @component-padding/2; + margin-left: 7px; + padding-left: 12px; + font-size: @font-size-body; + border-left: 2px solid @base-border-color; + } +} + + +// Review Comments ---------------------------------------------- + +.github-Review { + position: relative; + border: 1px solid @base-border-color; + border-radius: @component-border-radius; + background-color: @background-color; + box-shadow: 0; + transition: border-color 0.5s, box-shadow 0.5s; + + & + & { + margin-top: @component-padding; + } + + &--highlight { + border-color: @button-background-color-selected; + box-shadow: 0 0 5px @button-background-color-selected; + } + + // Header ------------------ + + &-reference { + display: flex; + align-items: center; + padding-left: @component-padding; + height: @nav-size; + cursor: default; + + &::-webkit-details-marker { + margin-right: @component-padding/1.5; + } + + .resolved & { + opacity: 0.5; + } + } + + &-path { + color: @text-color-subtle; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + pointer-events: none; + } + + &-file { + color: @text-color; + margin-right: @component-padding/2; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + pointer-events: none; + } + + &-lineNr { + color: @text-color-subtle; + margin-right: @component-padding/2; + } + + &-referenceAvatar { + margin-left: auto; + margin-right: @component-padding/2; + width: @avatar-size; + height: @avatar-size; + border-radius: @component-border-radius; + image-rendering: -webkit-optimize-contrast; + + .github-Review[open] & { + display: none; + } + } + + &-referenceTimeAgo { + color: @text-color-subtle; + margin-right: @component-padding/2; + .github-Review[open] & { + display: none; + } + } + + &-nav { + display: none; + .github-Review[open] & { + display: flex; + } + border-top: 1px solid @base-border-color; + } + + &-navButton { + height: @nav-size; + padding: 0; + border: none; + + background-color: transparent; + cursor: default; + flex-basis: 50%; + + &.outdated { + border-bottom: 1px solid @base-border-color; + } + &:last-child { + border-left: 1px solid @base-border-color; + } + &:hover { + background-color: @background-color-highlight; + } + &:active { + background-color: @background-color-selected; + } + &[disabled] { + opacity: 0.65; + cursor: not-allowed; + &:hover { + background-color: transparent; + } + } + } + + + // Diff ------------------ + + atom-text-editor { + padding: @component-padding/2; + border-top: 1px solid @base-border-color; + border-bottom: 1px solid @base-border-color; + } + + &-diffLine { + padding: 0 @component-padding/2; + line-height: 1.75; + + &:last-child { + font-weight: 500; + } + + &::before { + content: " "; + margin-right: .5em; + } + + &.is-added { + color: mix(@text-color-success, @text-color-highlight, 25%); + background-color: mix(@background-color-success, @base-background-color, 20%); + &::before { + content: "+"; + } + } + &.is-deleted { + color: mix(@text-color-error, @text-color-highlight, 25%); + background-color: mix(@background-color-error, @base-background-color, 20%); + &::before { + content: "-"; + } + } + } + + &-diffLineIcon { + margin-right: .5em; + } + + + // Comments ------------------ + + &-comments { + padding: @component-padding; + padding-left: @component-padding + @avatar-size + 5px; // space for avatar + } + + &-comment { + margin-bottom: @component-padding*1.5; + + &--hidden { + color: @text-color-subtle; + display: flex; + align-items: center; + } + } + + &-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: @component-padding/4; + } + + &-avatar { + position: absolute; + left: @component-padding; + margin-right: .5em; + width: @avatar-size; + height: @avatar-size; + border-radius: @component-border-radius; + image-rendering: -webkit-optimize-contrast; + } + + &-username { + margin-right: .5em; + font-weight: 500; + } + + &-timeAgo, &-reportAbuseLink { + color: @text-color-subtle; + } + + &-pendingBadge { + margin-left: @component-padding; + } + + &-text { + font-size: @font-size-body; + + a { + font-weight: 500; + } + } + + &-reply { + margin-top: @component-padding; + + atom-text-editor { + width: 100%; + min-height: 2.125em; + border: 1px solid @base-border-color; + } + + &--disabled { + atom-text-editor { + color: @text-color-subtle; + } + } + } + + &-replyButton { + margin-left: @avatar-size + 5px; // match left position of the comment editor + } + + &-resolvedText { + text-align: center; + margin-bottom: @component-padding; + padding: 0 2*@component-padding; + } + + + // Footer ------------------ + + &-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: @component-padding; + border-top: 1px solid @base-border-color; + } + + &-resolveButton { + margin-left: auto; + &.btn.icon { + &:before { + font-size: 14px; + } + } + } + +} diff --git a/styles/reviews-footer-view.less b/styles/reviews-footer-view.less new file mode 100644 index 0000000000..4a3179b19b --- /dev/null +++ b/styles/reviews-footer-view.less @@ -0,0 +1,39 @@ +@import 'variables'; + +.github-ReviewsFooterView { + + margin-right: auto; + + // Footer ------------------------ + + &-footer { + display: flex; + align-items: center; + padding: @component-padding; + border-top: 1px solid @base-border-color; + background-color: @app-background-color; + } + + &-footerTitle { + font-size: 1.4em; + margin: 0 @component-padding*2 0 0; + } + + &-openReviewsButton, + &-reviewChangesButton { + margin-left: @component-padding; + } + + &-commentCount { + margin-right: @component-padding; + color: @text-color-subtle; + } + + &-commentsResolved { + color: @text-color-highlight; + } + + &-progessBar { + margin-right: @component-padding; + } +} diff --git a/test/atom/decoration.test.js b/test/atom/decoration.test.js index 011f3b267f..b31b040b5e 100644 --- a/test/atom/decoration.test.js +++ b/test/atom/decoration.test.js @@ -81,9 +81,10 @@ describe('Decoration', function() { assert.equal(child.textContent, 'This is a subtree'); }); - it('creates a gutter decoration', function() { + it('decorates a gutter after it has been created in the editor', function() { + const gutterName = 'rubin-starset-memorial-gutter'; const app = ( - +
This is a subtree
@@ -91,13 +92,52 @@ describe('Decoration', function() { ); mount(app); + // the gutter has not been created yet at this point + assert.isNull(editor.gutterWithName(gutterName)); + assert.isFalse(editor.decorateMarker.called); + + editor.addGutter({name: gutterName}); + + assert.isTrue(editor.decorateMarker.called); const args = editor.decorateMarker.firstCall.args; assert.equal(args[0], marker); assert.equal(args[1].type, 'gutter'); + assert.equal(args[1].gutterName, 'rubin-starset-memorial-gutter'); const child = args[1].item.getElement().firstElementChild; assert.equal(child.className, 'decoration-subtree'); assert.equal(child.textContent, 'This is a subtree'); }); + + it('does not decorate a non-existent gutter', function() { + const gutterName = 'rubin-starset-memorial-gutter'; + const app = ( + +
+ This is a subtree +
+
+ ); + mount(app); + + assert.isNull(editor.gutterWithName(gutterName)); + assert.isFalse(editor.decorateMarker.called); + + editor.addGutter({name: 'another-gutter-name'}); + + assert.isFalse(editor.decorateMarker.called); + assert.isNull(editor.gutterWithName(gutterName)); + }); + + it('throws an error if `gutterName` prop is not supplied for gutter decorations', function() { + const app = ( + +
+ This is a subtree +
+
+ ); + assert.throws(() => mount(app), 'You are trying to decorate a gutter but did not supply gutterName prop.'); + }); }); describe('when props update', function() { @@ -181,7 +221,7 @@ describe('Decoration', function() { assert.lengthOf(editor.getLineDecorations({class: 'whatever'}), 0); }); - it('decorates a parent Marker', function() { + it('decorates a parent Marker on a parent TextEditor', function() { const wrapper = mount( @@ -194,15 +234,48 @@ describe('Decoration', function() { assert.lengthOf(theEditor.getLineDecorations({position: 'head', class: 'whatever'}), 1); }); - it('decorates a parent MarkerLayer', function() { - mount( + it('decorates a parent MarkerLayer on a parent TextEditor', function() { + let layerID = null; + const wrapper = mount( - + { layerID = id; }}> , ); + const theEditor = wrapper.instance().getModel(); + const theLayer = theEditor.getMarkerLayer(layerID); + + const layerMap = theEditor.decorationManager.layerDecorationsByMarkerLayer; + const decorationSet = layerMap.get(theLayer); + assert.strictEqual(decorationSet.size, 1); + }); + + it('decorates a parent Marker on a prop-provided TextEditor', function() { + mount( + + + , + ); + + assert.lengthOf(editor.getLineDecorations({class: 'something'}), 1); + }); + + it('decorates a parent MarkerLayer on a prop-provided TextEditor', function() { + let layerID = null; + mount( + { layerID = id; }}> + + + , + ); + + const theLayer = editor.getMarkerLayer(layerID); + + const layerMap = editor.decorationManager.layerDecorationsByMarkerLayer; + const decorationSet = layerMap.get(theLayer); + assert.strictEqual(decorationSet.size, 1); }); it('does not attempt to decorate a destroyed Marker', function() { diff --git a/test/atom/pane-item.test.js b/test/atom/pane-item.test.js index 388fb9cff8..cbdddd1364 100644 --- a/test/atom/pane-item.test.js +++ b/test/atom/pane-item.test.js @@ -274,6 +274,25 @@ describe('PaneItem', function() { assert.strictEqual(stub.getText(), '10'); }); + it('passes additional props from the stub to the real component', function() { + const extra = Symbol('extra'); + const stub = StubItem.create( + 'some-component', + {title: 'Component', extra}, + 'atom-github://pattern/root/10', + ); + workspace.getActivePane().addItem(stub); + + const wrapper = mount( + + {({itemHolder, deserialized}) => } + , + ); + + assert.lengthOf(wrapper.find('Component'), 1); + assert.strictEqual(wrapper.find('Component').prop('extra'), extra); + }); + it('adds a CSS class to the stub root', function() { const stub = StubItem.create( 'some-component', diff --git a/test/builder/graphql/README.md b/test/builder/graphql/README.md new file mode 100644 index 0000000000..9bbc5d3d95 --- /dev/null +++ b/test/builder/graphql/README.md @@ -0,0 +1,371 @@ +# GraphQL Builders + +Consistently mocking the results from a GraphQL query fragment is difficult and error-prone. To help, these specialized builders use Relay-generated modules to ensure that the mock data we're constructing in our tests accurately reflects the shape of the real props that Relay will provide us at runtime. + +## Using these builders in tests + +GraphQL builders are intended to be used when you're writing tests for a component that's wrapped in a [Relay fragment container](https://facebook.github.io/relay/docs/en/fragment-container.html). Here's an example component we can use: + +```js +import {React} from 'react'; +import {createFragmentContainer, graphql} from 'react-relay'; + +export class BareUserView extends React.Component { + render() { + return ( +
+

ID: {this.props.user.id}

+

Login: {this.props.user.login}

+

Real name: {this.props.user.realName}

+

Role: {this.props.role.displayName}

+ {this.props.permissions.edges.map(e => ( +

Permission: {p.displayName}

+ ))} +
+ ) + } +} + +export default createFragmentContainer(BareUserView, { + user: graphql` + fragment userView_user on User { + id + login + realName + } + `, + role: graphql` + fragment userView_role on Role { + displayName + internalName + permissions { + edge { + node { + id + displayName + aliasedField: userCount + } + } + } + } + `, +}) +``` + +Begin by locating the generated `*.graphql.js` files that correspond to the fragments the component is requesting. These should be located within a `__generated__` directory that's a sibling to the component source, with a filename based on the fragment name. In this case, they would be called `./__generated__/userView_user.graphql.js` and `./__generated__/userView_role.graphql.js`. + +In your test file, import each of these modules, as well as the builder method corresponding to each fragment's root type: + +```js +import {userBuilder} from '../builders/graphql/user'; +import {roleBuilder} from '../builders/graphql/role'; + +import userQuery from '../../lib/views/__generated__/userView_user.graphql'; +import roleQuery from '../../lib/views/__generated__/userView_role.graphql'; +``` + +Now, when writing your test cases, call the builder method with the corresponding query to create a builder object. The builder has accessor methods corresponding to each property requested in the fragment. Set only the fields that you care about in that specific test, then call `.build()` to construct a prop ready to pass to your component. + +```js +it('shows the user correctly', function() { + // Scalar fields have accessors that accept their value directly. + const user = userBuilder(userQuery) + .login('the login') + .realName('Real Name') + .build(); + + const wrapper = shallow( + , + ); + + // Assert that "the login" and "Real Name" show up in the right bits. +}); + +it('shows the role permissions', function() { + // Linked fields have accessors that accept a block, which is called with a builder instance configured for the + // linked type. + const role = roleBuilder(roleQuery) + .displayName('The Role') + .permissions(conn => { + conn.addEdge(e => e.node(p => p.displayName('Permission One'))); + + // Note that aliased fields have accessors based on the *alias* + conn.addEdge(e => e.node(p => p.displayName('Permission Two').aliasedField(7))); + }) + .build(); + + const wrapper = shallow( + , + ); + + // Assert that "Permission One" and "Permission Two" show up correctly. +}); + +// Will automatically include default, internally consistent props for anything that's requested by the GraphQL +// fragments, but not set with accessors. +it("doesn't care about the GraphQL response at all", function() { + const wrapper = shallow( + ); +}) +``` + +If you add a field to your query, re-run `npm run relay`, and add the field to the builder, its default value will automatically be included in tests for any queries that request that field. If you remove a field from your query and re-run `npm run relay`, it will be omitted from the built objects, and tests that attempt to populate it with a setter will fail with an exception. + +```js +it("will fail because this field is not included in this component's fragment", function() { + const user = userBuilder(userQuery) + .email('me@email.com') + .build(); +}) +``` + +### Integration tests + +Within our [integration tests](/test/integration), rather than constructing data to mimic what Relay is expected to provide a single component, we need to mimic what Relay itself expects to see from the live GraphQL API. These tests use the `expectRelayQuery()` method to supply this mock data to Relay's network layer. However, the format of the response data differs from the props passed to any component wrapped in a fragment container: + +* Relay implicitly selects `id` and `__typename` fields on certain GraphQL types (but not all of them). +* Data from non-inline fragment spreads are included in-place. +* The top-level object is wrapped in a `{data: }` sandwich. + +To generate mock data consistent with the final GraphQL query, use the special `relayResponseBuilder()`. The Relay response builder is able to parse the actual query text and use _that_ instead of a pre-parsed relay-compiler fragment to choose which fields to include. + +```js +import {expectRelayQuery} from '../../lib/relay-network-layer-manager'; +import {relayResponseBuilder} from '../builder/graphql/query'; + +describe('integration: doing a thing', function() { + function expectSomeQuery() { + return expectRelayQuery({ + name: 'someContainerQuery', + variables: { + one: 1, + two: 'two', + }, + }, op => { + return relayResponseBuilder(op) + .repository(r => { + // These builders operate as before + r.name('pushbot'); + }) + .build(); + }) + } + + // ... +}); +``` + +⚠️ One major problem to watch for: if two queries used by the same integration test return data for _the same field_, you _must ensure that the IDs of the objects built at that field are the same_. Otherwise, you'll mess up Relay's store and start to see `undefined` for fields extracted from props. + +For example: + +```js +import {expectRelayQuery} from '../../lib/relay-network-layer-manager'; +import {relayResponseBuilder} from '../builder/graphql/query'; + +describe('integration: doing a thing', function() { + const repositoryID = 'repository0'; + + // query { repository(name: 'x', owner: 'y') { pullRequest(number: 123) { id } } } + function expectQueryOne() { + return expectRelayQuery({name: 'containerOneQuery'}, op => { + return relayResponseBuilder(op) + .repository(r => { + r.id(repositoryID); // <-- MUST MATCH + r.pullRequest(pr => pr.number(123)); + }) + .build(); + }) + } + + // query { repository(name: 'x', owner: 'y') { issue(number: 456) { id } } } + function expectQueryTwo() { + return expectRelayQuery({name: 'containerTwoQuery'}, op => { + return relayResponseBuilder(op) + .repository(r => { + r.id(repositoryID); // <-- MUST MATCH + r.issue(i => i.number(456)); + }) + .build(); + }) + } + + // ... +}); +``` + +## Writing builders + +By convention, GraphQL builders should reside in the [`test/builder/graphql`](/test/builder/graphql) directory. Builders are organized into modules by the object type they construct, although some closely interrelated builders may be defined in the same module for convenience, like pull requests and review threads. In general, one builder class should exist for each GraphQL object type that we care about. + +GraphQL builders may be constructed with the `createSpecBuilderClass()` method, defined in [`test/builder/graphql/helpers.js`](/test/builder/graphql/helpers.js). It accepts the name of the GraphQL type it expects to construct and an object describing the behavior of individual fields within that type. + +Each key of the object describes a single field in the GraphQL response that the builder knows how to construct. The value that you provide is a sub-object that customizes the details of that field's construction. The set of keys passed to any single builder should be the **superset** of fields and aliases selected on its type in any query or fragment within the package. + +Here's an example that illustrates the behavior of each recognized field description: + +```js +import {createSpecBuilderClass} from './base'; + +export const CheckRunBuilder = createSpecBuilderClass('CheckRun', { + // Simple, scalar field. + // Generates an accessor method called ".name(_value)" that sets `.name` and returns the builder. + // The "default" value is used if .name() is not called before `.build()`. + name: {default: 'the check run'}, + + // "default" may also be a function which will be invoked to construct this value in each constructed result. + id: {default: () => { nextID++; return nextID; }}, + + // The "default" function may accept an argument. Its properties will be the other, populated fields on the object + // under construction. Beware: if a field you depend on isn't available, its property will be `undefined`. + url: {default: f => { + const id = f.id || 123; + return `https://github.com/atom/atom/pull/123/checks?check_run_id=${id}`; + }} + + // This field is a collection. It implicitly defaults to []. + // Generates an accessor method called "addString()" that accepts a String and appends it to an array in the final + // object, then returns the builder. + strings: {plural: true, singularName: 'string'}, + + // This field has a default, but may be omitted from real responses. + // Generates accessor methods called ".summary(_value)" and ".nullSummary()". `.nullSummary` will explicitly set the + // field to `null` and prevent its default value from being used. `.summary(null)` will work as well. + summary: {default: 'some summary', nullable: true}, + + // This field is a composite type. + // Generates an accessor method called `.repository(_block = () => {})` that invokes its block with a + // RepositoryBuilder instance. The model constructed by `.build()` is then set as this builder's "repository" field, + // and the builder is returned. + // If the accessor is not called, or if no block is provided, the `RepositoryBuilder` is used to set defaults for all + // requested repository fields. + repository: {linked: RepositoryBuilder}, + + // This field is a collection of composite types. If defaults to []. + // An `.addLine(block = () => {})` method is created that constructs a Line object with a LineBuilder. + lines: {linked: LineBuilder, plural: true, singularName: 'line'}, + + // "custom" allows you to add an arbitrary method to the builder class with the name of the field. "this" will be + // set to the builder instance, so you can use this to define things like arbitrary, composite field setters, or field + // aliases. + extra: {custom: function() {}}, + +// (Optional) a String describing the interfaces implemented by this type in the schema, separated by &. +}, 'Node & UniformResourceLocatable'); + +// By convention, I export a method that creates each top-level builder type. (It makes the method call read slightly +// cleaner.) +export function checkRunBuilder(...nodes) { + return CheckRunBuilder.onFragmentQuery(nodes); +} +``` + +### Paginated collections + +One common pattern used in GraphQL schema is a [connection type](https://facebook.github.io/relay/graphql/connections.htm) for traversing a paginated collection. The `createConnectionBuilderClass()` method constructs the builders needed, saving a little bit of boilerplate. + +```js +import {createConnectionBuilderClass} from './base'; + +export const CommentBuilder = createSpecBuilderClass('PullRequestReviewComment', { + path: {default: 'first.txt'}, +}) + +export const CommentConnectionBuilder = createConnectionBuilderClass( + 'PullRequestReviewComment', + CommentBuilder, +); + + +export const ReviewThreadBuilder = createSpecBuilderClass('PullRequestReviewThread', { + comments: {linked: CommentConnectionBuilder}, +}); +``` + +The connection builder class can be used like any other builder: + +```js +const reviewThread = reviewThreadBuilder(query) + .pageInfo(i => { + i.hasNextPage(true); // defaults to false + i.endCursor('zzz'); // defaults to null + }) + .addEdge(e => { + e.cursor('aaa'); // defaults to an arbitrary string + + // .node() is a linked builder of the builder class you provided to createConnectionBuilderClass() + e.node(c => c.path('file0.txt')); + }) + // Can also populate the direct "nodes" link + .addNode(c => c.path('file1.txt')); + .totalCount(100) // Will be inferred from `.addNode` or `.addEdge` calls if either are configured + .build(); +``` + +### Union types + +Sometimes, a GraphQL field's type will be specified as an interface which many concrete types may implement. To allow callers to construct the linked object as one of the concrete types, use a _union builder class_: + +```js +import {createSpecBuilderClass, createUnionBuilderClass} from './base'; + +// A pair of builders for concrete types + +const IssueBuilder = createSpecBuilderClass('Issue', { + number: {default: 100}, +}); + +const PullRequestBuilder = createSpecBuilderClass('PullRequest', { + number: {default: 200}, +}); + +// A union builder that may construct either of them. +// The convention is to specify each alternative as "beTypeName()". + +const IssueishBuilder = createUnionBuilderClass('Issueish', { + beIssue: IssueBuilder, + bePullRequest: PullRequestBuilder, + default: 'beIssue', +}); + +// Another builder that uses the union builder as a linked type + +const RepositoryBuilder = createSpecBuilderClass('Repository', { + issueOrPullRequest: {linked: IssueishBuilder}, +}); +``` + +The concrete type for a specific response may be chosen by calling one of the "be" methods on the union builder, which behaves just like a linked field: + +```js +repositoryBuilder(someFragment) + .issueOrPullRequest(u => { + u.bePullRequest(pr => { + pr.number(300); + }); + }) + .build(); +``` + +### Circular dependencies + +When writing builders, you'll often hit a situation where two builders in two source files have a circular dependency on one another. If this happens, the builder class will be `undefined` in one of the modules. + +To resolve this, on either side, use the `defer()` helper to lazily load the builder when it's used: + +```js +const {createSpecBuilderClass, defer} = require('./base'); + +// defer() accepts: +// * The module to load **relative to the ./helpers module** +// * The **exported** name of the builder class +const PullRequestBuilder = defer('./pr', 'PullRequestBuilder'); + +const RepositoryBuilder = createSpecBuilderClass('Repository', { + pullRequest: {linked: PullRequestBuilder}, + + // ... +}) +``` diff --git a/test/builder/graphql/aggregated-reviews-builder.js b/test/builder/graphql/aggregated-reviews-builder.js new file mode 100644 index 0000000000..e2f7deb6bc --- /dev/null +++ b/test/builder/graphql/aggregated-reviews-builder.js @@ -0,0 +1,99 @@ +import {pullRequestBuilder, reviewThreadBuilder} from './pr'; + +import summariesQuery from '../../../lib/containers/accumulators/__generated__/reviewSummariesAccumulator_pullRequest.graphql.js'; +import threadsQuery from '../../../lib/containers/accumulators/__generated__/reviewThreadsAccumulator_pullRequest.graphql.js'; +import commentsQuery from '../../../lib/containers/accumulators/__generated__/reviewCommentsAccumulator_reviewThread.graphql.js'; + +class AggregatedReviewsBuilder { + constructor() { + this._reviewSummaryBuilder = pullRequestBuilder(summariesQuery); + this._reviewThreadsBuilder = pullRequestBuilder(threadsQuery); + this._commentsBuilders = []; + + this._summaryBlocks = []; + this._threadBlocks = []; + + this._errors = []; + this._loading = false; + } + + addError(err) { + this._errors.push(err); + return this; + } + + loading(_loading) { + this._loading = _loading; + return this; + } + + addReviewSummary(block = () => {}) { + this._summaryBlocks.push(block); + return this; + } + + addReviewThread(block = () => {}) { + let threadBlock = () => {}; + const commentBlocks = []; + + const subBuilder = { + thread(block0 = () => {}) { + threadBlock = block0; + return subBuilder; + }, + + addComment(block0 = () => {}) { + commentBlocks.push(block0); + return subBuilder; + }, + }; + block(subBuilder); + + const commentBuilder = reviewThreadBuilder(commentsQuery); + commentBuilder.comments(conn => { + for (const block0 of commentBlocks) { + conn.addEdge(e => e.node(block0)); + } + }); + + this._threadBlocks.push(threadBlock); + this._commentsBuilders.push(commentBuilder); + + return this; + } + + build() { + this._reviewSummaryBuilder.reviews(conn => { + for (const block of this._summaryBlocks) { + conn.addEdge(e => e.node(block)); + } + }); + const summariesPullRequest = this._reviewSummaryBuilder.build(); + const summaries = summariesPullRequest.reviews.edges.map(e => e.node); + + this._reviewThreadsBuilder.reviewThreads(conn => { + for (const block of this._threadBlocks) { + conn.addEdge(e => e.node(block)); + } + }); + const threadsPullRequest = this._reviewThreadsBuilder.build(); + + const commentThreads = threadsPullRequest.reviewThreads.edges.map((e, i) => { + const thread = e.node; + const commentsReviewThread = this._commentsBuilders[i].build(); + const comments = commentsReviewThread.comments.edges.map(e0 => e0.node); + return {thread, comments}; + }); + + return { + errors: this._errors, + loading: this._loading, + summaries, + commentThreads, + }; + } +} + +export function aggregatedReviewsBuilder() { + return new AggregatedReviewsBuilder(); +} diff --git a/test/builder/graphql/base/builder.js b/test/builder/graphql/base/builder.js new file mode 100644 index 0000000000..81beca365f --- /dev/null +++ b/test/builder/graphql/base/builder.js @@ -0,0 +1,250 @@ +import {FragmentSpec, QuerySpec} from './spec'; +import {makeDefaultGetterName} from './names'; + +// Private symbol used to identify what fields within a Builder have been populated (by a default setter or an +// explicit setter call). Using this instead of "undefined" lets us actually have "null" or "undefined" values +// if we want them. +const UNSET = Symbol('unset'); + +// Superclass for Builders that are expected to adhere to the fields requested by a GraphQL fragment. +export class SpecBuilder { + + // Compatibility with deferred-resolution builders. + static resolve() { + return this; + } + + static onFragmentQuery(nodes) { + if (!nodes || nodes.length === 0) { + /* eslint-disable-next-line no-console */ + console.error( + `No parsed query fragments given to \`${this.builderName}.onFragmentQuery()\`.\n` + + "Make sure you're passing a compiled Relay query (__generated__/*.graphql.js module)" + + ' to the builder construction function.', + ); + throw new Error(`No parsed queries given to ${this.builderName}`); + } + + return new this(typeNameSet => new FragmentSpec(nodes, typeNameSet)); + } + + static onFullQuery(query) { + if (!query) { + /* eslint-disable-next-line no-console */ + console.error( + `No parsed GraphQL queries given to \`${this.builderName}.onFullQuery()\`.\n` + + "Make sure you're passing GraphQL query text to the builder construction function.", + ); + throw new Error(`No parsed queries given to ${this.builderName}`); + } + + let rootQuery = null; + const fragmentsByName = new Map(); + for (const definition of query.definitions) { + if ( + definition.kind === 'OperationDefinition' && + (definition.operation === 'query' || definition.operation === 'mutation') + ) { + rootQuery = definition; + } else if (definition.kind === 'FragmentDefinition') { + fragmentsByName.set(definition.name.value, definition); + } + } + + if (rootQuery === null) { + throw new Error('Parsed query contained no root query'); + } + + return new this(typeNameSet => new QuerySpec(rootQuery, typeNameSet, fragmentsByName)); + } + + // Construct a SpecBuilder that builds an instance corresponding to a single GraphQL schema type, including only + // the fields selected by "nodes". + constructor(specFn) { + this.spec = specFn(this.allTypeNames); + + this.knownScalarFieldNames = new Set(this.spec.getRequestedScalarFields()); + this.knownLinkedFieldNames = new Set(this.spec.getRequestedLinkedFields()); + + this.fields = {}; + for (const fieldName of [...this.knownScalarFieldNames, ...this.knownLinkedFieldNames]) { + this.fields[fieldName] = UNSET; + } + } + + // Directly populate the builder's value for a scalar (Int, String, ID, ...) field. This will fail if the fragment + // we're configured with doesn't select the field, or if the field is a linked field instead. + singularScalarFieldSetter(fieldName, value) { + if (!this.knownScalarFieldNames.has(fieldName)) { + /* eslint-disable-next-line no-console */ + console.error( + `Unselected scalar field name ${fieldName} in ${this.builderName}\n` + + `"${fieldName}" may not be included in the GraphQL fragments you passed to this builder.\n` + + 'It may also be present, but as a linked field, in which case the builder definitions should be updated.\n' + + 'Otherwise, try re-running "npm run relay" to regenerate the compiled GraphQL modules.', + ); + throw new Error(`Unselected field name ${fieldName} in ${this.builderName}`); + } + this.fields[fieldName] = value; + return this; + } + + // Append a scalar value to an Array field. This will fail if the fragment we're configured with doesn't select the + // field, or if the field is a linked field instead. + pluralScalarFieldAdder(fieldName, value) { + if (!this.knownScalarFieldNames.has(fieldName)) { + /* eslint-disable-next-line no-console */ + console.error( + `Unselected scalar field name ${fieldName} in ${this.builderName}\n` + + `"${fieldName}" may not be included in the GraphQL fragments you passed to this builder.\n` + + 'It may also be present, but as a linked field, in which case the builder definitions should be updated.\n' + + 'Otherwise, try re-running "npm run relay" to regenerate the compiled GraphQL modules.', + ); + throw new Error(`Unselected field name ${fieldName} in ${this.builderName}`); + } + + if (this.fields[fieldName] === UNSET) { + this.fields[fieldName] = []; + } + this.fields[fieldName].push(value); + + return this; + } + + // Build a linked object with a different Builder using "block", then set the field's value based on the builder's + // output. This will fail if the field is not selected by the current fragment, or if the field is actually a + // scalar field. + singularLinkedFieldSetter(fieldName, Builder, block) { + if (!this.knownLinkedFieldNames.has(fieldName)) { + /* eslint-disable-next-line no-console */ + console.error( + `Unrecognized linked field name ${fieldName} in ${this.builderName}.\n` + + `"${fieldName}" may not be included in the GraphQL fragments you passed to this builder.\n` + + 'It may also be present, but as a scalar field, in which case the builder definitions should be updated.\n' + + 'Otherwise, try re-running "npm run relay" to regenerate the compiled GraphQL modules.', + ); + throw new Error(`Unrecognized field name ${fieldName} in ${this.builderName}`); + } + + const Resolved = Builder.resolve(); + const specFn = this.spec.getLinkedSpecCreator(fieldName); + const builder = new Resolved(specFn); + block(builder); + this.fields[fieldName] = builder.build(); + + return this; + } + + // Construct a linked object with another Builder using "block", then append the built object to an Array. This will + // fail if the named field is not selected by the current fragment, or if it's actually a scalar field. + pluralLinkedFieldAdder(fieldName, Builder, block) { + if (!this.knownLinkedFieldNames.has(fieldName)) { + /* eslint-disable-next-line no-console */ + console.error( + `Unrecognized linked field name ${fieldName} in ${this.builderName}.\n` + + `"${fieldName}" may not be included in the GraphQL fragments you passed to this builder.\n` + + 'It may also be present, but as a scalar field, in which case the builder definitions should be updated.\n' + + 'Otherwise, try re-running "npm run relay" to regenerate the compiled GraphQL modules.', + ); + throw new Error(`Unrecognized field name ${fieldName} in ${this.builderName}`); + } + + if (this.fields[fieldName] === UNSET) { + this.fields[fieldName] = []; + } + + const Resolved = Builder.resolve(); + const specFn = this.spec.getLinkedSpecCreator(fieldName); + const builder = new Resolved(specFn); + block(builder); + this.fields[fieldName].push(builder.build()); + + return this; + } + + // Explicitly set a field to `null` and prevent it from being populated with a default value. This will fail if the + // named field is not selected by the current fragment. + nullField(fieldName) { + if (!this.knownScalarFieldNames.has(fieldName) && !this.knownLinkedFieldNames.has(fieldName)) { + /* eslint-disable-next-line no-console */ + console.error( + `Unrecognized field name ${fieldName} in ${this.builderName}.\n` + + `"${fieldName}" may not be included in the GraphQL fragments you provided to this builder.\n` + + 'Try re-running "npm run relay" to regenerate the compiled GraphQL modules.', + ); + throw new Error(`Unrecognized field name ${fieldName} in ${this.builderName}`); + } + + this.fields[fieldName] = null; + return this; + } + + // Finalize any fields selected by the current query that have not been explicitly populated with their default + // values. Fail if any unpopulated fields have no specified default value or function. Then, return the selected + // fields as a plain JavaScript object. + build() { + const fieldNames = Object.keys(this.fields); + + const missingFieldNames = []; + + const populators = {}; + for (const fieldName of fieldNames) { + const defaultGetterName = makeDefaultGetterName(fieldName); + if (this.fields[fieldName] === UNSET && typeof this[defaultGetterName] !== 'function') { + missingFieldNames.push(fieldName); + continue; + } + + Object.defineProperty(populators, fieldName, { + get: () => { + if (this.fields[fieldName] !== UNSET) { + return this.fields[fieldName]; + } else { + const value = this[defaultGetterName](populators); + this.fields[fieldName] = value; + return value; + } + }, + }); + } + + if (missingFieldNames.length > 0) { + /* eslint-disable-next-line no-console */ + console.error( + `Missing required fields ${missingFieldNames.join(', ')} in builder ${this.builderName}.\n` + + 'Either give these fields a "default" in the builder or call their setters explicitly before calling "build()".', + ); + throw new Error(`Missing required fields ${missingFieldNames.join(', ')} in builder ${this.builderName}`); + } + + for (const fieldName of fieldNames) { + populators[fieldName]; + } + + return this.fields; + } +} + +// Resolve circular references by deferring the loading of a linked Builder class. Create these instances with the +// exported "defer" function. +export class DeferredSpecBuilder { + // Construct a deferred builder that will load a named, exported builder class from a module path. Note that, if + // modulePath is relative, it should be relative to *this* file. + constructor(modulePath, className) { + this.modulePath = modulePath; + this.className = className; + this.Class = undefined; + } + + // Lazily load the requested builder. Fail if the named module doesn't exist, or if it does not export a symbol + // with the requested class name. + resolve() { + if (this.Class === undefined) { + this.Class = require(this.modulePath)[this.className]; + if (!this.Class) { + throw new Error(`No class ${this.className} exported from ${this.modulePath}.`); + } + } + return this.Class; + } +} diff --git a/test/builder/graphql/base/create-connection-builder.js b/test/builder/graphql/base/create-connection-builder.js new file mode 100644 index 0000000000..11f4132e6a --- /dev/null +++ b/test/builder/graphql/base/create-connection-builder.js @@ -0,0 +1,28 @@ +import {createSpecBuilderClass} from './create-spec-builder'; + +const PageInfoBuilder = createSpecBuilderClass('PageInfo', { + hasNextPage: {default: false}, + endCursor: {default: null, nullable: true}, +}); + +export function createConnectionBuilderClass(name, NodeBuilder) { + const EdgeBuilder = createSpecBuilderClass(`${name}Edge`, { + cursor: {default: 'zzz'}, + node: {linked: NodeBuilder}, + }); + + return createSpecBuilderClass(`${name}Connection`, { + pageInfo: {linked: PageInfoBuilder}, + edges: {linked: EdgeBuilder, plural: true, singularName: 'edge'}, + nodes: {linked: NodeBuilder, plural: true, singularName: 'node'}, + totalCount: {default: f => { + if (f.edges) { + return f.edges.length; + } + if (f.nodes) { + return f.nodes.length; + } + return 0; + }}, + }); +} diff --git a/test/builder/graphql/base/create-spec-builder.js b/test/builder/graphql/base/create-spec-builder.js new file mode 100644 index 0000000000..667ad39a4b --- /dev/null +++ b/test/builder/graphql/base/create-spec-builder.js @@ -0,0 +1,151 @@ +import {SpecBuilder} from './builder'; +import {makeDefaultGetterName, makeNullableFunctionName, makeAdderFunctionName} from './names'; + +// Dynamically construct a Builder class that includes *only* fields that are selected by a GraphQL fragment. Adding +// fields to a fragment will cause them to be automatically included when that a Builder instance is created with +// that fragment; when a field is removed from the fragment, attempting to populate it with a setter method at +// build time will fail with an error. +// +// "typeName" is the name of the GraphQL type from the schema being queried. It will be used to determine which +// fragments to include and to generate a builder name for diagnostic messages. +// +// "fieldDescriptions" is an object detailing the *superset* of the fields used by all fragments on this type. Each +// key is a field name, and its value is an object that controls which methods that are generated on the builder: +// +// * "default" may be a constant value or a function. It's used to populate this field if it has not been explicitly +// set before build() is called. +// * "linked" names another SpecBuilder class used to build a linked compound object. +// * "plural" specifies that this property is an Array. It implicitly defaults to [] and may be constructed +// incrementally with an addFieldName() method. +// * "nullable" generates a `nullFieldName()` method that may be used to intentionally omit a field that would normally +// have a default value. +// * "custom" installs its value as a method on the generated Builder with the provided field name. +// +// See the README in this directory for examples. +export function createSpecBuilderClass(typeName, fieldDescriptions, interfaces = '') { + class Builder extends SpecBuilder {} + Builder.prototype.typeName = typeName; + Builder.prototype.builderName = typeName + 'Builder'; + + Builder.prototype.allTypeNames = new Set([typeName, ...interfaces.split(/\s*&\s*/)]); + + // These functions are used to install functions on the Builder class that implement specific access patterns. They're + // implemented here as inner functions to avoid the use of function literals within a loop. + + function installScalarSetter(fieldName) { + Builder.prototype[fieldName] = function(_value) { + return this.singularScalarFieldSetter(fieldName, _value); + }; + } + + function installScalarAdder(pluralFieldName, singularFieldName) { + Builder.prototype[makeAdderFunctionName(singularFieldName)] = function(_value) { + return this.pluralScalarFieldAdder(pluralFieldName, _value); + }; + } + + function installLinkedSetter(fieldName, LinkedBuilder) { + Builder.prototype[fieldName] = function(_block = () => {}) { + return this.singularLinkedFieldSetter(fieldName, LinkedBuilder, _block); + }; + } + + function installLinkedAdder(pluralFieldName, singularFieldName, LinkedBuilder) { + Builder.prototype[makeAdderFunctionName(singularFieldName)] = function(_block = () => {}) { + return this.pluralLinkedFieldAdder(pluralFieldName, LinkedBuilder, _block); + }; + } + + function installNullableFunction(fieldName) { + Builder.prototype[makeNullableFunctionName(fieldName)] = function() { + return this.nullField(fieldName); + }; + } + + function installDefaultGetter(fieldName, descriptionDefault) { + const defaultGetterName = makeDefaultGetterName(fieldName); + const defaultGetter = typeof descriptionDefault === 'function' ? descriptionDefault : function() { + return descriptionDefault; + }; + Builder.prototype[defaultGetterName] = defaultGetter; + } + + function installDefaultPluralGetter(fieldName) { + installDefaultGetter(fieldName, function() { + return []; + }); + } + + function installDefaultLinkedGetter(fieldName) { + installDefaultGetter(fieldName, function() { + this[fieldName](); + return this.fields[fieldName]; + }); + } + + // Iterate through field descriptions and install requested methods on the Builder class. + + for (const fieldName in fieldDescriptions) { + const description = fieldDescriptions[fieldName]; + + if (description.custom !== undefined) { + // Custom method. This is a backdoor to let you add random stuff to the final Builder. + Builder.prototype[fieldName] = description.custom; + continue; + } + + const singularFieldName = description.singularName || fieldName; + + // Object.keys() is used to detect the "linked" key here because, in the relatively common case of a circular + // import dependency, the description will be `{linked: undefined}`, and I want to provide a better error message + // when that happens. + if (!Object.keys(description).includes('linked')) { + // Scalar field. + + if (description.plural) { + installScalarAdder(fieldName, singularFieldName); + } else { + installScalarSetter(fieldName); + } + } else { + // Linked field. + + if (description.linked === undefined) { + /* eslint-disable-next-line no-console */ + console.error( + `Linked field ${fieldName} requested without a builder class in ${name}.\n` + + 'This can happen if you have a circular dependency between builders in different ' + + 'modules. Use defer() to defer loading of one builder to break it.', + fieldDescriptions, + ); + throw new Error(`Linked field ${fieldName} requested without a builder class in ${name}`); + } + + if (description.plural) { + installLinkedAdder(fieldName, singularFieldName, description.linked); + } else { + installLinkedSetter(fieldName, description.linked); + } + } + + // Install the appropriate default getter method. Explicitly specified defaults take precedence, then plural + // fields default to [], and linked fields default to calling the linked builder with an empty block to get + // the sub-builder's defaults. + + if (description.default !== undefined) { + installDefaultGetter(fieldName, description.default); + } else if (description.plural) { + installDefaultPluralGetter(fieldName); + } else if (description.linked) { + installDefaultLinkedGetter(fieldName); + } + + // Install the "explicitly null me out" method. + + if (description.nullable) { + installNullableFunction(fieldName); + } + } + + return Builder; +} diff --git a/test/builder/graphql/base/create-union-builder.js b/test/builder/graphql/base/create-union-builder.js new file mode 100644 index 0000000000..edbb501d12 --- /dev/null +++ b/test/builder/graphql/base/create-union-builder.js @@ -0,0 +1,41 @@ +const UNSET = Symbol('unset'); + +class UnionBuilder { + static resolve() { return this; } + + constructor(...args) { + this.args = args; + this._value = UNSET; + } + + build() { + if (this._value === UNSET) { + this[this.defaultAlternative](); + } + + return this._value; + } +} + +export function createUnionBuilderClass(typeName, alternativeSpec) { + class Builder extends UnionBuilder {} + Builder.prototype.typeName = typeName; + Builder.prototype.defaultAlternative = alternativeSpec.default; + + function installAlternativeMethod(methodName, BuilderClass) { + Builder.prototype[methodName] = function(block = () => {}) { + const Resolved = BuilderClass.resolve(); + const b = new Resolved(...this.args); + block(b); + this._value = b.build(); + return this; + }; + } + + for (const methodName in alternativeSpec) { + const BuilderClass = alternativeSpec[methodName]; + installAlternativeMethod(methodName, BuilderClass); + } + + return Builder; +} diff --git a/test/builder/graphql/base/index.js b/test/builder/graphql/base/index.js new file mode 100644 index 0000000000..48d8d96dcf --- /dev/null +++ b/test/builder/graphql/base/index.js @@ -0,0 +1,13 @@ +// Danger! Danger! Metaprogramming bullshit ahead. + +import {DeferredSpecBuilder} from './builder'; + +export {createSpecBuilderClass} from './create-spec-builder'; +export {createUnionBuilderClass} from './create-union-builder'; +export {createConnectionBuilderClass} from './create-connection-builder'; + +// Resolve circular dependencies among SpecBuilder classes by replacing one of the imports with a defer() call. The +// deferred Builder it returns will lazily require and locate the linked builder at first use. +export function defer(modulePath, className) { + return new DeferredSpecBuilder(modulePath, className); +} diff --git a/test/builder/graphql/base/names.js b/test/builder/graphql/base/names.js new file mode 100644 index 0000000000..bb935fe51c --- /dev/null +++ b/test/builder/graphql/base/names.js @@ -0,0 +1,22 @@ +// How many times has this exact helper been written? +export function capitalize(word) { + return word[0].toUpperCase() + word.slice(1); +} + +// Format the name of the method used to generate a default value for a field if one is not explicitly provided. For +// example, a fieldName of "someThing" would be "getDefaultSomeThing()". +export function makeDefaultGetterName(fieldName) { + return `getDefault${capitalize(fieldName)}`; +} + +// Format the name of a method used to append a value to the end of a collection. For example, a fieldName of +// "someThing" would be "addSomeThing()". +export function makeAdderFunctionName(fieldName) { + return `add${capitalize(fieldName)}`; +} + +// Format the name of a method used to mark a field as explicitly null and prevent it from being filled out with +// default values. For example, a fieldName of "someThing" would be "nullSomeThing()". +export function makeNullableFunctionName(fieldName) { + return `null${capitalize(fieldName)}`; +} diff --git a/test/builder/graphql/base/spec.js b/test/builder/graphql/base/spec.js new file mode 100644 index 0000000000..08bbdb7c2d --- /dev/null +++ b/test/builder/graphql/base/spec.js @@ -0,0 +1,200 @@ +const fieldKind = { + SCALAR: Symbol('scalar'), + LINKED: Symbol('linked'), +}; + +class Spec { + getRequestedFields(_kind) { + return []; + } + + // Return the names of all known scalar (Int, String, and so forth) fields selected by current queries. + getRequestedScalarFields() { + return this.getRequestedFields(fieldKind.SCALAR); + } + + // Return the names of all known linked (composite with sub-field) fields selected by current queries. + getRequestedLinkedFields() { + return this.getRequestedFields(fieldKind.LINKED); + } + + getLinkedSpecCreator(_name) { + throw new Error('No linked specs'); + } +} + +// Wraps one or more GraphQL query specs loaded from code generated by relay-compiler. These are the files with +// names matching `**/__generated__/*.graphql.js` that are created when you run "npm run relay". +export class FragmentSpec extends Spec { + + // Normalize an Array of "node" objects imported from *.graphql.js files. Wrap them in a Spec instance to take + // advantage of query methods. + constructor(nodes, typeNameSet) { + super(); + + const gettersByKind = { + Fragment: node => node, + LinkedField: node => node, + Request: node => node.fragment, + }; + + this.nodes = nodes.map(node => { + const fn = gettersByKind[node.kind]; + if (fn === undefined) { + /* eslint-disable-next-line */ + console.error( + `Unrecognized node kind "${node.kind}".\n` + + "I couldn't figure out what to do with a parsed GraphQL module.\n" + + "Either you're passing something unexpected into an xyzBuilder() method,\n" + + 'or the Relay compiler is generating something that our builder factory\n' + + "doesn't yet know how to handle.", + node, + ); + throw new Error(`Unrecognized node kind ${node.kind}`); + } else { + return fn(node); + } + }); + + // Discover and (recursively) flatten any inline fragment spreads in-place. + const flattenFragments = selections => { + const spreads = new Map(); + for (let i = selections.length - 1; i >= 0; i--) { + if (selections[i].kind === 'InlineFragment') { + // Replace inline fragments in-place with their selected fields *if* the GraphQL type name matches. + + if (!typeNameSet.has(selections[i].type)) { + continue; + } + spreads.set(i, selections[i].selections); + } + } + + for (const [index, subSelections] of spreads) { + flattenFragments(subSelections); + selections.splice(index, 1, ...subSelections); + } + }; + + for (const node of this.nodes) { + flattenFragments(node.selections); + } + } + + // Query all of our query specs for the names of selected fields that match a certain "kind". Field kinds include + // ScalarField, LinkedField, FragmentSpread, and likely others. Field aliases are preferred over field names if + // present. Fields that are duplicated across query specs (which could happen when multiple query specs are + // provided) will be returned once. + getRequestedFields(kind) { + let kindName = null; + if (kind === fieldKind.SCALAR) { + kindName = 'ScalarField'; + } + if (kind === fieldKind.LINKED) { + kindName = 'LinkedField'; + } + + const fieldNames = new Set(); + for (const node of this.nodes) { + for (const selection of node.selections) { + if (selection.kind === kindName) { + fieldNames.add(selection.alias || selection.name); + } + } + } + return Array.from(fieldNames); + } + + // Return one or more subqueries that describe fields selected within a linked field called "name". If no such + // subqueries may be found, an error is thrown. + getLinkedSpecCreator(name) { + const subNodes = []; + for (const node of this.nodes) { + const match = node.selections.find(selection => selection.alias === name || selection.name === name); + if (match) { + subNodes.push(match); + } + } + if (subNodes.length === 0) { + throw new Error(`Unable to find linked field ${name}`); + } + return typeNameSet => new FragmentSpec(subNodes, typeNameSet); + } +} + +export class QuerySpec extends Spec { + constructor(root, typeNameSet, fragmentsByName) { + super(); + + this.root = root; + this.fragmentsByName = fragmentsByName; + + // Discover and (recursively) flatten any fragment spreads in-place. + const flattenFragments = selections => { + const spreads = new Map(); + for (let i = selections.length - 1; i >= 0; i--) { + if (selections[i].kind === 'InlineFragment') { + // Replace inline fragments in-place with their selected fields *if* the GraphQL type name matches. + + if (selections[i].typeCondition.kind !== 'NamedType' || !typeNameSet.has(selections[i].typeCondition.name.value)) { + continue; + } + spreads.set(i, selections[i].selectionSet.selections); + } else if (selections[i].kind === 'FragmentSpread') { + // Replace named fragments in-place with their selected fields if a non-null SpecRegistry is available and + // the GraphQL type name matches. + + const fragment = this.fragmentsByName.get(selections[i].name.value); + if (!fragment) { + throw new Error(`Reference to unknown fragment: ${selections[i].name.value}`); + } + if (fragment.typeCondition.kind !== 'NamedType' || !typeNameSet.has(fragment.typeCondition.name.value)) { + continue; + } + + spreads.set(i, fragment.selectionSet.selections); + } + } + + for (const [index, subSelections] of spreads) { + flattenFragments(subSelections); + selections.splice(index, 1, ...subSelections); + } + }; + + flattenFragments(this.root.selectionSet.selections); + } + + getRequestedFields(kind) { + const fields = []; + for (const selection of this.root.selectionSet.selections) { + if (selection.kind !== 'Field') { + continue; + } + + if (selection.selectionSet === undefined && kind !== fieldKind.SCALAR) { + continue; + } else if (selection.selectionSet !== undefined && kind !== fieldKind.LINKED) { + continue; + } + + fields.push((selection.alias || selection.name).value); + } + return fields; + } + + getLinkedSpecCreator(name) { + for (const selection of this.root.selectionSet.selections) { + if (selection.kind !== 'Field' || selection.selectionSet === undefined) { + continue; + } + + const fieldName = (selection.alias || selection.name).value; + if (fieldName === name) { + return typeNameSet => new QuerySpec(selection, typeNameSet, this.fragmentsByName); + } + } + + throw new Error(`Unable to find linked field ${name}`); + } +} diff --git a/test/builder/graphql/issue.js b/test/builder/graphql/issue.js new file mode 100644 index 0000000000..09314d0200 --- /dev/null +++ b/test/builder/graphql/issue.js @@ -0,0 +1,36 @@ +import {nextID} from '../id-sequence'; +import {createSpecBuilderClass, createUnionBuilderClass, createConnectionBuilderClass} from './base'; + +import {ReactionGroupBuilder} from './reaction-group'; +import {UserBuilder} from './user'; +import {CommitBuilder, CrossReferencedEventBuilder, IssueCommentBuilder} from './timeline'; + +export const IssueTimelineItemBuilder = createUnionBuilderClass('IssueTimelineItem', { + beCommit: CommitBuilder, + beCrossReferencedEvent: CrossReferencedEventBuilder, + beIssueComment: IssueCommentBuilder, +}); + +export const IssueBuilder = createSpecBuilderClass('Issue', { + __typename: {default: 'Issue'}, + id: {default: nextID}, + title: {default: 'Something is wrong'}, + number: {default: 123}, + state: {default: 'OPEN'}, + bodyHTML: {default: '

HI

'}, + author: {linked: UserBuilder}, + reactionGroups: {linked: ReactionGroupBuilder, plural: true, singularName: 'reactionGroup'}, + viewerCanReact: {default: true}, + timeline: {linked: createConnectionBuilderClass('IssueTimeline', IssueTimelineItemBuilder)}, + url: {default: f => { + const id = f.id || '1'; + return `https://github.com/atom/github/issue/${id}`; + }}, +}, +'Node & Assignable & Closable & Comment & Updatable & UpdatableComment & Labelable & Lockable & Reactable & ' + + 'RepositoryNode & Subscribable & UniformResourceLocatable', +); + +export function issueBuilder(...nodes) { + return IssueBuilder.onFragmentQuery(nodes); +} diff --git a/test/builder/graphql/issueish.js b/test/builder/graphql/issueish.js new file mode 100644 index 0000000000..3bdc19e3ea --- /dev/null +++ b/test/builder/graphql/issueish.js @@ -0,0 +1,10 @@ +import {createUnionBuilderClass} from './base'; + +import {PullRequestBuilder} from './pr'; +import {IssueBuilder} from './issue'; + +export const IssueishBuilder = createUnionBuilderClass('Issueish', { + beIssue: IssueBuilder, + bePullRequest: PullRequestBuilder, + default: 'beIssue', +}); diff --git a/test/builder/graphql/mutations.js b/test/builder/graphql/mutations.js new file mode 100644 index 0000000000..3bf3455424 --- /dev/null +++ b/test/builder/graphql/mutations.js @@ -0,0 +1,44 @@ +import {createSpecBuilderClass} from './base'; + +import {CommentBuilder, ReviewBuilder, ReviewThreadBuilder} from './pr'; +import {ReactableBuilder} from './reactable'; + +const ReviewEdgeBuilder = createSpecBuilderClass('PullRequestReviewEdge', { + node: {linked: ReviewBuilder}, +}); + +const CommentEdgeBuilder = createSpecBuilderClass('PullRequestReviewCommentEdge', { + node: {linked: CommentBuilder}, +}); + +export const AddPullRequestReviewPayloadBuilder = createSpecBuilderClass('AddPullRequestReviewPayload', { + reviewEdge: {linked: ReviewEdgeBuilder}, +}); + +export const AddPullRequestReviewCommentPayloadBuilder = createSpecBuilderClass('AddPullRequestReviewCommentPayload', { + commentEdge: {linked: CommentEdgeBuilder}, +}); + +export const SubmitPullRequestReviewPayloadBuilder = createSpecBuilderClass('SubmitPullRequestReviewPayload', { + pullRequestReview: {linked: ReviewBuilder}, +}); + +export const DeletePullRequestReviewPayloadBuilder = createSpecBuilderClass('DeletePullRequestReviewPayload', { + pullRequestReview: {linked: ReviewBuilder, nullable: true}, +}); + +export const ResolveReviewThreadPayloadBuilder = createSpecBuilderClass('ResolveReviewThreadPayload', { + thread: {linked: ReviewThreadBuilder}, +}); + +export const UnresolveReviewThreadPayloadBuilder = createSpecBuilderClass('UnresolveReviewThreadPayload', { + thread: {linked: ReviewThreadBuilder}, +}); + +export const AddReactionPayloadBuilder = createSpecBuilderClass('AddReactionPayload', { + subject: {linked: ReactableBuilder}, +}); + +export const RemoveReactionPayloadBuilder = createSpecBuilderClass('RemoveReactionPayload', { + subject: {linked: ReactableBuilder}, +}); diff --git a/test/builder/graphql/pr.js b/test/builder/graphql/pr.js new file mode 100644 index 0000000000..a2a394b5d0 --- /dev/null +++ b/test/builder/graphql/pr.js @@ -0,0 +1,118 @@ +import {createSpecBuilderClass, createConnectionBuilderClass, createUnionBuilderClass} from './base'; +import {nextID} from '../id-sequence'; + +import {RepositoryBuilder} from './repository'; +import {UserBuilder} from './user'; +import {ReactionGroupBuilder} from './reaction-group'; +import { + CommitBuilder, + CommitCommentThreadBuilder, + CrossReferencedEventBuilder, + HeadRefForcePushedEventBuilder, + IssueCommentBuilder, + MergedEventBuilder, +} from './timeline'; + +const PullRequestTimelineItemBuilder = createUnionBuilderClass('PullRequestTimelineItem', { + beCommit: CommitBuilder, + beCommitCommentThread: CommitCommentThreadBuilder, + beCrossReferencedEvent: CrossReferencedEventBuilder, + beHeadRefForcePushedEvent: HeadRefForcePushedEventBuilder, + beIssueComment: IssueCommentBuilder, + beMergedEvent: MergedEventBuilder, + default: 'beIssueComment', +}); + +export const CommentBuilder = createSpecBuilderClass('PullRequestReviewComment', { + __typename: {default: 'PullRequestReviewComment'}, + id: {default: nextID}, + path: {default: 'first.txt'}, + position: {default: 0, nullable: true}, + diffHunk: {default: '@ -1,4 +1,5 @@'}, + author: {linked: UserBuilder}, + reactionGroups: {linked: ReactionGroupBuilder, plural: true, singularName: 'reactionGroup'}, + url: {default: 'https://github.com/atom/github/pull/1829/files#r242224689'}, + createdAt: {default: '2018-12-27T17:51:17Z'}, + bodyHTML: {default: 'Lorem ipsum dolor sit amet, te urbanitas appellantur est.'}, + replyTo: {default: null, nullable: true}, + isMinimized: {default: false}, + minimizedReason: {default: null, nullable: true}, + state: {default: 'SUBMITTED'}, + viewerCanReact: {default: true}, + viewerCanMinimize: {default: true}, +}, 'Node & Comment & Deletable & Updatable & UpdatableComment & Reactable & RepositoryNode'); + +export const CommentConnectionBuilder = createConnectionBuilderClass('PullRequestReviewComment', CommentBuilder); + +export const ReviewThreadBuilder = createSpecBuilderClass('PullRequestReviewThread', { + __typename: {default: 'PullRequestReviewThread'}, + id: {default: nextID}, + isResolved: {default: false}, + viewerCanResolve: {default: f => !f.isResolved}, + viewerCanUnresolve: {default: f => !!f.isResolved}, + resolvedBy: {linked: UserBuilder}, + comments: {linked: CommentConnectionBuilder}, +}, 'Node'); + +export const ReviewBuilder = createSpecBuilderClass('PullRequestReview', { + __typename: {default: 'PullRequestReview'}, + id: {default: nextID}, + submittedAt: {default: '2018-12-28T20:40:55Z'}, + bodyHTML: {default: 'Lorem ipsum dolor sit amet, consectetur adipisicing elit'}, + state: {default: 'COMMENTED'}, + author: {linked: UserBuilder}, + comments: {linked: CommentConnectionBuilder}, + viewerCanReact: {default: true}, + reactionGroups: {linked: ReactionGroupBuilder, plural: true, singularName: 'reactionGroup'}, +}, 'Node & Comment & Deletable & Updatable & UpdatableComment & Reactable & RepositoryNode'); + +export const CommitConnectionBuilder = createConnectionBuilderClass('PullRequestCommit', CommentBuilder); + +export const PullRequestCommitBuilder = createSpecBuilderClass('PullRequestCommit', { + id: {default: nextID}, + commit: {linked: CommitBuilder}, +}, 'Node & UniformResourceLocatable'); + +export const PullRequestBuilder = createSpecBuilderClass('PullRequest', { + id: {default: nextID}, + __typename: {default: 'PullRequest'}, + number: {default: 123}, + title: {default: 'the title'}, + baseRefName: {default: 'base-ref'}, + headRefName: {default: 'head-ref'}, + headRefOid: {default: '0000000000000000000000000000000000000000'}, + isCrossRepository: {default: false}, + changedFiles: {default: 5}, + state: {default: 'OPEN'}, + bodyHTML: {default: '', nullable: true}, + createdAt: {default: '2019-01-01T10:00:00Z'}, + countedCommits: {linked: CommitConnectionBuilder}, + url: {default: f => { + const ownerLogin = (f.repository && f.repository.owner && f.repository.owner.login) || 'aaa'; + const repoName = (f.repository && f.repository.name) || 'bbb'; + const number = f.number || 1; + return `https://github.com/${ownerLogin}/${repoName}/pull/${number}`; + }}, + author: {linked: UserBuilder}, + repository: {linked: RepositoryBuilder}, + headRepository: {linked: RepositoryBuilder, nullable: true}, + headRepositoryOwner: {linked: UserBuilder}, + commits: {linked: createConnectionBuilderClass('PullRequestCommit', PullRequestCommitBuilder)}, + recentCommits: {linked: createConnectionBuilderClass('PullRequestCommit', PullRequestCommitBuilder)}, + reviews: {linked: createConnectionBuilderClass('ReviewConnection', ReviewBuilder)}, + reviewThreads: {linked: createConnectionBuilderClass('ReviewThreadConnection', ReviewThreadBuilder)}, + timeline: {linked: createConnectionBuilderClass('PullRequestTimeline', PullRequestTimelineItemBuilder)}, + reactionGroups: {linked: ReactionGroupBuilder, plural: true, singularName: 'reactionGroup'}, + viewerCanReact: {default: true}, +}, +'Node & Assignable & Closable & Comment & Updatable & UpdatableComment & Labelable & Lockable & Reactable & ' + +'RepositoryNode & Subscribable & UniformResourceLocatable', +); + +export function reviewThreadBuilder(...nodes) { + return ReviewThreadBuilder.onFragmentQuery(nodes); +} + +export function pullRequestBuilder(...nodes) { + return PullRequestBuilder.onFragmentQuery(nodes); +} diff --git a/test/builder/graphql/query.js b/test/builder/graphql/query.js new file mode 100644 index 0000000000..5474e81fae --- /dev/null +++ b/test/builder/graphql/query.js @@ -0,0 +1,92 @@ +import {parse, Source} from 'graphql'; + +import {createSpecBuilderClass} from './base'; + +import {RepositoryBuilder} from './repository'; +import {PullRequestBuilder} from './pr'; + +import { + AddPullRequestReviewPayloadBuilder, + AddPullRequestReviewCommentPayloadBuilder, + SubmitPullRequestReviewPayloadBuilder, + DeletePullRequestReviewPayloadBuilder, + ResolveReviewThreadPayloadBuilder, + UnresolveReviewThreadPayloadBuilder, + AddReactionPayloadBuilder, + RemoveReactionPayloadBuilder, +} from './mutations'; + +class SearchResultItemBuilder { + static resolve() { return this; } + + constructor(...args) { + this.args = args; + this._value = null; + } + + bePullRequest(block = () => {}) { + const b = new PullRequestBuilder(...this.args); + block(b); + this._value = b.build(); + } + + build() { + return this._value; + } +} + +const SearchResultBuilder = createSpecBuilderClass('SearchResultItemConnection', { + issueCount: {default: 0}, + nodes: {linked: SearchResultItemBuilder, plural: true, singularName: 'node'}, +}); + +const QueryBuilder = createSpecBuilderClass('Query', { + repository: {linked: RepositoryBuilder, nullable: true}, + search: {linked: SearchResultBuilder}, + + // Mutations + addPullRequestReview: {linked: AddPullRequestReviewPayloadBuilder}, + addPullRequestReviewComment: {linked: AddPullRequestReviewCommentPayloadBuilder}, + submitPullRequestReview: {linked: SubmitPullRequestReviewPayloadBuilder}, + deletePullRequestReview: {linked: DeletePullRequestReviewPayloadBuilder}, + resolveReviewThread: {linked: ResolveReviewThreadPayloadBuilder}, + unresolveReviewThread: {linked: UnresolveReviewThreadPayloadBuilder}, + addReaction: {linked: AddReactionPayloadBuilder}, + removeReaction: {linked: RemoveReactionPayloadBuilder}, +}); + +export function queryBuilder(...nodes) { + return QueryBuilder.onFragmentQuery(nodes); +} + +class RelayResponseBuilder extends QueryBuilder { + static onOperation(op) { + const doc = parse(new Source(op.text)); + return this.onFullQuery(doc); + } + + constructor(...args) { + super(...args); + + this._errors = []; + } + + addError(string) { + this._errors.push({message: string}); + return this; + } + + build() { + if (this._errors.length > 0) { + const error = new Error('Pre-recorded GraphQL failure'); + error.errors = this._errors; + throw error; + } + + return {data: super.build()}; + } +} + +export function relayResponseBuilder(op) { + return RelayResponseBuilder.onOperation(op); +} diff --git a/test/builder/graphql/reactable.js b/test/builder/graphql/reactable.js new file mode 100644 index 0000000000..6dfd614752 --- /dev/null +++ b/test/builder/graphql/reactable.js @@ -0,0 +1,12 @@ +import {createUnionBuilderClass} from './base'; + +import {IssueBuilder} from './issue'; +import {PullRequestBuilder, CommentBuilder, ReviewBuilder} from './pr'; + +export const ReactableBuilder = createUnionBuilderClass('Reactable', { + beIssue: IssueBuilder, + bePullRequest: PullRequestBuilder, + bePullRequestReviewComment: CommentBuilder, + beReview: ReviewBuilder, + default: 'beIssue', +}); diff --git a/test/builder/graphql/reaction-group.js b/test/builder/graphql/reaction-group.js new file mode 100644 index 0000000000..1ed73c3b58 --- /dev/null +++ b/test/builder/graphql/reaction-group.js @@ -0,0 +1,9 @@ +import {createSpecBuilderClass, createConnectionBuilderClass} from './base'; + +import {UserBuilder} from './user'; + +export const ReactionGroupBuilder = createSpecBuilderClass('ReactionGroup', { + content: {default: 'ROCKET'}, + viewerHasReacted: {default: false}, + users: {linked: createConnectionBuilderClass('ReactingUser', UserBuilder)}, +}); diff --git a/test/builder/graphql/ref.js b/test/builder/graphql/ref.js new file mode 100644 index 0000000000..ca86f71782 --- /dev/null +++ b/test/builder/graphql/ref.js @@ -0,0 +1,11 @@ +import {createSpecBuilderClass, createConnectionBuilderClass, defer} from './base'; +import {nextID} from '../id-sequence'; + +const PullRequestBuilder = defer('../pr', 'PullRequestBuilder'); + +export const RefBuilder = createSpecBuilderClass('Ref', { + id: {default: nextID}, + prefix: {default: 'refs/heads/'}, + name: {default: 'master'}, + associatedPullRequests: {linked: createConnectionBuilderClass('PullRequest', PullRequestBuilder)}, +}, 'Node'); diff --git a/test/builder/graphql/repository.js b/test/builder/graphql/repository.js new file mode 100644 index 0000000000..b71d6723f7 --- /dev/null +++ b/test/builder/graphql/repository.js @@ -0,0 +1,26 @@ +import {createSpecBuilderClass, defer} from './base'; +import {nextID} from '../id-sequence'; + +import {RefBuilder} from './ref'; +import {UserBuilder} from './user'; +import {IssueBuilder} from './issue'; + +const PullRequestBuilder = defer('../pr', 'PullRequestBuilder'); +const IssueishBuilder = defer('../issueish', 'IssueishBuilder'); + +export const RepositoryBuilder = createSpecBuilderClass('Repository', { + id: {default: nextID}, + name: {default: 'the-repository'}, + url: {default: f => `https://github.com/${f.owner.login}/${f.name}`}, + sshUrl: {default: f => `git@github.com:${f.owner.login}/${f.name}.git`}, + owner: {linked: UserBuilder}, + defaultBranchRef: {linked: RefBuilder, nullable: true}, + ref: {linked: RefBuilder, nullable: true}, + issue: {linked: IssueBuilder, nullable: true}, + pullRequest: {linked: PullRequestBuilder, nullable: true}, + issueish: {linked: IssueishBuilder, nullable: true}, +}, 'Node & ProjectOwner & RegistryPackageOwner & Subscribable & Starrable & UniformResourceLocatable & RepositoryInfo'); + +export function repositoryBuilder(...nodes) { + return RepositoryBuilder.onFragmentQuery(nodes); +} diff --git a/test/builder/graphql/timeline.js b/test/builder/graphql/timeline.js new file mode 100644 index 0000000000..1b59689311 --- /dev/null +++ b/test/builder/graphql/timeline.js @@ -0,0 +1,75 @@ +import {createSpecBuilderClass, createConnectionBuilderClass, defer} from './base'; +import {nextID} from '../id-sequence'; + +import {UserBuilder} from './user'; +const IssueishBuilder = defer('../issueish', 'IssueishBuilder'); + +export const StatusContextBuilder = createSpecBuilderClass('StatusContext', { + // +}, 'Node'); + +export const StatusBuilder = createSpecBuilderClass('Status', { + id: {default: nextID}, + state: {default: 'SUCCESS'}, + contexts: {linked: StatusContextBuilder, plural: true, singularName: 'context'}, +}, 'Node'); + +export const CommitBuilder = createSpecBuilderClass('Commit', { + id: {default: nextID}, + author: {linked: UserBuilder}, + committer: {linked: UserBuilder}, + authoredByCommitter: {default: true}, + sha: {default: '0000000000000000000000000000000000000000'}, + oid: {default: '0000000000000000000000000000000000000000'}, + message: {default: 'Commit message'}, + messageHeadlineHTML: {default: '

Commit message

'}, + commitUrl: {default: f => { + const sha = f.oid || f.sha || '0000000000000000000000000000000000000000'; + return `https://github.com/atom/github/commit/${sha}`; + }}, + status: {linked: StatusBuilder}, +}, 'Node & GitObject & Subscribable & UniformResourceLocatable'); + +export const CommitCommentBuilder = createSpecBuilderClass('CommitComment', { + id: {default: nextID}, + author: {linked: UserBuilder}, + commit: {linked: CommitBuilder}, + bodyHTML: {default: 'comment body'}, + createdAt: {default: '2019-01-01T10:00:00Z'}, + path: {default: 'file.txt'}, + position: {default: 0, nullable: true}, +}, 'Node & Comment & Deletable & Updatable & UpdatableComment & Reactable & RepositoryNode'); + +export const CommitCommentThreadBuilder = createSpecBuilderClass('CommitCommentThread', { + commit: {linked: CommitBuilder}, + comments: {linked: createConnectionBuilderClass('CommitComment', CommitCommentBuilder)}, +}, 'Node & RepositoryNode'); + +export const CrossReferencedEventBuilder = createSpecBuilderClass('CrossReferencedEvent', { + id: {default: nextID}, + referencedAt: {default: '2019-01-01T10:00:00Z'}, + isCrossRepository: {default: false}, + actor: {linked: UserBuilder}, + source: {linked: IssueishBuilder}, +}, 'Node & UniformResourceLocatable'); + +export const HeadRefForcePushedEventBuilder = createSpecBuilderClass('HeadRefForcePushedEvent', { + actor: {linked: UserBuilder}, + beforeCommit: {linked: CommitBuilder}, + afterCommit: {linked: CommitBuilder}, + createdAt: {default: '2019-01-01T10:00:00Z'}, +}, 'Node'); + +export const IssueCommentBuilder = createSpecBuilderClass('IssueComment', { + author: {linked: UserBuilder}, + bodyHTML: {default: 'issue comment'}, + createdAt: {default: '2019-01-01T10:00:00Z'}, + url: {default: 'https://github.com/atom/github/issue/123'}, +}, 'Node & Comment & Deletable & Updatable & UpdatableComment & Reactable & RepositoryNode'); + +export const MergedEventBuilder = createSpecBuilderClass('MergedEvent', { + actor: {linked: UserBuilder}, + commit: {linked: CommitBuilder}, + mergeRefName: {default: 'master'}, + createdAt: {default: '2019-01-01T10:00:00Z'}, +}, 'Node & UniformResourceLocatable'); diff --git a/test/builder/graphql/user.js b/test/builder/graphql/user.js new file mode 100644 index 0000000000..08392b438b --- /dev/null +++ b/test/builder/graphql/user.js @@ -0,0 +1,17 @@ +import {createSpecBuilderClass} from './base'; +import {nextID} from '../id-sequence'; + +export const UserBuilder = createSpecBuilderClass('User', { + __typename: {default: 'User'}, + id: {default: nextID}, + login: {default: 'someone'}, + avatarUrl: {default: 'https://avatars3.githubusercontent.com/u/17565?s=32&v=4'}, + url: {default: f => { + const login = f.login || 'login'; + return `https://github.com/${login}`; + }}, +}); + +export function userBuilder(...nodes) { + return UserBuilder.onFragmentQuery(nodes); +} diff --git a/test/builder/id-sequence.js b/test/builder/id-sequence.js new file mode 100644 index 0000000000..f7277f4508 --- /dev/null +++ b/test/builder/id-sequence.js @@ -0,0 +1,17 @@ +export default class IDSequence { + constructor() { + this.current = 0; + } + + nextID() { + const id = this.current; + this.current++; + return id; + } +} + +const seq = new IDSequence(); + +export function nextID() { + return seq.nextID().toString(); +} diff --git a/test/builder/patch.js b/test/builder/patch.js index 73a6819aeb..da0c796d97 100644 --- a/test/builder/patch.js +++ b/test/builder/patch.js @@ -200,7 +200,7 @@ class PatchBuilder { } status(st) { - if (['modified', 'added', 'deleted'].indexOf(st) === -1) { + if (['modified', 'added', 'deleted', 'renamed'].indexOf(st) === -1) { throw new Error(`Unrecognized status: ${st} (must be 'modified', 'added' or 'deleted')`); } diff --git a/test/builder/pr.js b/test/builder/pr.js deleted file mode 100644 index 4b2a0d3613..0000000000 --- a/test/builder/pr.js +++ /dev/null @@ -1,170 +0,0 @@ -class CommentBuilder { - constructor() { - this._id = 0; - this._path = 'first.txt'; - this._position = 0; - this._authorLogin = 'someone'; - this._authorAvatarUrl = 'https://avatars3.githubusercontent.com/u/17565?s=32&v=4'; - this._url = 'https://github.com/atom/github/pull/1829/files#r242224689'; - this._createdAt = '2018-12-27T17:51:17Z'; - this._body = 'Lorem ipsum dolor sit amet, te urbanitas appellantur est.'; - this._replyTo = null; - this._isMinimized = false; - } - - id(i) { - this._id = i; - return this; - } - - minimized(m) { - this._isMinimized = m; - return this; - } - - path(p) { - this._path = p; - return this; - } - - position(pos) { - this._position = pos; - return this; - } - - authorLogin(login) { - this._authorLogin = login; - return this; - } - - authorAvatarUrl(url) { - this._authorAvatarUrl = url; - return this; - } - - url(u) { - this._url = u; - return this; - } - - createdAt(ts) { - this._createdAt = ts; - return this; - } - - body(text) { - this._body = text; - return this; - } - - replyTo(replyToId) { - this._replyTo = {id: replyToId}; - return this; - } - - build() { - return { - id: this._id, - author: { - login: this._authorLogin, - avatarUrl: this._authorAvatarUrl, - }, - body: this._body, - path: this._path, - position: this._position, - createdAt: this._createdAt, - url: this._url, - replyTo: this._replyTo, - isMinimized: this._isMinimized, - }; - } -} - -class ReviewBuilder { - constructor() { - this.nextCommentID = 0; - this._id = 0; - this._comments = []; - this._submittedAt = '2018-12-28T20:40:55Z'; - } - - id(i) { - this._id = i; - return this; - } - - submittedAt(timestamp) { - this._submittedAt = timestamp; - return this; - } - - addComment(block = () => {}) { - const builder = new CommentBuilder(); - builder.id(this.nextCommentID); - this.nextCommentID++; - - block(builder); - this._comments.push(builder.build()); - - return this; - } - - build() { - const comments = this._comments.map(comment => { - return {node: comment}; - }); - return { - id: this._id, - submittedAt: this._submittedAt, - comments: {edges: comments}, - }; - } -} - -class PullRequestBuilder { - constructor() { - this.nextCommentID = 0; - this.nextReviewID = 0; - this._reviews = []; - } - - addReview(block = () => {}) { - const builder = new ReviewBuilder(); - builder.id(this.nextReviewID); - this.nextReviewID++; - - block(builder); - this._reviews.push(builder.build()); - return this; - } - - build() { - const commentThreads = {}; - this._reviews.forEach(review => { - review.comments.edges.forEach(({node: comment}) => { - if (comment.replyTo && comment.replyTo.id && commentThreads[comment.replyTo.id]) { - commentThreads[comment.replyTo.id].push(comment); - } else { - commentThreads[comment.id] = [comment]; - } - }); - }); - return { - reviews: {nodes: this._reviews}, - commentThreads: Object.keys(commentThreads).map(rootCommentId => { - return { - rootCommentId, - comments: commentThreads[rootCommentId], - }; - }), - }; - } -} - -export function reviewBuilder() { - return new ReviewBuilder(); -} - -export function pullRequestBuilder() { - return new PullRequestBuilder(); -} diff --git a/test/containers/accumulators/accumulator.test.js b/test/containers/accumulators/accumulator.test.js new file mode 100644 index 0000000000..ab6716173f --- /dev/null +++ b/test/containers/accumulators/accumulator.test.js @@ -0,0 +1,122 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import {Disposable} from 'event-kit'; + +import Accumulator from '../../../lib/containers/accumulators/accumulator'; + +describe('Accumulator', function() { + function buildApp(override = {}) { + const props = { + resultBatch: [], + pageSize: 10, + waitTimeMs: 0, + onDidRefetch: () => new Disposable(), + children: () => {}, + ...override, + relay: { + hasMore: () => false, + loadMore: (_pageSize, cb) => { + cb(null); + return new Disposable(); + }, + isLoading: () => false, + ...(override.relay || {}), + }, + }; + + return ; + } + + it('accepts an already-loaded single page of results', function() { + const fn = sinon.spy(); + shallow(buildApp({ + relay: { + hasMore: () => false, + isLoading: () => false, + }, + resultBatch: [1, 2, 3], + children: fn, + })); + + assert.isTrue(fn.calledWith(null, [1, 2, 3], false)); + }); + + it('continues to paginate and accumulate results', function() { + const fn = sinon.spy(); + let hasMore = true; + let loadMoreCallback = null; + + const relay = { + hasMore: () => hasMore, + loadMore: (pageSize, callback) => { + assert.strictEqual(pageSize, 10); + loadMoreCallback = () => { + loadMoreCallback = null; + callback(null); + }; + return new Disposable(); + }, + isLoading: () => false, + }; + + const wrapper = shallow(buildApp({relay, resultBatch: [1, 2, 3], children: fn})); + assert.isTrue(fn.calledWith(null, [1, 2, 3], true)); + + wrapper.setProps({resultBatch: [1, 2, 3, 4, 5, 6]}); + loadMoreCallback(); + assert.isTrue(fn.calledWith(null, [1, 2, 3, 4, 5, 6], true)); + + hasMore = false; + wrapper.setProps({resultBatch: [1, 2, 3, 4, 5, 6, 7, 8, 9]}); + loadMoreCallback(); + assert.isTrue(fn.calledWith(null, [1, 2, 3, 4, 5, 6, 7, 8, 9], false)); + assert.isNull(loadMoreCallback); + }); + + it('reports an error to its render prop', function() { + const fn = sinon.spy(); + const error = Symbol('the error'); + + const relay = { + hasMore: () => true, + loadMore: (pageSize, callback) => { + assert.strictEqual(pageSize, 10); + callback(error); + return new Disposable(); + }, + }; + + shallow(buildApp({relay, children: fn})); + assert.isTrue(fn.calledWith(error, [], true)); + }); + + it('terminates a loadMore call when unmounting', function() { + const disposable = {dispose: sinon.spy()}; + const relay = { + hasMore: () => true, + loadMore: sinon.stub().returns(disposable), + isLoading: () => false, + }; + + const wrapper = shallow(buildApp({relay})); + assert.isTrue(relay.loadMore.called); + + wrapper.unmount(); + assert.isTrue(disposable.dispose.called); + }); + + it('cancels a delayed accumulate call when unmounting', function() { + const setTimeoutStub = sinon.stub(window, 'setTimeout').returns(123); + const clearTimeoutStub = sinon.stub(window, 'clearTimeout'); + + const relay = { + hasMore: () => true, + }; + + const wrapper = shallow(buildApp({relay, waitTimeMs: 1000})); + assert.isTrue(setTimeoutStub.called); + + wrapper.unmount(); + assert.isTrue(clearTimeoutStub.calledWith(123)); + }); +}); diff --git a/test/containers/accumulators/review-comments-accumulator.test.js b/test/containers/accumulators/review-comments-accumulator.test.js new file mode 100644 index 0000000000..c0fcd8d7bd --- /dev/null +++ b/test/containers/accumulators/review-comments-accumulator.test.js @@ -0,0 +1,64 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import {BareReviewCommentsAccumulator} from '../../../lib/containers/accumulators/review-comments-accumulator'; +import {reviewThreadBuilder} from '../../builder/graphql/pr'; + +import reviewThreadQuery from '../../../lib/containers/accumulators/__generated__/reviewCommentsAccumulator_reviewThread.graphql.js'; + +describe('ReviewCommentsAccumulator', function() { + function buildApp(opts = {}) { + const options = { + buildReviewThread: () => {}, + props: {}, + ...opts, + }; + + const builder = reviewThreadBuilder(reviewThreadQuery); + options.buildReviewThread(builder); + + const props = { + relay: { + hasMore: () => false, + loadMore: () => {}, + isLoading: () => false, + }, + reviewThread: builder.build(), + children: () =>
, + onDidRefetch: () => {}, + ...options.props, + }; + + return ; + } + + it('passes the review thread comments as its result batch', function() { + function buildReviewThread(b) { + b.comments(conn => { + conn.addEdge(e => e.node(c => c.id(10))); + conn.addEdge(e => e.node(c => c.id(20))); + conn.addEdge(e => e.node(c => c.id(30))); + }); + } + + const wrapper = shallow(buildApp({buildReviewThread})); + + assert.deepEqual( + wrapper.find('Accumulator').prop('resultBatch').map(each => each.id), + [10, 20, 30], + ); + }); + + it('passes a child render prop', function() { + const children = sinon.stub().returns(
); + const wrapper = shallow(buildApp({props: {children}})); + const resultWrapper = wrapper.find('Accumulator').renderProp('children')(null, [], false); + + assert.isTrue(resultWrapper.exists('.done')); + assert.isTrue(children.calledWith({ + error: null, + comments: [], + loading: false, + })); + }); +}); diff --git a/test/containers/accumulators/review-summaries-accumulator.test.js b/test/containers/accumulators/review-summaries-accumulator.test.js new file mode 100644 index 0000000000..fa2ef43b41 --- /dev/null +++ b/test/containers/accumulators/review-summaries-accumulator.test.js @@ -0,0 +1,77 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import {BareReviewSummariesAccumulator} from '../../../lib/containers/accumulators/review-summaries-accumulator'; +import {pullRequestBuilder} from '../../builder/graphql/pr'; + +import pullRequestQuery from '../../../lib/containers/accumulators/__generated__/reviewSummariesAccumulator_pullRequest.graphql.js'; + +describe('ReviewSummariesAccumulator', function() { + function buildApp(opts = {}) { + const options = { + buildPullRequest: () => {}, + props: {}, + ...opts, + }; + + const builder = pullRequestBuilder(pullRequestQuery); + options.buildPullRequest(builder); + + const props = { + relay: { + hasMore: () => false, + loadMore: () => {}, + isLoading: () => false, + }, + pullRequest: builder.build(), + onDidRefetch: () => {}, + children: () =>
, + ...options.props, + }; + + return ; + } + + it('passes pull request reviews as its result batches', function() { + function buildPullRequest(b) { + b.reviews(conn => { + conn.addEdge(e => e.node(r => r.id(0))); + conn.addEdge(e => e.node(r => r.id(1))); + conn.addEdge(e => e.node(r => r.id(3))); + }); + } + + const wrapper = shallow(buildApp({buildPullRequest})); + + assert.deepEqual( + wrapper.find('Accumulator').prop('resultBatch').map(each => each.id), + [0, 1, 3], + ); + }); + + it('calls a children render prop with sorted review summaries', function() { + const children = sinon.stub().returns(
); + const wrapper = shallow(buildApp({props: {children}})); + + const resultWrapper = wrapper.find('Accumulator').renderProp('children')( + null, + [ + {submittedAt: '2019-01-01T10:00:00Z'}, + {submittedAt: '2019-01-05T10:00:00Z'}, + {submittedAt: '2019-01-02T10:00:00Z'}, + ], + false, + ); + + assert.isTrue(resultWrapper.exists('.done')); + assert.isTrue(children.calledWith({ + error: null, + summaries: [ + {submittedAt: '2019-01-01T10:00:00Z'}, + {submittedAt: '2019-01-02T10:00:00Z'}, + {submittedAt: '2019-01-05T10:00:00Z'}, + ], + loading: false, + })); + }); +}); diff --git a/test/containers/accumulators/review-threads-accumulator.test.js b/test/containers/accumulators/review-threads-accumulator.test.js new file mode 100644 index 0000000000..80de8fa7dd --- /dev/null +++ b/test/containers/accumulators/review-threads-accumulator.test.js @@ -0,0 +1,207 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import {BareReviewThreadsAccumulator} from '../../../lib/containers/accumulators/review-threads-accumulator'; +import ReviewCommentsAccumulator from '../../../lib/containers/accumulators/review-comments-accumulator'; +import {pullRequestBuilder} from '../../builder/graphql/pr'; + +import pullRequestQuery from '../../../lib/containers/accumulators/__generated__/reviewThreadsAccumulator_pullRequest.graphql.js'; + +describe('ReviewThreadsAccumulator', function() { + function buildApp(opts = {}) { + const options = { + pullRequest: pullRequestBuilder(pullRequestQuery).build(), + props: {}, + ...opts, + }; + + const props = { + relay: { + hasMore: () => false, + loadMore: () => {}, + isLoading: () => false, + }, + pullRequest: options.pullRequest, + children: () =>
, + onDidRefetch: () => {}, + ...options.props, + }; + + return ; + } + + it('passes reviewThreads as its result batch', function() { + const pullRequest = pullRequestBuilder(pullRequestQuery) + .reviewThreads(conn => { + conn.addEdge(); + conn.addEdge(); + }) + .build(); + + const wrapper = shallow(buildApp({pullRequest})); + + const actualThreads = wrapper.find('Accumulator').prop('resultBatch'); + const expectedThreads = pullRequest.reviewThreads.edges.map(e => e.node); + assert.deepEqual(actualThreads, expectedThreads); + }); + + it('handles an error from the thread query results', function() { + const err = new Error('oh no'); + + const children = sinon.stub().returns(
); + const wrapper = shallow(buildApp({props: {children}})); + const resultWrapper = wrapper.find('Accumulator').renderProp('children')(err, [], false); + + assert.isTrue(resultWrapper.exists('.done')); + assert.isTrue(children.calledWith({ + errors: [err], + commentThreads: [], + loading: false, + })); + }); + + it('recursively renders a ReviewCommentsAccumulator for each reviewThread', function() { + const pullRequest = pullRequestBuilder(pullRequestQuery) + .reviewThreads(conn => { + conn.addEdge(); + conn.addEdge(); + }) + .build(); + const reviewThreads = pullRequest.reviewThreads.edges.map(e => e.node); + + const children = sinon.stub().returns(
); + const wrapper = shallow(buildApp({pullRequest, props: {children}})); + + const args = [null, wrapper.find('Accumulator').prop('resultBatch'), false]; + const threadWrapper = wrapper.find('Accumulator').renderProp('children')(...args); + + const accumulator0 = threadWrapper.find(ReviewCommentsAccumulator); + assert.strictEqual(accumulator0.prop('reviewThread'), reviewThreads[0]); + const result0 = {error: null, comments: [1, 2, 3], loading: false}; + const commentsWrapper0 = accumulator0.renderProp('children')(result0); + + const accumulator1 = commentsWrapper0.find(ReviewCommentsAccumulator); + assert.strictEqual(accumulator1.prop('reviewThread'), reviewThreads[1]); + const result1 = {error: null, comments: [10, 20, 30], loading: false}; + const commentsWrapper1 = accumulator1.renderProp('children')(result1); + + assert.isTrue(commentsWrapper1.exists('.done')); + assert.isTrue(children.calledWith({ + errors: [], + commentThreads: [ + {thread: reviewThreads[0], comments: [1, 2, 3]}, + {thread: reviewThreads[1], comments: [10, 20, 30]}, + ], + loading: false, + })); + }); + + it('handles the arrival of additional review thread batches', function() { + const pr0 = pullRequestBuilder(pullRequestQuery) + .reviewThreads(conn => { + conn.addEdge(); + conn.addEdge(); + }) + .build(); + + const children = sinon.stub().returns(
); + const batch0 = pr0.reviewThreads.edges.map(e => e.node); + + const wrapper = shallow(buildApp({pullRequest: pr0, props: {children}})); + const threadsWrapper0 = wrapper.find('Accumulator').renderProp('children')(null, batch0, true); + const comments0Wrapper0 = threadsWrapper0.find(ReviewCommentsAccumulator).renderProp('children')({ + error: null, + comments: [1, 1, 1], + loading: false, + }); + comments0Wrapper0.find(ReviewCommentsAccumulator).renderProp('children')({ + error: null, + comments: [2, 2, 2], + loading: false, + }); + + assert.isTrue(children.calledWith({ + commentThreads: [ + {thread: batch0[0], comments: [1, 1, 1]}, + {thread: batch0[1], comments: [2, 2, 2]}, + ], + errors: [], + loading: true, + })); + children.resetHistory(); + + const pr1 = pullRequestBuilder(pullRequestQuery) + .reviewThreads(conn => { + conn.addEdge(); + conn.addEdge(); + conn.addEdge(); + }) + .build(); + const batch1 = pr1.reviewThreads.edges.map(e => e.node); + + wrapper.setProps({pullRequest: pr1}); + const threadsWrapper1 = wrapper.find('Accumulator').renderProp('children')(null, batch1, false); + const comments1Wrapper0 = threadsWrapper1.find(ReviewCommentsAccumulator).renderProp('children')({ + error: null, + comments: [1, 1, 1], + loading: false, + }); + const comments1Wrapper1 = comments1Wrapper0.find(ReviewCommentsAccumulator).renderProp('children')({ + error: null, + comments: [2, 2, 2], + loading: false, + }); + comments1Wrapper1.find(ReviewCommentsAccumulator).renderProp('children')({ + error: null, + comments: [3, 3, 3], + loading: false, + }); + + assert.isTrue(children.calledWith({ + commentThreads: [ + {thread: batch1[0], comments: [1, 1, 1]}, + {thread: batch1[1], comments: [2, 2, 2]}, + {thread: batch1[2], comments: [3, 3, 3]}, + ], + errors: [], + loading: false, + })); + }); + + it('handles errors from each ReviewCommentsAccumulator', function() { + const pullRequest = pullRequestBuilder(pullRequestQuery) + .reviewThreads(conn => { + conn.addEdge(); + conn.addEdge(); + }) + .build(); + const batch = pullRequest.reviewThreads.edges.map(e => e.node); + + const children = sinon.stub().returns(
); + const wrapper = shallow(buildApp({pullRequest, props: {children}})); + + const threadsWrapper = wrapper.find('Accumulator').renderProp('children')(null, batch, false); + const error0 = new Error('oh shit'); + const commentsWrapper0 = threadsWrapper.find(ReviewCommentsAccumulator).renderProp('children')({ + error: error0, + comments: [], + loading: false, + }); + const error1 = new Error('wat'); + const commentsWrapper1 = commentsWrapper0.find(ReviewCommentsAccumulator).renderProp('children')({ + error: error1, + comments: [], + loading: false, + }); + + assert.isTrue(commentsWrapper1.exists('.done')); + assert.isTrue(children.calledWith({ + errors: [error0, error1], + commentThreads: [ + {thread: batch[0], comments: []}, + {thread: batch[1], comments: []}, + ], + loading: false, + })); + }); +}); diff --git a/test/containers/aggregated-reviews-container.test.js b/test/containers/aggregated-reviews-container.test.js new file mode 100644 index 0000000000..99f839d84e --- /dev/null +++ b/test/containers/aggregated-reviews-container.test.js @@ -0,0 +1,227 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import {BareAggregatedReviewsContainer} from '../../lib/containers/aggregated-reviews-container'; +import ReviewSummariesAccumulator from '../../lib/containers/accumulators/review-summaries-accumulator'; +import ReviewThreadsAccumulator from '../../lib/containers/accumulators/review-threads-accumulator'; +import {pullRequestBuilder, reviewThreadBuilder} from '../builder/graphql/pr'; + +import pullRequestQuery from '../../lib/containers/__generated__/aggregatedReviewsContainer_pullRequest.graphql.js'; +import summariesQuery from '../../lib/containers/accumulators/__generated__/reviewSummariesAccumulator_pullRequest.graphql.js'; +import threadsQuery from '../../lib/containers/accumulators/__generated__/reviewThreadsAccumulator_pullRequest.graphql.js'; +import commentsQuery from '../../lib/containers/accumulators/__generated__/reviewCommentsAccumulator_reviewThread.graphql.js'; + +describe('AggregatedReviewsContainer', function() { + function buildApp(override = {}) { + const props = { + pullRequest: pullRequestBuilder(pullRequestQuery).build(), + relay: { + refetch: () => {}, + }, + ...override, + }; + + return ; + } + + it('reports errors from review summaries or review threads', function() { + const children = sinon.stub().returns(
); + const wrapper = shallow(buildApp({children})); + + const summaryError = new Error('everything is on fire'); + const summariesWrapper = wrapper.find(ReviewSummariesAccumulator).renderProp('children')({ + error: summaryError, + summaries: [], + loading: false, + }); + + const threadError0 = new Error('tripped over a power cord'); + const threadError1 = new Error('cosmic rays'); + const threadsWrapper = summariesWrapper.find(ReviewThreadsAccumulator).renderProp('children')({ + errors: [threadError0, threadError1], + commentThreads: [], + loading: false, + }); + + assert.isTrue(threadsWrapper.exists('.done')); + assert.isTrue(children.calledWith({ + errors: [summaryError, threadError0, threadError1], + refetch: sinon.match.func, + summaries: [], + commentThreads: [], + loading: false, + })); + }); + + it('collects review summaries', function() { + const pullRequest0 = pullRequestBuilder(summariesQuery) + .reviews(conn => { + conn.addEdge(e => e.node(r => r.id(0))); + conn.addEdge(e => e.node(r => r.id(1))); + conn.addEdge(e => e.node(r => r.id(2))); + }) + .build(); + const batch0 = pullRequest0.reviews.edges.map(e => e.node); + + const children = sinon.stub().returns(
); + const wrapper = shallow(buildApp({children})); + const summariesWrapper0 = wrapper.find(ReviewSummariesAccumulator).renderProp('children')({ + error: null, + summaries: batch0, + loading: true, + }); + const threadsWrapper0 = summariesWrapper0.find(ReviewThreadsAccumulator).renderProp('children')({ + errors: [], + commentThreads: [], + loading: false, + }); + assert.isTrue(threadsWrapper0.exists('.done')); + assert.isTrue(children.calledWith({ + errors: [], + summaries: batch0, + refetch: sinon.match.func, + commentThreads: [], + loading: true, + })); + + const pullRequest1 = pullRequestBuilder(summariesQuery) + .reviews(conn => { + conn.addEdge(e => e.node(r => r.id(0))); + conn.addEdge(e => e.node(r => r.id(1))); + conn.addEdge(e => e.node(r => r.id(2))); + conn.addEdge(e => e.node(r => r.id(3))); + conn.addEdge(e => e.node(r => r.id(4))); + }) + .build(); + const batch1 = pullRequest1.reviews.edges.map(e => e.node); + + const summariesWrapper1 = wrapper.find(ReviewSummariesAccumulator).renderProp('children')({ + error: null, + summaries: batch1, + loading: false, + }); + const threadsWrapper1 = summariesWrapper1.find(ReviewThreadsAccumulator).renderProp('children')({ + errors: [], + commentThreads: [], + loading: false, + }); + assert.isTrue(threadsWrapper1.exists('.done')); + assert.isTrue(children.calledWith({ + errors: [], + refetch: sinon.match.func, + summaries: batch1, + commentThreads: [], + loading: false, + })); + }); + + it('collects and aggregates review threads and comments', function() { + const pullRequest = pullRequestBuilder(pullRequestQuery).build(); + + const threadsPullRequest = pullRequestBuilder(threadsQuery) + .reviewThreads(conn => { + conn.addEdge(); + conn.addEdge(); + conn.addEdge(); + }) + .build(); + + const thread0 = reviewThreadBuilder(commentsQuery) + .comments(conn => { + conn.addEdge(e => e.node(c => c.id(10))); + conn.addEdge(e => e.node(c => c.id(11))); + }) + .build(); + + const thread1 = reviewThreadBuilder(commentsQuery) + .comments(conn => { + conn.addEdge(e => e.node(c => c.id(20))); + }) + .build(); + + const thread2 = reviewThreadBuilder(commentsQuery) + .comments(conn => { + conn.addEdge(e => e.node(c => c.id(30))); + conn.addEdge(e => e.node(c => c.id(31))); + conn.addEdge(e => e.node(c => c.id(32))); + }) + .build(); + + const threads = threadsPullRequest.reviewThreads.edges.map(e => e.node); + const commentThreads = [ + {thread: threads[0], comments: thread0.comments.edges.map(e => e.node)}, + {thread: threads[1], comments: thread1.comments.edges.map(e => e.node)}, + {thread: threads[2], comments: thread2.comments.edges.map(e => e.node)}, + ]; + + const children = sinon.stub().returns(
); + const wrapper = shallow(buildApp({pullRequest, children})); + + const summariesWrapper = wrapper.find(ReviewSummariesAccumulator).renderProp('children')({ + error: null, + summaries: [], + loading: false, + }); + const threadsWrapper = summariesWrapper.find(ReviewThreadsAccumulator).renderProp('children')({ + errors: [], + commentThreads, + loading: false, + }); + assert.isTrue(threadsWrapper.exists('.done')); + + assert.isTrue(children.calledWith({ + errors: [], + refetch: sinon.match.func, + summaries: [], + commentThreads, + loading: false, + })); + }); + + it('broadcasts a refetch event on refretch', function() { + const refetchStub = sinon.stub().callsArg(2); + + let refetchFn = null; + const children = ({refetch}) => { + refetchFn = refetch; + return
; + }; + + const wrapper = shallow(buildApp({ + children, + relay: {refetch: refetchStub}, + })); + + const summariesAccumulator = wrapper.find(ReviewSummariesAccumulator); + + const cb0 = sinon.spy(); + summariesAccumulator.prop('onDidRefetch')(cb0); + + const summariesWrapper = summariesAccumulator.renderProp('children')({ + error: null, + summaries: [], + loading: false, + }); + + const threadAccumulator = summariesWrapper.find(ReviewThreadsAccumulator); + + const cb1 = sinon.spy(); + threadAccumulator.prop('onDidRefetch')(cb1); + + const threadWrapper = threadAccumulator.renderProp('children')({ + errors: [], + commentThreads: [], + loading: false, + }); + + assert.isTrue(threadWrapper.exists('.done')); + assert.isNotNull(refetchFn); + + const done = sinon.spy(); + refetchFn(done); + + assert.isTrue(refetchStub.called); + assert.isTrue(cb0.called); + assert.isTrue(cb1.called); + }); +}); diff --git a/test/containers/comment-decorations-container.test.js b/test/containers/comment-decorations-container.test.js new file mode 100644 index 0000000000..90c664407a --- /dev/null +++ b/test/containers/comment-decorations-container.test.js @@ -0,0 +1,402 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import {QueryRenderer} from 'react-relay'; + +import {cloneRepository, buildRepository} from '../helpers'; +import {queryBuilder} from '../builder/graphql/query'; +import {multiFilePatchBuilder} from '../builder/patch'; +import Branch, {nullBranch} from '../../lib/models/branch'; +import BranchSet from '../../lib/models/branch-set'; +import GithubLoginModel from '../../lib/models/github-login-model'; +import ObserveModel from '../../lib/views/observe-model'; +import Remote from '../../lib/models/remote'; +import RemoteSet from '../../lib/models/remote-set'; +import {InMemoryStrategy, UNAUTHENTICATED, INSUFFICIENT} from '../../lib/shared/keytar-strategy'; +import CommentDecorationsContainer from '../../lib/containers/comment-decorations-container'; +import PullRequestPatchContainer from '../../lib/containers/pr-patch-container'; +import CommentPositioningContainer from '../../lib/containers/comment-positioning-container'; +import AggregatedReviewsContainer from '../../lib/containers/aggregated-reviews-container'; +import CommentDecorationsController from '../../lib/controllers/comment-decorations-controller'; + +import rootQuery from '../../lib/containers/__generated__/commentDecorationsContainerQuery.graphql.js'; + +describe('CommentDecorationsContainer', function() { + let atomEnv, workspace, localRepository, loginModel; + + beforeEach(async function() { + atomEnv = global.buildAtomEnvironment(); + workspace = atomEnv.workspace; + localRepository = await buildRepository(await cloneRepository()); + loginModel = new GithubLoginModel(InMemoryStrategy); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(overrideProps = {}) { + return ( + + ); + } + + it('renders nothing while repository data is being fetched', function() { + const wrapper = shallow(buildApp()); + const localRepoWrapper = wrapper.find(ObserveModel).renderProp('children')(null); + assert.isTrue(localRepoWrapper.isEmptyRender()); + }); + + it('renders nothing if no GitHub remotes exist', async function() { + const wrapper = shallow(buildApp()); + + const localRepoData = await wrapper.find(ObserveModel).prop('fetchData')(localRepository); + const localRepoWrapper = wrapper.find(ObserveModel).renderProp('children')(localRepoData); + + const token = await localRepoWrapper.find(ObserveModel).prop('fetchData')(loginModel, localRepoData); + assert.isNull(token); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')(null); + assert.isTrue(tokenWrapper.isEmptyRender()); + }); + + it('renders nothing if no head branch is present', async function() { + const wrapper = shallow(buildApp({localRepository, loginModel})); + + const origin = new Remote('origin', 'git@somewhere.com:atom/github.git'); + + const repoData = { + branches: new BranchSet(), + remotes: new RemoteSet([origin]), + currentRemote: origin, + workingDirectoryPath: 'path/path', + }; + const localRepoWrapper = wrapper.find(ObserveModel).renderProp('children')(repoData); + const token = await localRepoWrapper.find(ObserveModel).prop('fetchData')(loginModel, repoData); + assert.isNull(token); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + assert.isTrue(tokenWrapper.isEmptyRender()); + }); + + it('renders nothing if the head branch has no push upstream', function() { + const wrapper = shallow(buildApp({localRepository, loginModel})); + + const origin = new Remote('origin', 'git@github.com:atom/github.git'); + const upstreamBranch = Branch.createRemoteTracking('refs/remotes/origin/master', 'origin', 'refs/heads/master'); + const branch = new Branch('master', upstreamBranch, nullBranch, true); + + const repoData = { + branches: new BranchSet([branch]), + remotes: new RemoteSet([origin]), + currentRemote: origin, + workingDirectoryPath: 'path/path', + }; + const localRepoWrapper = wrapper.find(ObserveModel).renderProp('children')(repoData); + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + assert.isTrue(tokenWrapper.isEmptyRender()); + }); + + it('renders nothing if the push remote is invalid', function() { + const wrapper = shallow(buildApp({localRepository, loginModel})); + + const origin = new Remote('origin', 'git@github.com:atom/github.git'); + const upstreamBranch = Branch.createRemoteTracking('refs/remotes/nope/master', 'nope', 'refs/heads/master'); + const branch = new Branch('master', upstreamBranch, upstreamBranch, true); + + const repoData = { + branches: new BranchSet([branch]), + remotes: new RemoteSet([origin]), + currentRemote: origin, + workingDirectoryPath: 'path/path', + }; + const localRepoWrapper = wrapper.find(ObserveModel).renderProp('children')(repoData); + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + assert.isTrue(tokenWrapper.isEmptyRender()); + }); + + it('renders nothing if the push remote is not a GitHub remote', function() { + const wrapper = shallow(buildApp({localRepository, loginModel})); + + const origin = new Remote('origin', 'git@elsewhere.com:atom/github.git'); + const upstreamBranch = Branch.createRemoteTracking('refs/remotes/origin/master', 'origin', 'refs/heads/master'); + const branch = new Branch('master', upstreamBranch, upstreamBranch, true); + + const repoData = { + branches: new BranchSet([branch]), + remotes: new RemoteSet([origin]), + currentRemote: origin, + workingDirectoryPath: 'path/path', + }; + const localRepoWrapper = wrapper.find(ObserveModel).renderProp('children')(repoData); + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + assert.isTrue(tokenWrapper.isEmptyRender()); + }); + + describe('when GitHub remote exists', function() { + let localRepo, repoData, wrapper, localRepoWrapper; + + beforeEach(async function() { + await loginModel.setToken('https://api.github.com', '1234'); + sinon.stub(loginModel, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES); + + localRepo = await buildRepository(await cloneRepository()); + + wrapper = shallow(buildApp({localRepository: localRepo, loginModel})); + + const origin = new Remote('origin', 'git@github.com:atom/github.git'); + const upstreamBranch = Branch.createRemoteTracking('refs/remotes/origin/master', 'origin', 'refs/heads/master'); + const branch = new Branch('master', upstreamBranch, upstreamBranch, true); + + repoData = { + branches: new BranchSet([branch]), + remotes: new RemoteSet([origin]), + currentRemote: origin, + workingDirectoryPath: 'path/path', + }; + localRepoWrapper = wrapper.find(ObserveModel).renderProp('children')(repoData); + }); + + it('renders nothing if token is UNAUTHENTICATED', function() { + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')(UNAUTHENTICATED); + assert.isTrue(tokenWrapper.isEmptyRender()); + }); + + it('renders nothing if token is INSUFFICIENT', function() { + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')(INSUFFICIENT); + assert.isTrue(tokenWrapper.isEmptyRender()); + }); + + it('makes a relay query if token works', async function() { + const token = await localRepoWrapper.find(ObserveModel).prop('fetchData')(loginModel, repoData); + assert.strictEqual(token, '1234'); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + assert.lengthOf(tokenWrapper.find(QueryRenderer), 1); + }); + + it('renders nothing if query errors', function() { + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ + error: 'oh noes', props: null, retry: () => {}}); + assert.isTrue(resultWrapper.isEmptyRender()); + }); + + it('renders nothing if query is loading', function() { + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props: null, retry: () => {}}); + assert.isTrue(resultWrapper.isEmptyRender()); + }); + + it('renders nothing if query result does not include repository', function() { + const props = queryBuilder(rootQuery) + .nullRepository() + .build(); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}}); + assert.isTrue(resultWrapper.isEmptyRender()); + }); + + it('renders nothing if query result does not include repository ref', function() { + const props = queryBuilder(rootQuery) + .repository(r => r.nullRef()) + .build(); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + assert.isTrue(resultWrapper.isEmptyRender()); + }); + + it('renders the PullRequestPatchContainer if result includes repository and ref', function() { + const props = queryBuilder(rootQuery) + .repository(r => { + r.ref(r0 => { + r0.associatedPullRequests(conn => { + conn.totalCount(1); + conn.addNode(); + }); + }); + }) + .build(); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + assert.lengthOf(resultWrapper.find(PullRequestPatchContainer), 1); + }); + + it('renders nothing if patch cannot be fetched', function() { + const props = queryBuilder(rootQuery) + .repository(r => { + r.ref(r0 => { + r0.associatedPullRequests(conn => { + conn.totalCount(1); + conn.addNode(); + }); + }); + }) + .build(); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + const patchWrapper = resultWrapper.find(PullRequestPatchContainer).renderProp('children')( + new Error('oops'), null, + ); + assert.isTrue(patchWrapper.isEmptyRender()); + }); + + it('aggregates reviews while the patch is loading', function() { + const props = queryBuilder(rootQuery) + .repository(r => { + r.ref(r0 => { + r0.associatedPullRequests(conn => { + conn.totalCount(1); + conn.addNode(); + }); + }); + }) + .build(); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + const patchWrapper = resultWrapper.find(PullRequestPatchContainer).renderProp('children')( + null, null, + ); + const reviewsWrapper = patchWrapper.find(AggregatedReviewsContainer).renderProp('children')({ + errors: [], + summaries: [], + commentThreads: [], + }); + + assert.isTrue(reviewsWrapper.isEmptyRender()); + }); + + it("renders nothing if there's an error aggregating reviews", function() { + const props = queryBuilder(rootQuery) + .repository(r => { + r.ref(r0 => { + r0.associatedPullRequests(conn => { + conn.totalCount(1); + conn.addNode(); + }); + }); + }) + .build(); + const patch = multiFilePatchBuilder().build(); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + const patchWrapper = resultWrapper.find(PullRequestPatchContainer).renderProp('children')(null, patch); + const reviewsWrapper = patchWrapper.find(AggregatedReviewsContainer).renderProp('children')({ + errors: [new Error('ahhhh')], + summaries: [], + commentThreads: [], + }); + + assert.isTrue(reviewsWrapper.isEmptyRender()); + }); + + it('renders a CommentPositioningContainer when the patch and reviews arrive', function() { + const props = queryBuilder(rootQuery) + .repository(r => { + r.ref(r0 => { + r0.associatedPullRequests(conn => { + conn.totalCount(1); + conn.addNode(); + }); + }); + }) + .build(); + const patch = multiFilePatchBuilder().build(); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + const patchWrapper = resultWrapper.find(PullRequestPatchContainer).renderProp('children')(null, patch); + const reviewsWrapper = patchWrapper.find(AggregatedReviewsContainer).renderProp('children')({ + errors: [], + summaries: [], + commentThreads: [], + }); + + assert.isTrue(reviewsWrapper.find(CommentPositioningContainer).exists()); + }); + + it('renders nothing while the comment positions are being calculated', function() { + const props = queryBuilder(rootQuery) + .repository(r => { + r.ref(r0 => { + r0.associatedPullRequests(conn => { + conn.totalCount(1); + conn.addNode(); + }); + }); + }) + .build(); + const patch = multiFilePatchBuilder().build(); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + const patchWrapper = resultWrapper.find(PullRequestPatchContainer).renderProp('children')(null, patch); + const reviewsWrapper = patchWrapper.find(AggregatedReviewsContainer).renderProp('children')({ + errors: [], + summaries: [], + commentThreads: [], + }); + + const positionedWrapper = reviewsWrapper.find(CommentPositioningContainer).renderProp('children')(null); + assert.isTrue(positionedWrapper.isEmptyRender()); + }); + + it('renders a CommentDecorationsController with all of the results once comment positions arrive', function() { + const props = queryBuilder(rootQuery) + .repository(r => { + r.ref(r0 => { + r0.associatedPullRequests(conn => { + conn.totalCount(1); + conn.addNode(); + }); + }); + }) + .build(); + const patch = multiFilePatchBuilder().build(); + + const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + const patchWrapper = resultWrapper.find(PullRequestPatchContainer).renderProp('children')(null, patch); + const reviewsWrapper = patchWrapper.find(AggregatedReviewsContainer).renderProp('children')({ + errors: [], + summaries: [], + commentThreads: [], + }); + + const translations = new Map(); + const positionedWrapper = reviewsWrapper.find(CommentPositioningContainer).renderProp('children')(translations); + + const controller = positionedWrapper.find(CommentDecorationsController); + assert.strictEqual(controller.prop('commentTranslations'), translations); + assert.strictEqual(controller.prop('pullRequests'), props.repository.ref.associatedPullRequests.nodes); + }); + }); +}); diff --git a/test/containers/comment-positioning-container.test.js b/test/containers/comment-positioning-container.test.js new file mode 100644 index 0000000000..22178f175e --- /dev/null +++ b/test/containers/comment-positioning-container.test.js @@ -0,0 +1,236 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import CommentPositioningContainer from '../../lib/containers/comment-positioning-container'; +import File from '../../lib/models/patch/file'; +import ObserveModel from '../../lib/views/observe-model'; +import {cloneRepository, buildRepository} from '../helpers'; +import {multiFilePatchBuilder} from '../builder/patch'; + +describe('CommentPositioningContainer', function() { + let atomEnv, localRepository; + + beforeEach(async function() { + atomEnv = global.buildAtomEnvironment(); + localRepository = await buildRepository(await cloneRepository()); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const props = { + localRepository, + multiFilePatch: multiFilePatchBuilder().build().multiFilePatch, + commentThreads: [], + prCommitSha: '0000000000000000000000000000000000000000', + children: () =>
, + translateLinesGivenDiff: () => {}, + diffPositionToFilePosition: () => {}, + ...override, + }; + + return ; + } + + it('renders its child with null while loading positions', function() { + const children = sinon.stub().returns(
); + const wrapper = shallow(buildApp({children})) + .find(ObserveModel).renderProp('children')(null); + + assert.isTrue(wrapper.exists('.done')); + assert.isTrue(children.calledWith(null)); + }); + + it('renders its child with a map of translated positions', function() { + const children = sinon.stub().returns(
); + + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file0.txt')); + }) + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file1.txt')); + }) + .build({preserveOriginal: true}); + + const commentThreads = [ + {comments: [{path: 'file0.txt', position: 1}, {path: 'ignored.txt', position: 999}]}, + {comments: [{path: 'file0.txt', position: 5}]}, + {comments: [{path: 'file1.txt', position: 11}]}, + ]; + + const diffPositionToFilePosition = (rawPositions, patch) => { + if (patch.oldPath === 'file0.txt') { + assert.sameMembers(Array.from(rawPositions), [1, 5]); + return new Map([ + [1, 10], + [5, 50], + ]); + } else if (patch.oldPath === 'file1.txt') { + assert.sameMembers(Array.from(rawPositions), [11]); + return new Map([ + [11, 16], + ]); + } else { + throw new Error(`Unexpected patch: ${patch.oldPath}`); + } + }; + + const wrapper = shallow(buildApp({children, multiFilePatch, commentThreads, diffPositionToFilePosition})) + .find(ObserveModel).renderProp('children')({}); + + assert.isTrue(wrapper.exists('.done')); + const [translations] = children.lastCall.args; + + const file0 = translations.get('file0.txt'); + assert.sameDeepMembers(Array.from(file0.diffToFilePosition), [[1, 10], [5, 50]]); + assert.isNull(file0.fileTranslations); + + const file1 = translations.get('file1.txt'); + assert.sameDeepMembers(Array.from(file1.diffToFilePosition), [[11, 16]]); + assert.isNull(file1.fileTranslations); + }); + + it('uses a single content-change diff if one is available', function() { + const children = sinon.stub().returns(
); + + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file0.txt')); + }) + .build({preserveOriginal: true}); + + const commentThreads = [ + {comments: [{path: 'file0.txt', position: 1}]}, + ]; + + const diffPositionToFilePosition = (rawPositions, patch) => { + assert.strictEqual(patch.oldPath, 'file0.txt'); + assert.sameMembers(Array.from(rawPositions), [1]); + return new Map([[1, 2]]); + }; + + const translateLinesGivenDiff = (translatedRows, diff) => { + assert.sameMembers(Array.from(translatedRows), [2]); + assert.strictEqual(diff, DIFF); + return new Map([[2, 4]]); + }; + + const DIFF = Symbol('diff payload'); + + const wrapper = shallow(buildApp({ + children, + multiFilePatch, + commentThreads, + prCommitSha: '1111111111111111111111111111111111111111', + diffPositionToFilePosition, + translateLinesGivenDiff, + })).find(ObserveModel).renderProp('children')({'file0.txt': [DIFF]}); + + assert.isTrue(wrapper.exists('.done')); + + const [translations] = children.lastCall.args; + + const file0 = translations.get('file0.txt'); + assert.sameDeepMembers(Array.from(file0.diffToFilePosition), [[1, 2]]); + assert.sameDeepMembers(Array.from(file0.fileTranslations), [[2, 4]]); + }); + + it('identifies the content change diff when two diffs are present', function() { + const children = sinon.stub().returns(
); + + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file0.txt')); + }) + .build({preserveOriginal: true}); + + const commentThreads = [ + {comments: [{path: 'file0.txt', position: 1}]}, + ]; + + const diffs = [ + {oldPath: 'file0.txt', oldMode: File.modes.SYMLINK}, + {oldPath: 'file0.txt', oldMode: File.modes.NORMAL}, + ]; + + const diffPositionToFilePosition = (rawPositions, patch) => { + assert.strictEqual(patch.oldPath, 'file0.txt'); + assert.sameMembers(Array.from(rawPositions), [1]); + return new Map([[1, 2]]); + }; + + const translateLinesGivenDiff = (translatedRows, diff) => { + assert.sameMembers(Array.from(translatedRows), [2]); + assert.strictEqual(diff, diffs[1]); + return new Map([[2, 4]]); + }; + + const wrapper = shallow(buildApp({ + children, + multiFilePatch, + commentThreads, + prCommitSha: '1111111111111111111111111111111111111111', + diffPositionToFilePosition, + translateLinesGivenDiff, + })).find(ObserveModel).renderProp('children')({'file0.txt': diffs}); + + assert.isTrue(wrapper.exists('.done')); + + const [translations] = children.lastCall.args; + + const file0 = translations.get('file0.txt'); + assert.sameDeepMembers(Array.from(file0.diffToFilePosition), [[1, 2]]); + assert.sameDeepMembers(Array.from(file0.fileTranslations), [[2, 4]]); + }); + + it("finds the content diff if it's the first one", function() { + const children = sinon.stub().returns(
); + + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file0.txt')); + }) + .build({preserveOriginal: true}); + + const commentThreads = [ + {comments: [{path: 'file0.txt', position: 1}]}, + ]; + + const diffs = [ + {oldPath: 'file0.txt', oldMode: File.modes.NORMAL}, + {oldPath: 'file0.txt', oldMode: File.modes.SYMLINK}, + ]; + + const diffPositionToFilePosition = (rawPositions, patch) => { + assert.strictEqual(patch.oldPath, 'file0.txt'); + assert.sameMembers(Array.from(rawPositions), [1]); + return new Map([[1, 2]]); + }; + + const translateLinesGivenDiff = (translatedRows, diff) => { + assert.sameMembers(Array.from(translatedRows), [2]); + assert.strictEqual(diff, diffs[0]); + return new Map([[2, 4]]); + }; + + const wrapper = shallow(buildApp({ + children, + multiFilePatch, + commentThreads, + prCommitSha: '1111111111111111111111111111111111111111', + diffPositionToFilePosition, + translateLinesGivenDiff, + })).find(ObserveModel).renderProp('children')({'file0.txt': diffs}); + + assert.isTrue(wrapper.exists('.done')); + + const [translations] = children.lastCall.args; + + const file0 = translations.get('file0.txt'); + assert.sameDeepMembers(Array.from(file0.diffToFilePosition), [[1, 2]]); + assert.sameDeepMembers(Array.from(file0.fileTranslations), [[2, 4]]); + }); +}); diff --git a/test/containers/current-pull-request-container.test.js b/test/containers/current-pull-request-container.test.js index 4b48505a21..7d37e017c2 100644 --- a/test/containers/current-pull-request-container.test.js +++ b/test/containers/current-pull-request-container.test.js @@ -1,15 +1,18 @@ import React from 'react'; -import {mount} from 'enzyme'; +import {shallow} from 'enzyme'; +import {QueryRenderer} from 'react-relay'; +import CurrentPullRequestContainer from '../../lib/containers/current-pull-request-container'; import {ManualStateObserver} from '../helpers'; -import {createPullRequestResult} from '../fixtures/factories/pull-request-result'; -import {createRepositoryResult} from '../fixtures/factories/repository-result'; +import {queryBuilder} from '../builder/graphql/query'; import Remote from '../../lib/models/remote'; import RemoteSet from '../../lib/models/remote-set'; import Branch, {nullBranch} from '../../lib/models/branch'; import BranchSet from '../../lib/models/branch-set'; -import {expectRelayQuery} from '../../lib/relay-network-layer-manager'; -import CurrentPullRequestContainer from '../../lib/containers/current-pull-request-container'; +import IssueishListController, {BareIssueishListController} from '../../lib/controllers/issueish-list-controller'; + +import repositoryQuery from '../../lib/containers/__generated__/remoteContainerQuery.graphql.js'; +import currentQuery from '../../lib/containers/__generated__/currentPullRequestContainerQuery.graphql.js'; describe('CurrentPullRequestContainer', function() { let observer; @@ -18,71 +21,26 @@ describe('CurrentPullRequestContainer', function() { observer = new ManualStateObserver(); }); - function useEmptyResult() { - return expectRelayQuery({ - name: 'currentPullRequestContainerQuery', - variables: { - headOwner: 'atom', - headName: 'github', - headRef: 'refs/heads/master', - first: 5, - }, - }, { - repository: { - ref: { - associatedPullRequests: { - totalCount: 0, - nodes: [], - }, - id: 'ref0', - }, - id: 'repository0', - }, - }); - } - - function useResults(...attrs) { - return expectRelayQuery({ - name: 'currentPullRequestContainerQuery', - variables: { - headOwner: 'atom', - headName: 'github', - headRef: 'refs/heads/master', - first: 5, - }, - }, { - repository: { - ref: { - associatedPullRequests: { - totalCount: attrs.length, - nodes: attrs.map(createPullRequestResult), - }, - id: 'ref0', - }, - id: 'repository0', - }, - }); - } - function buildApp(overrideProps = {}) { const origin = new Remote('origin', 'git@github.com:atom/github.git'); const upstreamBranch = Branch.createRemoteTracking('refs/remotes/origin/master', 'origin', 'refs/heads/master'); const branch = new Branch('master', upstreamBranch, upstreamBranch, true); - const branchSet = new BranchSet(); - branchSet.add(branch); + const branches = new BranchSet([branch]); const remotes = new RemoteSet([origin]); + const {repository} = queryBuilder(repositoryQuery).build(); + return ( null); + const remote = new Remote('elsewhere', 'git@elsewhere.wtf:atom/github.git'); + const remotes = new RemoteSet([remote]); - const wrapper = mount(buildApp()); - await assert.async.isFalse(wrapper.update().find('BareIssueishListController').prop('isLoading')); + const wrapper = shallow(buildApp({branches, remotes})); - assert.strictEqual(wrapper.find('BareIssueishListController').prop('error'), e); + assert.isFalse(wrapper.find(QueryRenderer).exists()); + + const list = wrapper.find(BareIssueishListController); + assert.isTrue(list.exists()); + assert.isFalse(list.prop('isLoading')); + assert.strictEqual(list.prop('total'), 0); + assert.lengthOf(list.prop('results'), 0); }); - it('passes a configured pull request creation tile to the controller', async function() { - const {resolve, promise} = useEmptyResult(); + it('passes an empty result list and an isLoading prop to the controller while loading', function() { + const wrapper = shallow(buildApp()); - resolve(); - await promise; + const resultWrapper = wrapper.find(QueryRenderer).renderProp('render')({error: null, props: null, retry: null}); + assert.isTrue(resultWrapper.find(BareIssueishListController).prop('isLoading')); + }); - const wrapper = mount(buildApp()); - await assert.async.isFalse(wrapper.update().find('BareIssueishListController').prop('isLoading')); + it('passes an empty result list and an error prop to the controller when errored', function() { + const error = new Error('oh no'); + error.rawStack = error.stack; - assert.isTrue(wrapper.find('CreatePullRequestTile').exists()); + const wrapper = shallow(buildApp()); + const resultWrapper = wrapper.find(QueryRenderer).renderProp('render')({error, props: null, retry: () => {}}); + + assert.strictEqual(resultWrapper.find(BareIssueishListController).prop('error'), error); + assert.isFalse(resultWrapper.find(BareIssueishListController).prop('isLoading')); }); - it('passes results to the controller', async function() { - const {resolve, promise} = useResults({number: 10}); + it('passes a configured pull request creation tile to the controller', function() { + const {repository} = queryBuilder(repositoryQuery).build(); + const remote = new Remote('home', 'git@github.com:atom/atom.git'); + const upstreamBranch = Branch.createRemoteTracking('refs/remotes/home/master', 'home', 'refs/heads/master'); + const branch = new Branch('master', upstreamBranch, upstreamBranch, true); + const branches = new BranchSet([branch]); + const remotes = new RemoteSet([remote]); + const onCreatePr = sinon.spy(); + + const wrapper = shallow(buildApp({repository, remote, remotes, branches, aheadCount: 2, pushInProgress: false, onCreatePr})); - const wrapper = mount(buildApp()); + const props = queryBuilder(currentQuery).build(); + const resultWrapper = wrapper.find(QueryRenderer).renderProp('render')({error: null, props, retry: () => {}}); - resolve(); - await promise; + const emptyTileWrapper = resultWrapper.find(IssueishListController).renderProp('emptyComponent')(); - const controller = wrapper.update().find('BareIssueishListController'); - assert.strictEqual(controller.prop('total'), 1); - assert.deepEqual(controller.prop('results').map(result => result.number), [10]); + const tile = emptyTileWrapper.find('CreatePullRequestTile'); + assert.strictEqual(tile.prop('repository'), repository); + assert.strictEqual(tile.prop('remote'), remote); + assert.strictEqual(tile.prop('branches'), branches); + assert.strictEqual(tile.prop('aheadCount'), 2); + assert.isFalse(tile.prop('pushInProgress')); + assert.strictEqual(tile.prop('onCreatePr'), onCreatePr); }); - it('filters out pull requests opened on different repositories', async function() { - const repository = createRepositoryResult({id: 'upstream-repo'}); + it('passes no results if the repository is not found', function() { + const wrapper = shallow(buildApp()); - const {resolve, promise} = useResults( - {id: 'pr0', number: 11, repositoryID: 'upstream-repo'}, - {id: 'pr1', number: 22, repositoryID: 'someones-fork'}, - ); + const props = queryBuilder(currentQuery) + .nullRepository() + .build(); - const wrapper = mount(buildApp({repository})); - resolve(); - await promise; - wrapper.update(); + const resultWrapper = wrapper.find(QueryRenderer).renderProp('render')({error: null, props, retry: () => {}}); - const numbers = wrapper.find('IssueishListView').prop('issueishes').map(i => i.getNumber()); - assert.deepEqual(numbers, [11]); + const controller = resultWrapper.find(BareIssueishListController); + assert.isFalse(controller.prop('isLoading')); + assert.strictEqual(controller.prop('total'), 0); }); - it('performs the query again when a remote operation completes', async function() { - const {resolve: resolve0, promise: promise0, disable: disable0} = useResults({number: 0}); + it('passes results to the controller', function() { + const wrapper = shallow(buildApp()); + + const props = queryBuilder(currentQuery) + .repository(r => { + r.ref(r0 => { + r0.associatedPullRequests(conn => { + conn.addNode(); + conn.addNode(); + conn.addNode(); + }); + }); + }) + .build(); + + const resultWrapper = wrapper.find(QueryRenderer).renderProp('render')({error: null, props, retry: () => {}}); + + const controller = resultWrapper.find(IssueishListController); + assert.strictEqual(controller.prop('total'), 3); + }); - const wrapper = mount(buildApp()); + it('filters out pull requests opened on different repositories', function() { + const {repository} = queryBuilder(repositoryQuery) + .repository(r => r.id('100')) + .build(); - resolve0(); - await promise0; + const wrapper = shallow(buildApp({repository})); - wrapper.update(); - const controller = wrapper.find('BareIssueishListController'); - assert.deepEqual(controller.prop('results').map(result => result.number), [0]); + const props = queryBuilder(currentQuery).build(); + const resultWrapper = wrapper.find(QueryRenderer).renderProp('render')({error: null, props, retry: () => {}}); - disable0(); - const {resolve: resolve1, promise: promise1} = useResults({number: 1}); + const filterFn = resultWrapper.find(IssueishListController).prop('resultFilter'); + assert.isTrue(filterFn({getHeadRepositoryID: () => '100'})); + assert.isFalse(filterFn({getHeadRepositoryID: () => '12'})); + }); - resolve1(); - await promise1; + it('performs the query again when a remote operation completes', function() { + const wrapper = shallow(buildApp()); - wrapper.update(); - assert.deepEqual(controller.prop('results').map(result => result.number), [0]); + const props = queryBuilder(currentQuery).build(); + const retry = sinon.spy(); + wrapper.find(QueryRenderer).renderProp('render')({error: null, props, retry}); observer.trigger(); - - await assert.async.deepEqual( - wrapper.update().find('BareIssueishListController').prop('results').map(result => result.number), - [1], - ); + assert.isTrue(retry.called); }); }); diff --git a/test/containers/issueish-detail-container.test.js b/test/containers/issueish-detail-container.test.js index d8323e8f89..3b1a33617b 100644 --- a/test/containers/issueish-detail-container.test.js +++ b/test/containers/issueish-detail-container.test.js @@ -1,135 +1,363 @@ import React from 'react'; -import {mount} from 'enzyme'; +import {shallow} from 'enzyme'; +import {QueryRenderer} from 'react-relay'; +import IssueishDetailContainer from '../../lib/containers/issueish-detail-container'; import {cloneRepository, buildRepository} from '../helpers'; -import {expectRelayQuery} from '../../lib/relay-network-layer-manager'; -import {issueishDetailContainerProps} from '../fixtures/props/issueish-pane-props'; -import {createPullRequestDetailResult} from '../fixtures/factories/pull-request-result'; -import {PAGE_SIZE} from '../../lib/helpers'; +import {queryBuilder} from '../builder/graphql/query'; +import {aggregatedReviewsBuilder} from '../builder/graphql/aggregated-reviews-builder'; import GithubLoginModel from '../../lib/models/github-login-model'; -import {InMemoryStrategy, UNAUTHENTICATED} from '../../lib/shared/keytar-strategy'; -import IssueishDetailContainer from '../../lib/containers/issueish-detail-container'; +import RefHolder from '../../lib/models/ref-holder'; +import {getEndpoint} from '../../lib/models/endpoint'; +import {InMemoryStrategy, UNAUTHENTICATED, INSUFFICIENT} from '../../lib/shared/keytar-strategy'; +import ObserveModel from '../../lib/views/observe-model'; +import IssueishDetailItem from '../../lib/items/issueish-detail-item'; +import IssueishDetailController from '../../lib/controllers/issueish-detail-controller'; +import AggregatedReviewsContainer from '../../lib/containers/aggregated-reviews-container'; + +import rootQuery from '../../lib/containers/__generated__/issueishDetailContainerQuery.graphql'; describe('IssueishDetailContainer', function() { - let loginModel, repository; + let atomEnv, loginModel, repository; beforeEach(async function() { + atomEnv = global.buildAtomEnvironment(); loginModel = new GithubLoginModel(InMemoryStrategy); + repository = await buildRepository(await cloneRepository()); + }); - const workDir = await cloneRepository(); - repository = await buildRepository(workDir); + afterEach(function() { + atomEnv.destroy(); }); - function useResult() { - return expectRelayQuery({ - name: 'issueishDetailContainerQuery', - variables: { - repoOwner: 'owner', - repoName: 'repo', - issueishNumber: 1, - timelineCount: PAGE_SIZE, - timelineCursor: null, - commitCount: PAGE_SIZE, - commitCursor: null, - commentCount: PAGE_SIZE, - commentCursor: null, - reviewCount: PAGE_SIZE, - reviewCursor: null, - }, - }, { - repository: { - id: 'repository0', - name: 'repo', - owner: { - __typename: 'User', - login: 'owner', - id: 'user0', - }, - issueish: createPullRequestDetailResult(), - }, - }); - } + function buildApp(override = {}) { + const props = { + endpoint: getEndpoint('github.com'), + + owner: 'atom', + repo: 'github', + issueishNumber: 123, + + selectedTab: 0, + onTabSelected: () => {}, + onOpenFilesTab: () => {}, + + repository, + loginModel, - function buildApp(overrideProps = {}) { - return ; + workspace: atomEnv.workspace, + commands: atomEnv.commands, + keymaps: atomEnv.keymaps, + tooltips: atomEnv.tooltips, + config: atomEnv.config, + + switchToIssueish: () => {}, + onTitleChange: () => {}, + destroy: () => {}, + + itemType: IssueishDetailItem, + refEditor: new RefHolder(), + + ...override, + }; + + return ; } - it('renders a spinner while the token is being fetched', function() { - const wrapper = mount(buildApp()); - assert.isTrue(wrapper.find('LoadingView').exists()); + it('renders a spinner while the token is being fetched', async function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find(ObserveModel).renderProp('children')(null); + + const repoData = await tokenWrapper.find(ObserveModel).prop('fetchData')( + tokenWrapper.find(ObserveModel).prop('model'), + ); + const repoWrapper = tokenWrapper.find(ObserveModel).renderProp('children')(repoData); + + // Don't render the GraphQL query before the token is available + assert.isTrue(repoWrapper.exists('LoadingView')); }); - it('renders a login prompt if the user is unauthenticated', async function() { - const wrapper = mount(buildApp()); - await assert.async.isTrue(wrapper.update().find('GithubLoginView').exists()); + it('renders a login prompt if the user is unauthenticated', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find(ObserveModel).renderProp('children')({token: UNAUTHENTICATED}); + + assert.isTrue(tokenWrapper.exists('GithubLoginView')); }); - it("renders a login prompt if the user's token has insufficient scopes", async function() { - loginModel.setToken('https://api.github.com', '1234'); - sinon.stub(loginModel, 'getScopes').resolves(['not-enough']); + it("renders a login prompt if the user's token has insufficient scopes", function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find(ObserveModel).renderProp('children')({token: INSUFFICIENT}); - const wrapper = mount(buildApp()); - await assert.async.isTrue(wrapper.update().find('GithubLoginView').exists()); + assert.isTrue(tokenWrapper.exists('GithubLoginView')); + assert.match(tokenWrapper.find('p').text(), /re-authenticate/); }); - it('re-renders on login', async function() { - useResult(); - sinon.stub(loginModel, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES); + it('passes the token to the login model on login', async function() { + sinon.stub(loginModel, 'setToken').resolves(); - const wrapper = mount(buildApp()); + const wrapper = shallow(buildApp({ + endpoint: getEndpoint('github.enterprise.horse'), + })); + const tokenWrapper = wrapper.find(ObserveModel).renderProp('children')({token: UNAUTHENTICATED}); + + await tokenWrapper.find('GithubLoginView').prop('onLogin')('4321'); + assert.isTrue(loginModel.setToken.calledWith('https://github.enterprise.horse', '4321')); + }); - await assert.async.isTrue(wrapper.update().find('GithubLoginView').exists()); - wrapper.find('GithubLoginView').prop('onLogin')('4321'); + it('renders a spinner while repository data is being fetched', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find(ObserveModel).renderProp('children')({token: '1234'}); + const repoWrapper = tokenWrapper.find(ObserveModel).renderProp('children')(null); - await assert.async.isTrue(wrapper.update().find('ReactRelayQueryRenderer').exists()); + const props = queryBuilder(rootQuery).build(); + const resultWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + + assert.isTrue(resultWrapper.exists('LoadingView')); }); it('renders a spinner while the GraphQL query is being performed', async function() { - useResult(); + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find(ObserveModel).renderProp('children')({token: '1234'}); - loginModel.setToken('https://api.github.com', '1234'); - sinon.spy(loginModel, 'getToken'); - sinon.stub(loginModel, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES); + const repoData = await tokenWrapper.find(ObserveModel).prop('fetchData')( + tokenWrapper.find(ObserveModel).prop('model'), + ); + const repoWrapper = tokenWrapper.find(ObserveModel).renderProp('children')(repoData); - const wrapper = mount(buildApp()); - await loginModel.getToken.returnValues[0]; + const resultWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props: null, retry: () => {}, + }); - assert.isTrue(wrapper.update().find('LoadingView').exists()); + assert.isTrue(resultWrapper.exists('LoadingView')); }); it('renders an error view if the GraphQL query fails', async function() { - const {reject, promise} = useResult(); + const wrapper = shallow(buildApp({ + endpoint: getEndpoint('github.enterprise.horse'), + })); + const tokenWrapper = wrapper.find(ObserveModel).renderProp('children')({token: '1234'}); - loginModel.setToken('https://api.github.com', '1234'); - sinon.stub(loginModel, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES); + const repoData = await tokenWrapper.find(ObserveModel).prop('fetchData')( + tokenWrapper.find(ObserveModel).prop('model'), + ); + const repoWrapper = tokenWrapper.find(ObserveModel).renderProp('children')(repoData); + + const error = new Error('wat'); + error.rawStack = error.stack; + const retry = sinon.spy(); + const resultWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({ + error, props: null, retry, + }); - const wrapper = mount(buildApp()); - const e = new Error('wat'); - e.rawStack = e.stack; - reject(e); - await promise.catch(() => {}); + const errorView = resultWrapper.find('QueryErrorView'); + assert.strictEqual(errorView.prop('error'), error); - await assert.async.isTrue(wrapper.update().find('QueryErrorView').exists()); - const ev = wrapper.find('QueryErrorView'); - assert.strictEqual(ev.prop('error'), e); + errorView.prop('retry')(); + assert.isTrue(retry.called); - assert.strictEqual(await loginModel.getToken('https://api.github.com'), '1234'); - await ev.prop('logout')(); - assert.strictEqual(await loginModel.getToken('https://api.github.com'), UNAUTHENTICATED); + sinon.stub(loginModel, 'removeToken').resolves(); + await errorView.prop('logout')(); + assert.isTrue(loginModel.removeToken.calledWith('https://github.enterprise.horse')); + + sinon.stub(loginModel, 'setToken').resolves(); + await errorView.prop('login')('1234'); + assert.isTrue(loginModel.setToken.calledWith('https://github.enterprise.horse', '1234')); }); - it('passes GraphQL query results to an IssueishDetailController', async function() { - const {resolve} = useResult(); - loginModel.setToken('https://api.github.com', '1234'); - sinon.stub(loginModel, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES); + it('renders an IssueishDetailContainer with GraphQL results for an issue', async function() { + const wrapper = shallow(buildApp({ + owner: 'smashwilson', + repo: 'pushbot', + issueishNumber: 4000, + })); + + const tokenWrapper = wrapper.find(ObserveModel).renderProp('children')({token: '1234'}); + + const repoData = await tokenWrapper.find(ObserveModel).prop('fetchData')( + tokenWrapper.find(ObserveModel).prop('model'), + ); + const repoWrapper = tokenWrapper.find(ObserveModel).renderProp('children')(repoData); + + const variables = repoWrapper.find(QueryRenderer).prop('variables'); + assert.strictEqual(variables.repoOwner, 'smashwilson'); + assert.strictEqual(variables.repoName, 'pushbot'); + assert.strictEqual(variables.issueishNumber, 4000); + + const props = queryBuilder(rootQuery) + .repository(r => r.issueish(i => i.beIssue())) + .build(); + const resultWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + + const controller = resultWrapper.find(IssueishDetailController); + + // GraphQL query results + assert.strictEqual(controller.prop('repository'), props.repository); + + // Null data for aggregated comment threads + assert.isFalse(controller.prop('reviewCommentsLoading')); + assert.strictEqual(controller.prop('reviewCommentsTotalCount'), 0); + assert.strictEqual(controller.prop('reviewCommentsResolvedCount'), 0); + assert.lengthOf(controller.prop('reviewCommentThreads'), 0); + + // Requested repository attributes + assert.strictEqual(controller.prop('branches'), repoData.branches); + + // The local repository, passed with a different name to not collide with the GraphQL result + assert.strictEqual(controller.prop('localRepository'), repository); + assert.strictEqual(controller.prop('workdirPath'), repository.getWorkingDirectoryPath()); + + // The GitHub OAuth token + assert.strictEqual(controller.prop('token'), '1234'); + }); + + it('renders an IssueishDetailController while aggregating reviews for a pull request', async function() { + const wrapper = shallow(buildApp({ + owner: 'smashwilson', + repo: 'pushbot', + issueishNumber: 4000, + })); + + const tokenWrapper = wrapper.find(ObserveModel).renderProp('children')({token: '1234'}); + + const repoData = await tokenWrapper.find(ObserveModel).prop('fetchData')( + tokenWrapper.find(ObserveModel).prop('model'), + ); + const repoWrapper = tokenWrapper.find(ObserveModel).renderProp('children')(repoData); + + const props = queryBuilder(rootQuery) + .repository(r => r.issueish(i => i.bePullRequest())) + .build(); + const resultWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + + const reviews = aggregatedReviewsBuilder().loading(true).build(); + assert.strictEqual(resultWrapper.find(AggregatedReviewsContainer).prop('pullRequest'), props.repository.issueish); + const reviewsWrapper = resultWrapper.find(AggregatedReviewsContainer).renderProp('children')(reviews); + + const controller = reviewsWrapper.find(IssueishDetailController); + + // GraphQL query results + assert.strictEqual(controller.prop('repository'), props.repository); + + // Null data for aggregated comment threads + assert.isTrue(controller.prop('reviewCommentsLoading')); + assert.strictEqual(controller.prop('reviewCommentsTotalCount'), 0); + assert.strictEqual(controller.prop('reviewCommentsResolvedCount'), 0); + assert.lengthOf(controller.prop('reviewCommentThreads'), 0); + + // Requested repository attributes + assert.strictEqual(controller.prop('branches'), repoData.branches); + + // The local repository, passed with a different name to not collide with the GraphQL result + assert.strictEqual(controller.prop('localRepository'), repository); + assert.strictEqual(controller.prop('workdirPath'), repository.getWorkingDirectoryPath()); + + // The GitHub OAuth token + assert.strictEqual(controller.prop('token'), '1234'); + }); + + it('renders an error view if the review aggregation fails', async function() { + const wrapper = shallow(buildApp({ + endpoint: getEndpoint('github.enterprise.horse'), + })); + + const tokenWrapper = wrapper.find(ObserveModel).renderProp('children')({token: '1234'}); + + const repoData = await tokenWrapper.find(ObserveModel).prop('fetchData')( + tokenWrapper.find(ObserveModel).prop('model'), + ); + const repoWrapper = tokenWrapper.find(ObserveModel).renderProp('children')(repoData); + + const props = queryBuilder(rootQuery) + .repository(r => r.issueish(i => i.bePullRequest())) + .build(); + const retry = sinon.spy(); + const resultWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry, + }); + + const reviews = aggregatedReviewsBuilder() + .addError(new Error("It's not DNS")) + .addError(new Error("There's no way it's DNS")) + .addError(new Error('It was DNS')) + .build(); + const reviewsWrapper = resultWrapper.find(AggregatedReviewsContainer).renderProp('children')(reviews); + + const errorView = reviewsWrapper.find('ErrorView'); + assert.strictEqual(errorView.prop('title'), 'Unable to fetch review comments'); + assert.deepEqual(errorView.prop('descriptions'), [ + "Error: It's not DNS", + "Error: There's no way it's DNS", + 'Error: It was DNS', + ]); + + errorView.prop('retry')(); + assert.isTrue(retry.called); + + sinon.stub(loginModel, 'removeToken').resolves(); + await errorView.prop('logout')(); + assert.isTrue(loginModel.removeToken.calledWith('https://github.enterprise.horse')); + }); + + it('passes GraphQL query results and aggregated reviews to its IssueishDetailController', async function() { + const wrapper = shallow(buildApp({ + owner: 'smashwilson', + repo: 'pushbot', + issueishNumber: 4000, + })); + + const tokenWrapper = wrapper.find(ObserveModel).renderProp('children')({token: '1234'}); + + const repoData = await tokenWrapper.find(ObserveModel).prop('fetchData')( + tokenWrapper.find(ObserveModel).prop('model'), + ); + const repoWrapper = tokenWrapper.find(ObserveModel).renderProp('children')(repoData); + + const variables = repoWrapper.find(QueryRenderer).prop('variables'); + assert.strictEqual(variables.repoOwner, 'smashwilson'); + assert.strictEqual(variables.repoName, 'pushbot'); + assert.strictEqual(variables.issueishNumber, 4000); + + const props = queryBuilder(rootQuery) + .repository(r => r.issueish(i => i.bePullRequest())) + .build(); + const resultWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({ + error: null, props, retry: () => {}, + }); + + const reviews = aggregatedReviewsBuilder() + .addReviewThread(b => b.thread(t => t.isResolved(true)).addComment()) + .addReviewThread(b => b.thread(t => t.isResolved(false)).addComment().addComment()) + .addReviewThread(b => b.thread(t => t.isResolved(false))) + .addReviewThread(b => b.thread(t => t.isResolved(false)).addComment()) + .addReviewThread(b => b.thread(t => t.isResolved(true))) + .build(); + const reviewsWrapper = resultWrapper.find(AggregatedReviewsContainer).renderProp('children')(reviews); + + const controller = reviewsWrapper.find(IssueishDetailController); + + // GraphQL query results + assert.strictEqual(controller.prop('repository'), props.repository); + + // Aggregated comment thread data + assert.isFalse(controller.prop('reviewCommentsLoading')); + assert.strictEqual(controller.prop('reviewCommentsTotalCount'), 3); + assert.strictEqual(controller.prop('reviewCommentsResolvedCount'), 1); + assert.lengthOf(controller.prop('reviewCommentThreads'), 3); - const wrapper = mount(buildApp()); - resolve(); + // Requested repository attributes + assert.strictEqual(controller.prop('branches'), repoData.branches); - await assert.async.isTrue(wrapper.update().find('BareIssueishDetailController').exists()); + // The local repository, passed with a different name to not collide with the GraphQL result + assert.strictEqual(controller.prop('localRepository'), repository); + assert.strictEqual(controller.prop('workdirPath'), repository.getWorkingDirectoryPath()); - const controller = wrapper.find('BareIssueishDetailController'); - assert.isDefined(controller.prop('repository')); - assert.strictEqual(controller.prop('issueishNumber'), 1); + // The GitHub OAuth token + assert.strictEqual(controller.prop('token'), '1234'); }); }); diff --git a/test/containers/pr-changed-files-container.test.js b/test/containers/pr-changed-files-container.test.js index 70cf28333a..6e891c4df8 100644 --- a/test/containers/pr-changed-files-container.test.js +++ b/test/containers/pr-changed-files-container.test.js @@ -1,18 +1,13 @@ import React from 'react'; import {shallow} from 'enzyme'; -import {parse as parseDiff} from 'what-the-diff'; -import path from 'path'; -import {rawDiff, rawDiffWithPathPrefix} from '../fixtures/diffs/raw-diff'; -import {buildMultiFilePatch} from '../../lib/models/patch'; import {getEndpoint} from '../../lib/models/endpoint'; +import {multiFilePatchBuilder} from '../builder/patch'; import PullRequestChangedFilesContainer from '../../lib/containers/pr-changed-files-container'; import IssueishDetailItem from '../../lib/items/issueish-detail-item'; describe('PullRequestChangedFilesContainer', function() { - let diffResponse; - function buildApp(overrideProps = {}) { return ( {}} {...overrideProps} /> ); } - function setDiffResponse(body) { - diffResponse = new window.Response(body, { - status: 200, - headers: {'Content-type': 'text/plain'}, + describe('when the patch is loading', function() { + it('renders a LoadingView', function() { + const wrapper = shallow(buildApp()); + const subwrapper = wrapper.find('PullRequestPatchContainer').renderProp('children')(null, null); + assert.isTrue(subwrapper.exists('LoadingView')); }); - } + }); + + describe('when the patch is fetched successfully', function() { + it('passes the MultiFilePatch to a MultiFilePatchController', function() { + const {multiFilePatch} = multiFilePatchBuilder().build(); - describe('when the data is able to be fetched successfully', function() { - beforeEach(function() { - setDiffResponse(rawDiff); - sinon.stub(window, 'fetch').callsFake(() => Promise.resolve(diffResponse)); - }); - it('renders a loading spinner if data has not yet been fetched', function() { const wrapper = shallow(buildApp()); - assert.isTrue(wrapper.find('LoadingView').exists()); + const subwrapper = wrapper.find('PullRequestPatchContainer').renderProp('children')(null, multiFilePatch); + + assert.strictEqual(subwrapper.find('MultiFilePatchController').prop('multiFilePatch'), multiFilePatch); }); - it('passes extra props through to PullRequestChangedFilesController', async function() { + + it('passes extra props through to MultiFilePatchController', function() { + const {multiFilePatch} = multiFilePatchBuilder().build(); const extraProp = Symbol('really really extra'); const wrapper = shallow(buildApp({extraProp})); - await assert.async.isTrue(wrapper.update().find('MultiFilePatchController').exists()); + const subwrapper = wrapper.find('PullRequestPatchContainer').renderProp('children')(null, multiFilePatch); - const controller = wrapper.find('MultiFilePatchController'); - assert.strictEqual(controller.prop('extraProp'), extraProp); + assert.strictEqual(subwrapper.find('MultiFilePatchController').prop('extraProp'), extraProp); }); - it('builds the diff URL', function() { - const wrapper = shallow(buildApp({ - owner: 'smashwilson', - repo: 'pushbot', - number: 12, - endpoint: getEndpoint('github.com'), - })); - - const diffURL = wrapper.instance().getDiffURL(); - assert.strictEqual(diffURL, 'https://api.github.com/repos/smashwilson/pushbot/pulls/12'); + it('re-fetches data when shouldRefetch is true', function() { + const wrapper = shallow(buildApp({shouldRefetch: true})); + assert.isTrue(wrapper.find('PullRequestPatchContainer').prop('refetch')); }); - it('builds multifilepatch without the a/ and b/ prefixes in file paths', function() { - const wrapper = shallow(buildApp()); - const {filePatches} = wrapper.instance().buildPatch(rawDiffWithPathPrefix); - assert.notMatch(filePatches[0].newFile.path, /^[a|b]\//); - assert.notMatch(filePatches[0].oldFile.path, /^[a|b]\//); - }); + it('manages a subscription on the active MultiFilePatch', function() { + const {multiFilePatch: mfp0} = multiFilePatchBuilder().addFilePatch().build(); - it('converts file paths to use native path separators', function() { const wrapper = shallow(buildApp()); - const {filePatches} = wrapper.instance().buildPatch(rawDiffWithPathPrefix); - assert.strictEqual(filePatches[0].newFile.path, path.join('bad/path.txt')); - assert.strictEqual(filePatches[0].oldFile.path, path.join('bad/path.txt')); - }); + wrapper.find('PullRequestPatchContainer').renderProp('children')(null, mfp0); - it('passes loaded diff data through to the controller', async function() { - const wrapper = shallow(buildApp({ - token: '4321', - })); - await assert.async.isTrue(wrapper.update().find('MultiFilePatchController').exists()); - - const controller = wrapper.find('MultiFilePatchController'); - const expected = buildMultiFilePatch(parseDiff(rawDiff)); - assert.isTrue(controller.prop('multiFilePatch').isEqual(expected)); - - assert.deepEqual(window.fetch.lastCall.args, [ - 'https://api.github.com/repos/atom/github/pulls/1804', - { - headers: { - Accept: 'application/vnd.github.v3.diff', - Authorization: 'bearer 4321', - }, - }, - ]); - }); - it('re fetches data when shouldRefetch is true', async function() { - const wrapper = shallow(buildApp()); - await assert.async.isTrue(wrapper.update().find('MultiFilePatchController').exists()); - assert.strictEqual(window.fetch.callCount, 1); - wrapper.setProps({shouldRefetch: true}); - assert.isTrue(wrapper.instance().state.isLoading); - await assert.async.strictEqual(window.fetch.callCount, 2); + assert.strictEqual(mfp0.getFilePatches()[0].emitter.listenerCountForEventName('change-render-status'), 1); + + wrapper.find('PullRequestPatchContainer').renderProp('children')(null, mfp0); + assert.strictEqual(mfp0.getFilePatches()[0].emitter.listenerCountForEventName('change-render-status'), 1); + + const {multiFilePatch: mfp1} = multiFilePatchBuilder().addFilePatch().build(); + wrapper.find('PullRequestPatchContainer').renderProp('children')(null, mfp1); + + assert.strictEqual(mfp0.getFilePatches()[0].emitter.listenerCountForEventName('change-render-status'), 0); + assert.strictEqual(mfp1.getFilePatches()[0].emitter.listenerCountForEventName('change-render-status'), 1); }); - it('disposes MFP subscription on unmount', async function() { + + it('disposes the MultiFilePatch subscription on unmount', function() { + const {multiFilePatch} = multiFilePatchBuilder().addFilePatch().build(); + const wrapper = shallow(buildApp()); - await assert.async.isTrue(wrapper.update().find('MultiFilePatchController').exists()); + const subwrapper = wrapper.find('PullRequestPatchContainer').renderProp('children')(null, multiFilePatch); - const mfp = wrapper.find('MultiFilePatchController').prop('multiFilePatch'); + const mfp = subwrapper.find('MultiFilePatchController').prop('multiFilePatch'); const [fp] = mfp.getFilePatches(); assert.strictEqual(fp.emitter.listenerCountForEventName('change-render-status'), 1); @@ -128,34 +99,14 @@ describe('PullRequestChangedFilesContainer', function() { }); }); - describe('error states', function() { - async function assertErrorRendered(expectedErrorMessage, wrapper) { - await assert.async.deepEqual(wrapper.update().instance().state.error, expectedErrorMessage); - const errorView = wrapper.find('ErrorView'); - assert.deepEqual(errorView.prop('descriptions'), [expectedErrorMessage]); - } - it('renders an error if network request fails', async function() { - sinon.stub(window, 'fetch').callsFake(() => Promise.reject(new Error('oh shit'))); - const wrapper = shallow(buildApp()); - await assertErrorRendered('Network error encountered at fetching https://api.github.com/repos/atom/github/pulls/1804', wrapper); - }); + describe('when the patch load fails', function() { + it('renders the message in an ErrorView', function() { + const error = 'oh noooooo'; - it('renders an error if diff parsing fails', async function() { - setDiffResponse('bad diff no treat for you'); - sinon.stub(window, 'fetch').callsFake(() => Promise.resolve(diffResponse)); const wrapper = shallow(buildApp()); - await assertErrorRendered('Unable to parse diff for this pull request.', wrapper); - }); + const subwrapper = wrapper.find('PullRequestPatchContainer').renderProp('children')(error, null); - it('renders an error if fetch returns a non-ok response', async function() { - const badResponse = new window.Response(rawDiff, { - status: 404, - statusText: 'oh noes', - headers: {'Content-type': 'text/plain'}, - }); - sinon.stub(window, 'fetch').callsFake(() => Promise.resolve(badResponse)); - const wrapper = shallow(buildApp()); - await assertErrorRendered('Unable to fetch diff for this pull request: oh noes.', wrapper); + assert.deepEqual(subwrapper.find('ErrorView').prop('descriptions'), [error]); }); }); }); diff --git a/test/containers/pr-patch-container.test.js b/test/containers/pr-patch-container.test.js new file mode 100644 index 0000000000..afb31e14cf --- /dev/null +++ b/test/containers/pr-patch-container.test.js @@ -0,0 +1,260 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import path from 'path'; + +import PullRequestPatchContainer from '../../lib/containers/pr-patch-container'; +import {rawDiff, rawDiffWithPathPrefix, rawAdditionDiff, rawDeletionDiff} from '../fixtures/diffs/raw-diff'; +import {getEndpoint} from '../../lib/models/endpoint'; + +describe('PullRequestPatchContainer', function() { + function buildApp(override = {}) { + const props = { + owner: 'atom', + repo: 'github', + number: 1995, + endpoint: getEndpoint('github.com'), + token: '1234', + refetch: false, + children: () => null, + ...override, + }; + + return ; + } + + function setDiffResponse(body, options) { + const opts = { + status: 200, + statusText: 'OK', + getResolver: cb => cb(), + ...options, + }; + + return sinon.stub(window, 'fetch').callsFake(() => { + const resp = new window.Response(body, { + status: opts.status, + statusText: opts.statusText, + headers: {'Content-type': 'text/plain'}, + }); + + let resolveResponsePromise = null; + const promise = new Promise(resolve => { + resolveResponsePromise = resolve; + }); + opts.getResolver(() => resolveResponsePromise(resp)); + return promise; + }); + } + + describe('while the patch is loading', function() { + it('renders its child prop with nulls', function() { + setDiffResponse(rawDiff); + + const children = sinon.spy(); + shallow(buildApp({children})); + assert.isTrue(children.calledWith(null, null)); + }); + }); + + describe('when the patch has been fetched successfully', function() { + it('builds the correct request', async function() { + const stub = setDiffResponse(rawDiff); + const children = sinon.spy(); + shallow(buildApp({ + owner: 'smashwilson', + repo: 'pushbot', + number: 12, + endpoint: getEndpoint('github.com'), + token: 'swordfish', + children, + })); + + assert.isTrue(stub.calledWith( + 'https://api.github.com/repos/smashwilson/pushbot/pulls/12', + { + headers: { + Accept: 'application/vnd.github.v3.diff', + Authorization: 'bearer swordfish', + }, + }, + )); + + await assert.async.strictEqual(children.callCount, 2); + + const [error, mfp] = children.lastCall.args; + assert.isNull(error); + + assert.lengthOf(mfp.getFilePatches(), 1); + const [fp] = mfp.getFilePatches(); + assert.strictEqual(fp.getOldFile().getPath(), 'file.txt'); + assert.strictEqual(fp.getNewFile().getPath(), 'file.txt'); + assert.lengthOf(fp.getHunks(), 1); + const [h] = fp.getHunks(); + assert.strictEqual(h.getSectionHeading(), 'class Thing {'); + }); + + it('modifies the patch to exclude a/ and b/ prefixes on file paths', async function() { + setDiffResponse(rawDiffWithPathPrefix); + + const children = sinon.spy(); + shallow(buildApp({children})); + + await assert.async.strictEqual(children.callCount, 2); + const [error, mfp] = children.lastCall.args; + + assert.isNull(error); + assert.lengthOf(mfp.getFilePatches(), 1); + const [fp] = mfp.getFilePatches(); + assert.notMatch(fp.getOldFile().getPath(), /^[a|b]\//); + assert.notMatch(fp.getNewFile().getPath(), /^[a|b]\//); + }); + + it('excludes a/ prefix on the old file of a deletion', async function() { + setDiffResponse(rawDeletionDiff); + + const children = sinon.spy(); + shallow(buildApp({children})); + + await assert.async.strictEqual(children.callCount, 2); + const [error, mfp] = children.lastCall.args; + + assert.isNull(error); + assert.lengthOf(mfp.getFilePatches(), 1); + const [fp] = mfp.getFilePatches(); + assert.strictEqual(fp.getOldFile().getPath(), 'deleted'); + assert.isFalse(fp.getNewFile().isPresent()); + }); + + it('excludes b/ prefix on the new file of an addition', async function() { + setDiffResponse(rawAdditionDiff); + + const children = sinon.spy(); + shallow(buildApp({children})); + + await assert.async.strictEqual(children.callCount, 2); + const [error, mfp] = children.lastCall.args; + + assert.isNull(error); + assert.lengthOf(mfp.getFilePatches(), 1); + const [fp] = mfp.getFilePatches(); + assert.isFalse(fp.getOldFile().isPresent()); + assert.strictEqual(fp.getNewFile().getPath(), 'added'); + }); + + it('converts file paths to use native path separators', async function() { + setDiffResponse(rawDiffWithPathPrefix); + const children = sinon.spy(); + + shallow(buildApp({children})); + + await assert.async.strictEqual(children.callCount, 2); + const [error, mfp] = children.lastCall.args; + + assert.isNull(error); + assert.lengthOf(mfp.getFilePatches(), 1); + const [fp] = mfp.getFilePatches(); + assert.strictEqual(fp.getNewFile().getPath(), path.join('bad/path.txt')); + assert.strictEqual(fp.getOldFile().getPath(), path.join('bad/path.txt')); + }); + + it('does not setState if the component has been unmounted', async function() { + let resolve = null; + setDiffResponse(rawDiff, { + getResolver(cb) { resolve = cb; }, + }); + const children = sinon.spy(); + const wrapper = shallow(buildApp({children})); + const fetchDiffSpy = sinon.spy(wrapper.instance(), 'fetchDiff'); + wrapper.setProps({refetch: true}); + + assert.isTrue(children.calledWith(null, null)); + const setStateSpy = sinon.spy(wrapper.instance(), 'setState'); + wrapper.unmount(); + + resolve(); + await fetchDiffSpy.lastCall.returnValue; + + assert.isFalse(setStateSpy.called); + }); + }); + + describe('when there has been an error', function() { + it('reports an error when the network request fails', async function() { + const output = sinon.stub(console, 'error'); + sinon.stub(window, 'fetch').rejects(new Error('kerPOW')); + + const children = sinon.spy(); + shallow(buildApp({children})); + + await assert.async.strictEqual(children.callCount, 2); + const [error, mfp] = children.lastCall.args; + + assert.strictEqual(error, 'Network error encountered fetching the patch: kerPOW.'); + assert.isNull(mfp); + assert.isTrue(output.called); + }); + + it('reports an error if the fetch returns a non-OK response', async function() { + setDiffResponse('ouch', { + status: 404, + statusText: 'Not found', + }); + + const children = sinon.spy(); + shallow(buildApp({children})); + + await assert.async.strictEqual(children.callCount, 2); + const [error, mfp] = children.lastCall.args; + + assert.strictEqual(error, 'Unable to fetch the diff for this pull request: Not found.'); + assert.isNull(mfp); + }); + + it('reports an error if the patch cannot be parsed', async function() { + const output = sinon.stub(console, 'error'); + setDiffResponse('bad diff no treat for you'); + + const children = sinon.spy(); + shallow(buildApp({children})); + + await assert.async.strictEqual(children.callCount, 2); + const [error, mfp] = children.lastCall.args; + + assert.strictEqual(error, 'Unable to parse the diff for this pull request.'); + assert.isNull(mfp); + assert.isTrue(output.called); + }); + }); + + describe('when a refetch is requested', function() { + it('refetches patch data on the first render', async function() { + const fetch = setDiffResponse(rawDiff); + + const children = sinon.spy(); + const wrapper = shallow(buildApp({children})); + assert.strictEqual(fetch.callCount, 1); + assert.isTrue(children.calledWith(null, null)); + + await assert.async.strictEqual(children.callCount, 2); + + wrapper.setProps({refetch: true}); + assert.strictEqual(fetch.callCount, 2); + }); + + it('does not refetch data on additional renders', async function() { + const fetch = setDiffResponse(rawDiff); + + const children = sinon.spy(); + const wrapper = shallow(buildApp({children, refetch: true})); + assert.strictEqual(fetch.callCount, 1); + assert.strictEqual(children.callCount, 1); + + await assert.async.strictEqual(children.callCount, 2); + + wrapper.setProps({refetch: true}); + + assert.strictEqual(fetch.callCount, 1); + assert.strictEqual(children.callCount, 3); + }); + }); +}); diff --git a/test/containers/pr-review-comments-container.test.js b/test/containers/pr-review-comments-container.test.js deleted file mode 100644 index df3d9ea318..0000000000 --- a/test/containers/pr-review-comments-container.test.js +++ /dev/null @@ -1,131 +0,0 @@ -import React from 'react'; -import {shallow} from 'enzyme'; - -import {PAGE_SIZE, PAGINATION_WAIT_TIME_MS} from '../../lib/helpers'; - -import {BarePullRequestReviewCommentsContainer} from '../../lib/containers/pr-review-comments-container'; - -describe('PullRequestReviewCommentsContainer', function() { - const review = { - id: '123', - submittedAt: '2018-12-27T20:40:55Z', - comments: {edges: ['this kiki is marvelous']}, - }; - - function buildApp(opts, overrideProps = {}) { - const o = { - relayHasMore: () => { return false; }, - relayLoadMore: () => {}, - relayIsLoading: () => { return false; }, - ...opts, - }; - - const props = { - relay: { - hasMore: o.relayHasMore, - loadMore: o.relayLoadMore, - isLoading: o.relayIsLoading, - }, - collectComments: () => {}, - review, - ...overrideProps, - }; - return ; - } - - it('collects the comments after component has been mounted', function() { - const collectCommentsStub = sinon.stub(); - shallow(buildApp({}, {collectComments: collectCommentsStub})); - assert.strictEqual(collectCommentsStub.callCount, 1); - - const {submittedAt, comments, id} = review; - assert.deepEqual(collectCommentsStub.lastCall.args[0], { - reviewId: id, - submittedAt, - comments, - fetchingMoreComments: false, - }); - }); - - it('attempts to load comments after component has been mounted', function() { - const wrapper = shallow(buildApp()); - sinon.stub(wrapper.instance(), '_attemptToLoadMoreComments'); - wrapper.instance().componentDidMount(); - assert.strictEqual(wrapper.instance()._attemptToLoadMoreComments.callCount, 1); - }); - - describe('_loadMoreComments', function() { - it('calls this.props.relay.loadMore with correct args', function() { - const relayLoadMoreStub = sinon.stub(); - const wrapper = shallow(buildApp({relayLoadMore: relayLoadMoreStub})); - wrapper.instance()._loadMoreComments(); - - assert.deepEqual(relayLoadMoreStub.lastCall.args, [PAGE_SIZE, wrapper.instance().accumulateComments]); - }); - }); - - describe('accumulateComments', function() { - it('collects comments and attempts to load more comments', function() { - const collectCommentsStub = sinon.stub(); - const wrapper = shallow(buildApp({}, {collectComments: collectCommentsStub})); - // collect comments is called when mounted, we don't care about that in this test so reset the count - collectCommentsStub.reset(); - sinon.stub(wrapper.instance(), '_attemptToLoadMoreComments'); - wrapper.instance().accumulateComments(); - - assert.strictEqual(collectCommentsStub.callCount, 1); - - const {submittedAt, comments, id} = review; - assert.deepEqual(collectCommentsStub.lastCall.args[0], { - reviewId: id, - submittedAt, - comments, - fetchingMoreComments: false, - }); - }); - }); - - describe('attemptToLoadMoreComments', function() { - it('does not call loadMore if hasMore is false', function() { - const relayLoadMoreStub = sinon.stub(); - const wrapper = shallow(buildApp({relayLoadMore: relayLoadMoreStub})); - relayLoadMoreStub.reset(); - - wrapper.instance()._attemptToLoadMoreComments(); - assert.strictEqual(relayLoadMoreStub.callCount, 0); - }); - - it('calls loadMore immediately if hasMore is true and isLoading is false', function() { - const relayLoadMoreStub = sinon.stub(); - const relayHasMore = () => { return true; }; - const wrapper = shallow(buildApp({relayHasMore, relayLoadMore: relayLoadMoreStub})); - relayLoadMoreStub.reset(); - - wrapper.instance()._attemptToLoadMoreComments(); - assert.strictEqual(relayLoadMoreStub.callCount, 1); - assert.deepEqual(relayLoadMoreStub.lastCall.args, [PAGE_SIZE, wrapper.instance().accumulateComments]); - }); - - it('calls loadMore after a timeout if hasMore is true and isLoading is true', function() { - const clock = sinon.useFakeTimers(); - const relayLoadMoreStub = sinon.stub(); - const relayHasMore = () => { return true; }; - const relayIsLoading = () => { return true; }; - const wrapper = shallow(buildApp({relayHasMore, relayIsLoading, relayLoadMore: relayLoadMoreStub})); - // advancing the timer and resetting the stub to clear the initial calls of - // _attemptToLoadMoreComments when the component is initially mounted. - clock.tick(PAGINATION_WAIT_TIME_MS); - relayLoadMoreStub.reset(); - - wrapper.instance()._attemptToLoadMoreComments(); - assert.strictEqual(relayLoadMoreStub.callCount, 0); - - clock.tick(PAGINATION_WAIT_TIME_MS); - assert.strictEqual(relayLoadMoreStub.callCount, 1); - assert.deepEqual(relayLoadMoreStub.lastCall.args, [PAGE_SIZE, wrapper.instance().accumulateComments]); - - // buybye fake timer it was nice knowing you - sinon.restore(); - }); - }); -}); diff --git a/test/containers/remote-container.test.js b/test/containers/remote-container.test.js index 2d60943684..af61e71485 100644 --- a/test/containers/remote-container.test.js +++ b/test/containers/remote-container.test.js @@ -1,17 +1,20 @@ import React from 'react'; -import {mount} from 'enzyme'; +import {shallow} from 'enzyme'; +import {QueryRenderer} from 'react-relay'; +import RemoteContainer from '../../lib/containers/remote-container'; import * as reporterProxy from '../../lib/reporter-proxy'; -import {createRepositoryResult} from '../fixtures/factories/repository-result'; +import {queryBuilder} from '../builder/graphql/query'; import Remote from '../../lib/models/remote'; +import RemoteSet from '../../lib/models/remote-set'; import Branch, {nullBranch} from '../../lib/models/branch'; import BranchSet from '../../lib/models/branch-set'; import GithubLoginModel from '../../lib/models/github-login-model'; import {nullOperationStateObserver} from '../../lib/models/operation-state-observer'; import {getEndpoint} from '../../lib/models/endpoint'; -import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; -import RemoteContainer from '../../lib/containers/remote-container'; -import {expectRelayQuery} from '../../lib/relay-network-layer-manager'; +import {InMemoryStrategy, INSUFFICIENT, UNAUTHENTICATED} from '../../lib/shared/keytar-strategy'; + +import remoteQuery from '../../lib/containers/__generated__/remoteContainerQuery.graphql'; describe('RemoteContainer', function() { let atomEnv, model; @@ -27,9 +30,9 @@ describe('RemoteContainer', function() { function buildApp(overrideProps = {}) { const origin = new Remote('origin', 'git@github.com:atom/github.git'); + const remotes = new RemoteSet([origin]); const branch = new Branch('master', nullBranch, nullBranch, true); - const branchSet = new BranchSet(); - branchSet.add(branch); + const branches = new BranchSet([branch]); return ( {}, + }); - assert.isTrue(wrapper.update().find('GithubLoginView').exists()); + assert.isTrue(resultWrapper.exists('LoadingView')); }); - it('renders a login prompt if the token has insufficient OAuth scopes', async function() { - model.setToken('https://api.github.com', '1234'); - sinon.spy(model, 'getToken'); - sinon.stub(model, 'getScopes').resolves([]); + it('renders a login prompt if no token is found', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')(UNAUTHENTICATED); + assert.isTrue(tokenWrapper.exists('GithubLoginView')); + }); - const wrapper = mount(buildApp()); - await model.getToken.returnValues[0]; + it('renders a login prompt if the token has insufficient OAuth scopes', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')(INSUFFICIENT); - assert.match(wrapper.update().find('GithubLoginView').find('p').text(), /sufficient/); + assert.match(tokenWrapper.find('GithubLoginView').find('p').text(), /sufficient/); }); - it('renders an error message if the GraphQL query fails', async function() { - const {reject} = expectRepositoryQuery(); - const e = new Error('oh shit!'); - e.rawStack = e.stack; - reject(e); - model.setToken('https://api.github.com', '1234'); - sinon.stub(model, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES); + it('renders an error message if the GraphQL query fails', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('1234'); - const wrapper = mount(buildApp()); - await assert.async.isTrue(wrapper.update().find('QueryErrorView').exists()); + const error = new Error('oh shit!'); + error.rawStack = error.stack; + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({error, props: null, retry: () => {}}); - const qev = wrapper.find('QueryErrorView'); - assert.strictEqual(qev.prop('error'), e); + assert.strictEqual(resultWrapper.find('QueryErrorView').prop('error'), error); }); it('increments a counter on login', function() { - const token = '1234'; - sinon.stub(model, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES); const incrementCounterStub = sinon.stub(reporterProxy, 'incrementCounter'); - const wrapper = mount(buildApp()); - wrapper.instance().handleLogin(token); + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')(UNAUTHENTICATED); + + tokenWrapper.find('GithubLoginView').prop('onLogin')(); assert.isTrue(incrementCounterStub.calledOnceWith('github-login')); }); it('increments a counter on logout', function() { - const token = '1234'; - sinon.stub(model, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES); + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('1234'); - const wrapper = mount(buildApp()); - wrapper.instance().handleLogin(token); + const error = new Error('just show the logout button'); + error.rawStack = error.stack; + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({error, props: null, retry: () => {}}); const incrementCounterStub = sinon.stub(reporterProxy, 'incrementCounter'); - wrapper.instance().handleLogout(); + resultWrapper.find('QueryErrorView').prop('logout')(); assert.isTrue(incrementCounterStub.calledOnceWith('github-logout')); }); - it('renders the controller once results have arrived', async function() { - const {resolve} = expectRepositoryQuery(); - expectEmptyIssueishQuery(); - model.setToken('https://api.github.com', '1234'); - sinon.stub(model, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES); - - const wrapper = mount(buildApp()); - - resolve(); - - await assert.async.isTrue(wrapper.update().find('RemoteController').exists()); - const controller = wrapper.find('RemoteController'); + it('renders the controller once results have arrived', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('1234'); + + const props = queryBuilder(remoteQuery) + .repository(r => { + r.id('the-repo'); + r.defaultBranchRef(dbr => { + dbr.prefix('refs/heads/'); + dbr.name('devel'); + }); + }) + .build(); + const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({error: null, props, retry: () => {}}); + + const controller = resultWrapper.find('RemoteController'); assert.strictEqual(controller.prop('token'), '1234'); assert.deepEqual(controller.prop('repository'), { - id: 'repository0', + id: 'the-repo', defaultBranchRef: { prefix: 'refs/heads/', - name: 'master', + name: 'devel', }, }); }); diff --git a/test/containers/reviews-container.test.js b/test/containers/reviews-container.test.js new file mode 100644 index 0000000000..25c74488a5 --- /dev/null +++ b/test/containers/reviews-container.test.js @@ -0,0 +1,249 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import {QueryRenderer} from 'react-relay'; + +import ReviewsContainer from '../../lib/containers/reviews-container'; +import AggregatedReviewsContainer from '../../lib/containers/aggregated-reviews-container'; +import CommentPositioningContainer from '../../lib/containers/comment-positioning-container'; +import ReviewsController from '../../lib/controllers/reviews-controller'; +import {InMemoryStrategy, UNAUTHENTICATED, INSUFFICIENT} from '../../lib/shared/keytar-strategy'; +import GithubLoginModel from '../../lib/models/github-login-model'; +import WorkdirContextPool from '../../lib/models/workdir-context-pool'; +import {getEndpoint} from '../../lib/models/endpoint'; +import {multiFilePatchBuilder} from '../builder/patch'; +import {cloneRepository} from '../helpers'; + +describe('ReviewsContainer', function() { + let atomEnv, workdirContextPool, repository, loginModel, repoData, queryData; + + beforeEach(async function() { + atomEnv = global.buildAtomEnvironment(); + + workdirContextPool = new WorkdirContextPool(); + + const workdir = await cloneRepository(); + repository = workdirContextPool.add(workdir).getRepository(); + await repository.getLoadPromise(); + loginModel = new GithubLoginModel(InMemoryStrategy); + sinon.stub(loginModel, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES); + + repoData = { + branches: await repository.getBranches(), + remotes: await repository.getRemotes(), + isAbsent: repository.isAbsent(), + isLoading: repository.isLoading(), + isPresent: repository.isPresent(), + isMerging: await repository.isMerging(), + isRebasing: await repository.isRebasing(), + }; + + queryData = { + repository: { + pullRequest: {}, + }, + }; + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const props = { + endpoint: getEndpoint('github.com'), + + owner: 'atom', + repo: 'github', + number: 1234, + workdir: repository.getWorkingDirectoryPath(), + + repository, + loginModel, + workdirContextPool, + + workspace: atomEnv.workspace, + config: atomEnv.config, + commands: atomEnv.commands, + tooltips: atomEnv.tooltips, + reportMutationErrors: () => {}, + + ...override, + }; + + return ; + } + + it('renders a loading spinner while the token is loading', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')(null); + assert.isTrue(tokenWrapper.exists('LoadingView')); + }); + + it('shows a login form if no token is available', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')(UNAUTHENTICATED); + assert.isTrue(tokenWrapper.exists('GithubLoginView')); + }); + + it('shows a login form if the token is outdated', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')(INSUFFICIENT); + + assert.isTrue(tokenWrapper.exists('GithubLoginView')); + assert.match(tokenWrapper.find('GithubLoginView > p').text(), /re-authenticate/); + }); + + it('gets the token from the login model', async function() { + await loginModel.setToken('https://github.enterprise.horse', 'neigh'); + + const wrapper = shallow(buildApp({ + loginModel, + endpoint: getEndpoint('github.enterprise.horse'), + })); + + assert.strictEqual(wrapper.find('ObserveModel').prop('model'), loginModel); + assert.strictEqual(await wrapper.find('ObserveModel').prop('fetchData')(loginModel), 'neigh'); + }); + + it('shows a loading spinner if the patch is being fetched', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('shhh'); + const patchWrapper = tokenWrapper.find('PullRequestPatchContainer').renderProp('children')(null, null); + const repoWrapper = patchWrapper.find('ObserveModel').renderProp('children')(repoData); + const resultWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({error: null, props: queryData, retry: () => {}}); + + assert.isTrue(resultWrapper.exists('LoadingView')); + }); + + it('shows a loading spinner if the repository data is being fetched', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('shhh'); + const patchWrapper = tokenWrapper.find('PullRequestPatchContainer').renderProp('children')(null, {}); + const repoWrapper = patchWrapper.find('ObserveModel').renderProp('children')(null); + const resultWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({error: null, props: queryData, retry: () => {}}); + + assert.isTrue(resultWrapper.exists('LoadingView')); + }); + + it('shows a loading spinner if the GraphQL query is still being performed', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('shhh'); + const patchWrapper = tokenWrapper.find('PullRequestPatchContainer').renderProp('children')(null, {}); + const repoWrapper = patchWrapper.find('ObserveModel').renderProp('children')(repoData); + const resultWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({error: null, props: null, retry: () => {}}); + + assert.isTrue(resultWrapper.exists('LoadingView')); + }); + + it('passes the patch to the controller', function() { + const wrapper = shallow(buildApp({ + owner: 'secret', + repo: 'squirrel', + number: 123, + endpoint: getEndpoint('github.enterprise.com'), + })); + + const {multiFilePatch} = multiFilePatchBuilder().build(); + + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('shhh'); + + assert.strictEqual(tokenWrapper.find('PullRequestPatchContainer').prop('owner'), 'secret'); + assert.strictEqual(tokenWrapper.find('PullRequestPatchContainer').prop('repo'), 'squirrel'); + assert.strictEqual(tokenWrapper.find('PullRequestPatchContainer').prop('number'), 123); + assert.strictEqual(tokenWrapper.find('PullRequestPatchContainer').prop('endpoint').getHost(), 'github.enterprise.com'); + assert.strictEqual(tokenWrapper.find('PullRequestPatchContainer').prop('token'), 'shhh'); + const patchWrapper = tokenWrapper.find('PullRequestPatchContainer').renderProp('children')(null, multiFilePatch); + + const repoWrapper = patchWrapper.find('ObserveModel').renderProp('children')(repoData); + const relayWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({error: null, props: queryData, retry: () => {}}); + const reviewsWrapper = relayWrapper.find(AggregatedReviewsContainer).renderProp('children')({ + errors: [], + summaries: [], + commentThreads: [], + refetch: () => {}, + }); + const positionedWrapper = reviewsWrapper.find(CommentPositioningContainer).renderProp('children')(new Map()); + + assert.strictEqual(positionedWrapper.find(ReviewsController).prop('multiFilePatch'), multiFilePatch); + }); + + it('passes loaded repository data to the controller', async function() { + const wrapper = shallow(buildApp()); + + const extraRepoData = {...repoData, one: Symbol('one'), two: Symbol('two')}; + + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('shhh'); + const patchWrapper = tokenWrapper.find('PullRequestPatchContainer').renderProp('children')(null, {}); + + assert.strictEqual(patchWrapper.find('ObserveModel').prop('model'), repository); + assert.deepEqual(await patchWrapper.find('ObserveModel').prop('fetchData')(repository), repoData); + const repoWrapper = patchWrapper.find('ObserveModel').renderProp('children')(extraRepoData); + + const relayWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({error: null, props: queryData, retry: () => {}}); + const reviewsWrapper = relayWrapper.find(AggregatedReviewsContainer).renderProp('children')({ + errors: [], + summaries: [], + commentThreads: [], + refetch: () => {}, + }); + const positionedWrapper = reviewsWrapper.find(CommentPositioningContainer).renderProp('children')(new Map()); + + assert.strictEqual(positionedWrapper.find(ReviewsController).prop('one'), extraRepoData.one); + assert.strictEqual(positionedWrapper.find(ReviewsController).prop('two'), extraRepoData.two); + }); + + it('passes extra properties to the controller', function() { + const extra = Symbol('extra'); + const wrapper = shallow(buildApp({extra})); + + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('shhh'); + const patchWrapper = tokenWrapper.find('PullRequestPatchContainer').renderProp('children')(null, {}); + + assert.strictEqual(patchWrapper.find('ObserveModel').prop('model'), repository); + const repoWrapper = patchWrapper.find('ObserveModel').renderProp('children')(repoData); + + const relayWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({error: null, props: queryData, retry: () => {}}); + const reviewsWrapper = relayWrapper.find(AggregatedReviewsContainer).renderProp('children')({ + errors: [], + summaries: [], + commentThreads: [], + refetch: () => {}, + }); + const positionedWrapper = reviewsWrapper.find(CommentPositioningContainer).renderProp('children')(new Map()); + + assert.strictEqual(positionedWrapper.find(ReviewsController).prop('extra'), extra); + }); + + it('shows an error if the patch cannot be fetched', function() { + const wrapper = shallow(buildApp()); + + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('shhh'); + const patchWrapper = tokenWrapper.find('PullRequestPatchContainer').renderProp('children')('[errors intensify]', null); + assert.deepEqual(patchWrapper.find('ErrorView').prop('descriptions'), ['[errors intensify]']); + }); + + it('shows an error if the GraphQL query fails', async function() { + const wrapper = shallow(buildApp({ + endpoint: getEndpoint('github.enterprise.horse'), + })); + + const err = new Error("just didn't feel like it"); + const retry = sinon.spy(); + + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('shhh'); + const patchWrapper = tokenWrapper.find('PullRequestPatchContainer').renderProp('children')(null, {}); + const repoWrapper = patchWrapper.find('ObserveModel').renderProp('children')(repoData); + const resultWrapper = repoWrapper.find(QueryRenderer).renderProp('render')({error: err, props: null, retry}); + + assert.strictEqual(resultWrapper.find('QueryErrorView').prop('error'), err); + + await resultWrapper.find('QueryErrorView').prop('login')('different'); + assert.strictEqual(await loginModel.getToken('https://github.enterprise.horse'), 'different'); + + await resultWrapper.find('QueryErrorView').prop('logout')(); + assert.strictEqual(await loginModel.getToken('https://github.enterprise.horse'), UNAUTHENTICATED); + + resultWrapper.find('QueryErrorView').prop('retry')(); + assert.isTrue(retry.called); + }); +}); diff --git a/test/controllers/comment-decorations-controller.test.js b/test/controllers/comment-decorations-controller.test.js new file mode 100644 index 0000000000..b6aea137d8 --- /dev/null +++ b/test/controllers/comment-decorations-controller.test.js @@ -0,0 +1,190 @@ +import React from 'react'; +import {mount, shallow} from 'enzyme'; +import path from 'path'; +import fs from 'fs-extra'; + +import {BareCommentDecorationsController} from '../../lib/controllers/comment-decorations-controller'; +import RelayNetworkLayerManager from '../../lib/relay-network-layer-manager'; +import ReviewsItem from '../../lib/items/reviews-item'; +import {aggregatedReviewsBuilder} from '../builder/graphql/aggregated-reviews-builder'; +import {getEndpoint} from '../../lib/models/endpoint'; +import pullRequestsQuery from '../../lib/controllers/__generated__/commentDecorationsController_pullRequests.graphql'; +import {pullRequestBuilder} from '../builder/graphql/pr'; +import Branch from '../../lib/models/branch'; +import BranchSet from '../../lib/models/branch-set'; +import Remote from '../../lib/models/remote'; +import RemoteSet from '../../lib/models/remote-set'; + +describe('CommentDecorationsController', function() { + let atomEnv, relayEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + relayEnv = RelayNetworkLayerManager.getEnvironmentForHost(getEndpoint('github.com'), '1234'); + }); + + afterEach(async function() { + atomEnv.destroy(); + await fs.remove(path.join(__dirname, 'file0.txt')); + }); + + function buildApp(override = {}) { + const origin = new Remote('origin', 'git@github.com:owner/repo.git'); + const upstreamBranch = Branch.createRemoteTracking('refs/remotes/origin/featureBranch', 'origin', 'refs/heads/featureBranch'); + const branch = new Branch('featureBranch', upstreamBranch, upstreamBranch, true); + const {commentThreads} = aggregatedReviewsBuilder() + .addReviewThread(t => { + t.addComment(c => c.id(0).path('file0.txt').position(2).bodyHTML('one')); + }) + .addReviewThread(t => { + t.addComment(c => c.id(1).path('file1.txt').position(15).bodyHTML('two')); + }) + .addReviewThread(t => { + t.addComment(c => c.id(2).path('file2.txt').position(7).bodyHTML('three')); + }) + .addReviewThread(t => { + t.addComment(c => c.id(3).path('file2.txt').position(10).bodyHTML('four')); + }) + .build(); + + const pr = pullRequestBuilder(pullRequestsQuery) + .number(100) + .headRefName('featureBranch') + .headRepository(r => { + r.owner(o => o.login('owner')); + r.name('repo'); + }).build(); + + const props = { + relay: {environment: relayEnv}, + pullRequests: [pr], + repository: {}, + endpoint: getEndpoint('github.com'), + owner: 'owner', + repo: 'repo', + commands: atomEnv.commands, + workspace: atomEnv.workspace, + repoData: { + branches: new BranchSet([branch]), + remotes: new RemoteSet([origin]), + currentRemote: origin, + workingDirectoryPath: __dirname, + }, + commentThreads, + commentTranslations: new Map(), + updateCommentTranslations: () => {}, + ...override, + }; + + return ; + } + + describe('renders EditorCommentDecorationsController and Gutter', function() { + let editor0, editor1, editor2, wrapper; + + beforeEach(async function() { + editor0 = await atomEnv.workspace.open(path.join(__dirname, 'file0.txt')); + editor1 = await atomEnv.workspace.open(path.join(__dirname, 'another-unrelated-file.txt')); + editor2 = await atomEnv.workspace.open(path.join(__dirname, 'file1.txt')); + wrapper = mount(buildApp()); + }); + + it('a pair per matching opened editor', function() { + assert.strictEqual(wrapper.find('EditorCommentDecorationsController').length, 2); + assert.isNotNull(editor0.gutterWithName('github-comment-icon')); + assert.isNotNull(editor2.gutterWithName('github-comment-icon')); + assert.isNull(editor1.gutterWithName('github-comment-icon')); + }); + + it('updates its EditorCommentDecorationsController and Gutter children as editor panes get created', async function() { + editor2 = await atomEnv.workspace.open(path.join(__dirname, 'file2.txt')); + wrapper.update(); + + assert.strictEqual(wrapper.find('EditorCommentDecorationsController').length, 3); + assert.isNotNull(editor2.gutterWithName('github-comment-icon')); + }); + + it('updates its EditorCommentDecorationsController and Gutter children as editor panes get destroyed', async function() { + assert.strictEqual(wrapper.find('EditorCommentDecorationsController').length, 2); + await atomEnv.workspace.getActivePaneItem().destroy(); + wrapper.update(); + + assert.strictEqual(wrapper.find('EditorCommentDecorationsController').length, 1); + + wrapper.unmount(); + }); + }); + + describe('returns empty render', function() { + it('when PR is not checked out', async function() { + await atomEnv.workspace.open(path.join(__dirname, 'file0.txt')); + const pr = pullRequestBuilder(pullRequestsQuery) + .headRefName('wrongBranch') + .build(); + const wrapper = mount(buildApp({pullRequests: [pr]})); + assert.isTrue(wrapper.isEmptyRender()); + }); + + it('when a repository has been deleted', async function() { + await atomEnv.workspace.open(path.join(__dirname, 'file0.txt')); + const pr = pullRequestBuilder(pullRequestsQuery) + .headRefName('featureBranch') + .build(); + pr.headRepository = null; + const wrapper = mount(buildApp({pullRequests: [pr]})); + assert.isTrue(wrapper.isEmptyRender()); + }); + + it('when there is no PR', async function() { + await atomEnv.workspace.open(path.join(__dirname, 'file0.txt')); + const wrapper = mount(buildApp({pullRequests: []})); + assert.isTrue(wrapper.isEmptyRender()); + }); + }); + + it('skips comment thread with only minimized comments', async function() { + const {commentThreads} = aggregatedReviewsBuilder() + .addReviewThread(t => { + t.addComment(c => c.id(0).path('file0.txt').position(2).bodyHTML('one').isMinimized(true)); + t.addComment(c => c.id(2).path('file0.txt').position(2).bodyHTML('two').isMinimized(true)); + }) + .addReviewThread(t => { + t.addComment(c => c.id(1).path('file1.txt').position(15).bodyHTML('three')); + }) + .build(); + await atomEnv.workspace.open(path.join(__dirname, 'file0.txt')); + await atomEnv.workspace.open(path.join(__dirname, 'file1.txt')); + const wrapper = mount(buildApp({commentThreads})); + assert.lengthOf(wrapper.find('EditorCommentDecorationsController'), 1); + assert.strictEqual( + wrapper.find('EditorCommentDecorationsController').prop('fileName'), + path.join(__dirname, 'file1.txt'), + ); + }); + + describe('opening the reviews tab with a command', function() { + it('opens the correct tab', function() { + sinon.stub(atomEnv.workspace, 'open').returns(); + + const wrapper = shallow(buildApp({ + endpoint: getEndpoint('github.enterprise.horse'), + owner: 'me', + repo: 'pushbot', + })); + + const command = wrapper.find('Command[command="github:open-reviews-tab"]'); + command.prop('callback')(); + + assert.isTrue(atomEnv.workspace.open.calledWith( + ReviewsItem.buildURI({ + host: 'github.enterprise.horse', + owner: 'me', + repo: 'pushbot', + number: 100, + workdir: __dirname, + }), + {searchAllPanes: true}, + )); + }); + }); +}); diff --git a/test/controllers/comment-gutter-decoration-controller.test.js b/test/controllers/comment-gutter-decoration-controller.test.js new file mode 100644 index 0000000000..e9b917d84c --- /dev/null +++ b/test/controllers/comment-gutter-decoration-controller.test.js @@ -0,0 +1,72 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import CommentGutterDecorationController from '../../lib/controllers/comment-gutter-decoration-controller'; +import {getEndpoint} from '../../lib/models/endpoint'; +import {Range} from 'atom'; +import * as reporterProxy from '../../lib/reporter-proxy'; +import ReviewsItem from '../../lib/items/reviews-item'; + +describe('CommentGutterDecorationController', function() { + let atomEnv, workspace, editor; + + function buildApp(opts = {}) { + const props = { + workspace, + editor, + commentRow: 420, + threadId: 'my-thread-will-go-on', + extraClasses: ['celine', 'dion'], + endpoint: getEndpoint('github.com'), + owner: 'owner', + repo: 'repo', + number: 1337, + workdir: 'dir/path', + parent: 'TheThingThatMadeChildren', + ...opts, + }; + return ; + } + + beforeEach(async function() { + atomEnv = global.buildAtomEnvironment(); + workspace = atomEnv.workspace; + editor = await workspace.open(__filename); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + + it('decorates the comment gutter', function() { + const wrapper = shallow(buildApp()); + editor.addGutter({name: 'github-comment-icon'}); + const marker = wrapper.find('Marker'); + const decoration = marker.find('Decoration'); + + assert.deepEqual(marker.prop('bufferRange'), new Range([420, 0], [420, Infinity])); + assert.isTrue(decoration.hasClass('celine')); + assert.isTrue(decoration.hasClass('dion')); + assert.isTrue(decoration.hasClass('github-editorCommentGutterIcon')); + assert.strictEqual(decoration.children('button.icon.icon-comment').length, 1); + + }); + + it('opens review dock and jumps to thread when clicked', async function() { + sinon.stub(reporterProxy, 'addEvent'); + const jumpToThread = sinon.spy(); + sinon.stub(atomEnv.workspace, 'open').resolves({jumpToThread}); + const wrapper = shallow(buildApp()); + + wrapper.find('button.icon-comment').simulate('click'); + assert.isTrue(atomEnv.workspace.open.calledWith( + ReviewsItem.buildURI({host: 'github.com', owner: 'owner', repo: 'repo', number: 1337, workdir: 'dir/path'}), + {searchAllPanes: true}, + )); + await assert.async.isTrue(jumpToThread.calledWith('my-thread-will-go-on')); + assert.isTrue(reporterProxy.addEvent.calledWith('open-review-thread', { + package: 'github', + from: 'TheThingThatMadeChildren', + })); + }); +}); diff --git a/test/controllers/commit-controller.test.js b/test/controllers/commit-controller.test.js index 4ab3dd8e38..2b7ba9f32a 100644 --- a/test/controllers/commit-controller.test.js +++ b/test/controllers/commit-controller.test.js @@ -6,11 +6,10 @@ import {shallow, mount} from 'enzyme'; import Commit from '../../lib/models/commit'; import {nullBranch} from '../../lib/models/branch'; import UserStore from '../../lib/models/user-store'; -import URIPattern from '../../lib/atom/uri-pattern'; import CommitController, {COMMIT_GRAMMAR_SCOPE} from '../../lib/controllers/commit-controller'; import CommitPreviewItem from '../../lib/items/commit-preview-item'; -import {cloneRepository, buildRepository, buildRepositoryWithPipeline} from '../helpers'; +import {cloneRepository, buildRepository, buildRepositoryWithPipeline, registerGitHubOpener} from '../helpers'; import * as reporterProxy from '../../lib/reporter-proxy'; describe('CommitController', function() { @@ -30,22 +29,7 @@ describe('CommitController', function() { const noop = () => { }; const store = new UserStore({config}); - // Ensure the Workspace doesn't mangle atom-github://... URIs. - // If you don't have an opener registered for a non-standard URI protocol, the Workspace coerces it into a file URI - // and tries to open it with a TextEditor. In the process, the URI gets mangled: - // - // atom.workspace.open('atom-github://unknown/whatever').then(item => console.log(item.getURI())) - // > 'atom-github:/unknown/whatever' - // - // Adding an opener that creates fake items prevents it from doing this and keeps the URIs unchanged. - const pattern = new URIPattern(CommitPreviewItem.uriPattern); - workspace.addOpener(uri => { - if (pattern.matches(uri).ok()) { - return {getURI() { return uri; }}; - } else { - return undefined; - } - }); + registerGitHubOpener(atomEnvironment); app = ( ; + } + + it('renders nothing if no position translations are available for this path', function() { + wrapper = shallow(buildApp({commentTranslationsForPath: null})); + assert.isTrue(wrapper.isEmptyRender()); + }); + + it('creates a marker and decoration controller for each comment thread at its translated line position', function() { + const threadsForPath = [ + {rootCommentID: 'comment0', position: 4, threadID: 'thread0'}, + {rootCommentID: 'comment1', position: 10, threadID: 'thread1'}, + {rootCommentID: 'untranslateable', position: 20, threadID: 'thread2'}, + {rootCommentID: 'positionless', position: null, threadID: 'thread3'}, + ]; + + const commentTranslationsForPath = { + diffToFilePosition: new Map([ + [4, 7], + [10, 13], + ]), + }; + + wrapper = shallow(buildApp({threadsForPath, commentTranslationsForPath})); + + const markers = wrapper.find(Marker); + assert.lengthOf(markers, 2); + assert.isTrue(markers.someWhere(w => w.prop('bufferRange').isEqual([[6, 0], [6, Infinity]]))); + assert.isTrue(markers.someWhere(w => w.prop('bufferRange').isEqual([[12, 0], [12, Infinity]]))); + + const controllers = wrapper.find(CommentGutterDecorationController); + assert.lengthOf(controllers, 2); + assert.isTrue(controllers.someWhere(w => w.prop('commentRow') === 6)); + assert.isTrue(controllers.someWhere(w => w.prop('commentRow') === 12)); + }); + + it('creates a line decoration for each line with a comment', function() { + const threadsForPath = [ + {rootCommentID: 'comment0', position: 4, threadID: 'thread0'}, + {rootCommentID: 'comment1', position: 10, threadID: 'thread1'}, + ]; + const commentTranslationsForPath = { + diffToFilePosition: new Map([ + [4, 5], + [10, 11], + ]), + }; + + wrapper = shallow(buildApp({threadsForPath, commentTranslationsForPath})); + + const decorations = wrapper.find(Decoration); + assert.lengthOf(decorations.findWhere(decoration => decoration.prop('type') === 'line'), 2); + }); + + it('updates rendered marker positions as the underlying buffer is modified', function() { + const threadsForPath = [ + {rootCommentID: 'comment0', position: 4, threadID: 'thread0'}, + ]; + + const commentTranslationsForPath = { + diffToFilePosition: new Map([[4, 4]]), + digest: '1111', + }; + + wrapper = shallow(buildApp({threadsForPath, commentTranslationsForPath})); + + const marker = wrapper.find(Marker); + assert.isTrue(marker.prop('bufferRange').isEqual([[3, 0], [3, Infinity]])); + + marker.prop('didChange')({newRange: Range.fromObject([[5, 0], [5, 3]])}); + + // Ensure the component re-renders + wrapper.setProps({ + commentTranslationsForPath: { + ...commentTranslationsForPath, + digest: '2222', + }, + }); + + assert.isTrue(wrapper.find(Marker).prop('bufferRange').isEqual([[5, 0], [5, 3]])); + }); +}); diff --git a/test/controllers/emoji-reactions-controller.test.js b/test/controllers/emoji-reactions-controller.test.js new file mode 100644 index 0000000000..232e9769fc --- /dev/null +++ b/test/controllers/emoji-reactions-controller.test.js @@ -0,0 +1,150 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import {create as createRecord} from 'relay-runtime/lib/RelayModernRecord'; + +import {BareEmojiReactionsController} from '../../lib/controllers/emoji-reactions-controller'; +import EmojiReactionsView from '../../lib/views/emoji-reactions-view'; +import {issueBuilder} from '../builder/graphql/issue'; +import {relayResponseBuilder} from '../builder/graphql/query'; +import RelayNetworkLayerManager, {expectRelayQuery} from '../../lib/relay-network-layer-manager'; +import {getEndpoint} from '../../lib/models/endpoint'; + +import reactableQuery from '../../lib/controllers/__generated__/emojiReactionsController_reactable.graphql'; +import addReactionQuery from '../../lib/mutations/__generated__/addReactionMutation.graphql'; +import removeReactionQuery from '../../lib/mutations/__generated__/removeReactionMutation.graphql'; + +describe('EmojiReactionsController', function() { + let atomEnv, relayEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + relayEnv = RelayNetworkLayerManager.getEnvironmentForHost(getEndpoint('github.com'), '1234'); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const props = { + relay: { + environment: relayEnv, + }, + reactable: issueBuilder(reactableQuery).build(), + tooltips: atomEnv.tooltips, + reportMutationErrors: () => {}, + ...override, + }; + + return ; + } + + it('renders an EmojiReactionView and passes props', function() { + const extra = Symbol('extra'); + const wrapper = shallow(buildApp({extra})); + + assert.strictEqual(wrapper.find(EmojiReactionsView).prop('extra'), extra); + }); + + describe('adding a reaction', function() { + it('fires the add reaction mutation', async function() { + const reportMutationErrors = sinon.spy(); + + expectRelayQuery({ + name: addReactionQuery.operation.name, + variables: {input: {content: 'ROCKET', subjectId: 'issue0'}}, + }, op => { + return relayResponseBuilder(op) + .addReaction(m => { + m.subject(r => r.beIssue()); + }) + .build(); + }).resolve(); + + const reactable = issueBuilder(reactableQuery).id('issue0').build(); + relayEnv.getStore().getSource().set('issue0', {...createRecord('issue0', 'Issue'), ...reactable}); + + const wrapper = shallow(buildApp({reactable, reportMutationErrors})); + + await wrapper.find(EmojiReactionsView).prop('addReaction')('ROCKET'); + + assert.isFalse(reportMutationErrors.called); + }); + + it('reports errors encountered', async function() { + const reportMutationErrors = sinon.spy(); + + expectRelayQuery({ + name: addReactionQuery.operation.name, + variables: {input: {content: 'EYES', subjectId: 'issue1'}}, + }, op => { + return relayResponseBuilder(op) + .addError('oh no') + .build(); + }).resolve(); + + const reactable = issueBuilder(reactableQuery).id('issue1').build(); + relayEnv.getStore().getSource().set('issue1', {...createRecord('issue1', 'Issue'), ...reactable}); + + const wrapper = shallow(buildApp({reactable, reportMutationErrors})); + + await wrapper.find(EmojiReactionsView).prop('addReaction')('EYES'); + + assert.isTrue(reportMutationErrors.calledWith( + 'Unable to add reaction emoji', + sinon.match({errors: [{message: 'oh no'}]})), + ); + }); + }); + + describe('removing a reaction', function() { + it('fires the remove reaction mutation', async function() { + const reportMutationErrors = sinon.spy(); + + expectRelayQuery({ + name: removeReactionQuery.operation.name, + variables: {input: {content: 'THUMBS_DOWN', subjectId: 'issue0'}}, + }, op => { + return relayResponseBuilder(op) + .removeReaction(m => { + m.subject(r => r.beIssue()); + }) + .build(); + }).resolve(); + + const reactable = issueBuilder(reactableQuery).id('issue0').build(); + relayEnv.getStore().getSource().set('issue0', {...createRecord('issue0', 'Issue'), ...reactable}); + + const wrapper = shallow(buildApp({reactable, reportMutationErrors})); + + await wrapper.find(EmojiReactionsView).prop('removeReaction')('THUMBS_DOWN'); + + assert.isFalse(reportMutationErrors.called); + }); + + it('reports errors encountered', async function() { + const reportMutationErrors = sinon.spy(); + + expectRelayQuery({ + name: removeReactionQuery.operation.name, + variables: {input: {content: 'CONFUSED', subjectId: 'issue1'}}, + }, op => { + return relayResponseBuilder(op) + .addError('wtf') + .build(); + }).resolve(); + + const reactable = issueBuilder(reactableQuery).id('issue1').build(); + relayEnv.getStore().getSource().set('issue1', {...createRecord('issue1', 'Issue'), ...reactable}); + + const wrapper = shallow(buildApp({reactable, reportMutationErrors})); + + await wrapper.find(EmojiReactionsView).prop('removeReaction')('CONFUSED'); + + assert.isTrue(reportMutationErrors.calledWith( + 'Unable to remove reaction emoji', + sinon.match({errors: [{message: 'wtf'}]})), + ); + }); + }); +}); diff --git a/test/controllers/issueish-detail-controller.test.js b/test/controllers/issueish-detail-controller.test.js index b3687ca7d2..f74f9777d7 100644 --- a/test/controllers/issueish-detail-controller.test.js +++ b/test/controllers/issueish-detail-controller.test.js @@ -2,351 +2,241 @@ import React from 'react'; import {shallow} from 'enzyme'; import * as reporterProxy from '../../lib/reporter-proxy'; -import BranchSet from '../../lib/models/branch-set'; -import Branch, {nullBranch} from '../../lib/models/branch'; -import RemoteSet from '../../lib/models/remote-set'; -import Remote from '../../lib/models/remote'; -import {GitError} from '../../lib/git-shell-out-strategy'; import CommitDetailItem from '../../lib/items/commit-detail-item'; import {BareIssueishDetailController} from '../../lib/controllers/issueish-detail-controller'; -import {issueishDetailControllerProps} from '../fixtures/props/issueish-pane-props'; +import PullRequestCheckoutController from '../../lib/controllers/pr-checkout-controller'; +import PullRequestDetailView from '../../lib/views/pr-detail-view'; +import IssueishDetailItem from '../../lib/items/issueish-detail-item'; +import ReviewsItem from '../../lib/items/reviews-item'; +import RefHolder from '../../lib/models/ref-holder'; +import EnableableOperation from '../../lib/models/enableable-operation'; +import BranchSet from '../../lib/models/branch-set'; +import RemoteSet from '../../lib/models/remote-set'; +import {getEndpoint} from '../../lib/models/endpoint'; +import {cloneRepository, buildRepository, registerGitHubOpener} from '../helpers'; +import {repositoryBuilder} from '../builder/graphql/repository'; + +import repositoryQuery from '../../lib/controllers/__generated__/issueishDetailController_repository.graphql'; describe('IssueishDetailController', function() { - let atomEnv; + let atomEnv, localRepository; - beforeEach(function() { + beforeEach(async function() { atomEnv = global.buildAtomEnvironment(); - - atomEnv.workspace.addOpener(uri => { - if (uri.startsWith('atom-github://')) { - return { - getURI() { return uri; }, - }; - } - - return undefined; - }); + registerGitHubOpener(atomEnv); + localRepository = await buildRepository(await cloneRepository()); }); afterEach(function() { atomEnv.destroy(); }); - function buildApp(opts, overrideProps = {}) { - const props = issueishDetailControllerProps(opts, {workspace: atomEnv.workspace, ...overrideProps}); + function buildApp(override = {}) { + const props = { + relay: {}, + repository: repositoryBuilder(repositoryQuery).build(), + + localRepository, + branches: new BranchSet(), + remotes: new RemoteSet(), + isMerging: false, + isRebasing: false, + isAbsent: false, + isLoading: false, + isPresent: true, + workdirPath: localRepository.getWorkingDirectoryPath(), + issueishNumber: 100, + + reviewCommentsLoading: false, + reviewCommentsTotalCount: 0, + reviewCommentsResolvedCount: 0, + reviewCommentThreads: [], + + endpoint: getEndpoint('github.com'), + token: '1234', + + workspace: atomEnv.workspace, + commands: atomEnv.commands, + keymaps: atomEnv.keymaps, + tooltips: atomEnv.tooltips, + config: atomEnv.config, + + onTitleChange: () => {}, + switchToIssueish: () => {}, + destroy: () => {}, + reportMutationErrors: () => {}, + + itemType: IssueishDetailItem, + refEditor: new RefHolder(), + + selectedTab: 0, + onTabSelected: () => {}, + onOpenFilesTab: () => {}, + + ...override, + }; + return ; } it('updates the pane title for a pull request on mount', function() { const onTitleChange = sinon.stub(); - shallow(buildApp({ - repositoryName: 'reponame', - ownerLogin: 'ownername', - issueishNumber: 12, - pullRequestTitle: 'the title', - }, {onTitleChange})); + const repository = repositoryBuilder(repositoryQuery) + .name('reponame') + .owner(u => u.login('ownername')) + .issue(i => i.__typename('PullRequest')) + .pullRequest(pr => pr.number(12).title('the title')) + .build(); + + shallow(buildApp({repository, onTitleChange})); assert.isTrue(onTitleChange.calledWith('PR: ownername/reponame#12 — the title')); }); it('updates the pane title for an issue on mount', function() { const onTitleChange = sinon.stub(); - shallow(buildApp({ - repositoryName: 'reponame', - ownerLogin: 'ownername', - issueKind: 'Issue', - issueishNumber: 34, - omitPullRequestData: true, - issueTitle: 'the title', - }, {onTitleChange})); + const repository = repositoryBuilder(repositoryQuery) + .name('reponame') + .owner(u => u.login('ownername')) + .issue(i => i.number(34).title('the title')) + .pullRequest(pr => pr.__typename('Issue')) + .build(); + + shallow(buildApp({repository, onTitleChange})); + assert.isTrue(onTitleChange.calledWith('Issue: ownername/reponame#34 — the title')); }); it('updates the pane title on update', function() { const onTitleChange = sinon.stub(); - const wrapper = shallow(buildApp({ - repositoryName: 'reponame', - ownerLogin: 'ownername', - issueishNumber: 12, - pullRequestTitle: 'the title', - }, {onTitleChange})); + const repository0 = repositoryBuilder(repositoryQuery) + .name('reponame') + .owner(u => u.login('ownername')) + .issue(i => i.__typename('PullRequest')) + .pullRequest(pr => pr.number(12).title('the title')) + .build(); + + const wrapper = shallow(buildApp({repository: repository0, onTitleChange})); + assert.isTrue(onTitleChange.calledWith('PR: ownername/reponame#12 — the title')); - wrapper.setProps(issueishDetailControllerProps({ - repositoryName: 'different', - ownerLogin: 'new', - issueishNumber: 34, - pullRequestTitle: 'the title', - }, {onTitleChange})); + const repository1 = repositoryBuilder(repositoryQuery) + .name('different') + .owner(u => u.login('new')) + .issue(i => i.__typename('PullRequest')) + .pullRequest(pr => pr.number(34).title('the title')) + .build(); + + wrapper.setProps({repository: repository1}); assert.isTrue(onTitleChange.calledWith('PR: new/different#34 — the title')); }); it('leaves the title alone and renders a message if no repository was found', function() { const onTitleChange = sinon.stub(); - const wrapper = shallow(buildApp({}, {onTitleChange, repository: null, issueishNumber: 123})); + + const wrapper = shallow(buildApp({onTitleChange, repository: null, issueishNumber: 123})); + assert.isFalse(onTitleChange.called); assert.match(wrapper.find('div').text(), /#123 not found/); }); it('leaves the title alone and renders a message if no issueish was found', function() { const onTitleChange = sinon.stub(); - const wrapper = shallow(buildApp({omitIssueData: true, omitPullRequestData: true}, {onTitleChange, issueishNumber: 123})); + const repository = repositoryBuilder(repositoryQuery) + .nullPullRequest() + .nullIssue() + .build(); + + const wrapper = shallow(buildApp({onTitleChange, issueishNumber: 123, repository})); assert.isFalse(onTitleChange.called); assert.match(wrapper.find('div').text(), /#123 not found/); }); - describe('checkoutOp', function() { - it('checkout is disabled if the issueish is an issue', function() { - const wrapper = shallow(buildApp({pullRequestKind: 'Issue'})); - const op = wrapper.instance().checkoutOp; - assert.isFalse(op.isEnabled()); - assert.strictEqual(op.getMessage(), 'Cannot check out an issue'); - }); - it('is disabled if the repository is loading or absent', function() { - const wrapper = shallow(buildApp({}, {isAbsent: true})); - const op = wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp'); - assert.isFalse(op.isEnabled()); - assert.strictEqual(op.getMessage(), 'No repository found'); - - wrapper.setProps({isAbsent: false, isLoading: true}); - const op1 = wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp'); - assert.isFalse(op1.isEnabled()); - assert.strictEqual(op1.getMessage(), 'Loading'); - - wrapper.setProps({isAbsent: false, isLoading: false, isPresent: false}); - const op2 = wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp'); - assert.isFalse(op2.isEnabled()); - assert.strictEqual(op2.getMessage(), 'No repository found'); - }); - - it('is disabled if the local repository is merging or rebasing', function() { - const wrapper = shallow(buildApp({}, {isMerging: true})); - const op0 = wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp'); - assert.isFalse(op0.isEnabled()); - assert.strictEqual(op0.getMessage(), 'Merge in progress'); - - wrapper.setProps({isMerging: false, isRebasing: true}); - const op1 = wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp'); - assert.isFalse(op1.isEnabled()); - assert.strictEqual(op1.getMessage(), 'Rebase in progress'); - }); - it('is disabled if pullRequest.headRepository is null', function() { - const props = issueishDetailControllerProps({}, {}); - props.repository.pullRequest.headRepository = null; - const wrapper = shallow(buildApp({}, {...props})); - const op = wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp'); - assert.isFalse(op.isEnabled()); - assert.strictEqual(op.getMessage(), 'Pull request head repository does not exist'); - }); - - - it('is disabled if the current branch already corresponds to the pull request', function() { - const upstream = Branch.createRemoteTracking('remotes/origin/feature', 'origin', 'refs/heads/feature'); - const branches = new BranchSet([ - new Branch('current', upstream, upstream, true), - ]); - const remotes = new RemoteSet([ - new Remote('origin', 'git@github.com:aaa/bbb.git'), - ]); + describe('openCommit', function() { + beforeEach(async function() { + sinon.stub(reporterProxy, 'addEvent'); - const wrapper = shallow(buildApp({ - pullRequestHeadRef: 'feature', - pullRequestHeadRepoOwner: 'aaa', - pullRequestHeadRepoName: 'bbb', - }, { - branches, - remotes, - })); + const checkoutOp = new EnableableOperation(() => {}).disable("I don't feel like it"); - const op = wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp'); - assert.isFalse(op.isEnabled()); - assert.strictEqual(op.getMessage(), 'Current'); + const wrapper = shallow(buildApp({workdirPath: __dirname})); + const checkoutWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')(checkoutOp); + await checkoutWrapper.find(PullRequestDetailView).prop('openCommit')({sha: '1234'}); }); - it('recognizes a current branch even if it was pulled from the refs/pull/... ref', function() { - const upstream = Branch.createRemoteTracking('remotes/origin/pull/123/head', 'origin', 'refs/pull/123/head'); - const branches = new BranchSet([ - new Branch('current', upstream, upstream, true), - ]); - const remotes = new RemoteSet([ - new Remote('origin', 'git@github.com:aaa/bbb.git'), - ]); - - const wrapper = shallow(buildApp({ - repositoryName: 'bbb', - ownerLogin: 'aaa', - pullRequestHeadRef: 'feature', - issueishNumber: 123, - pullRequestHeadRepoOwner: 'ccc', - pullRequestHeadRepoName: 'ddd', - }, { - branches, - remotes, - })); - - const op = wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp'); - assert.isFalse(op.isEnabled()); - assert.strictEqual(op.getMessage(), 'Current'); + it('opens a CommitDetailItem in the workspace', function() { + assert.include( + atomEnv.workspace.getPaneItems().map(item => item.getURI()), + CommitDetailItem.buildURI(__dirname, '1234'), + ); }); - it('creates a new remote, fetches a PR branch, and checks it out into a new local branch', async function() { - const upstream = Branch.createRemoteTracking('remotes/origin/current', 'origin', 'refs/heads/current'); - const branches = new BranchSet([ - new Branch('current', upstream, upstream, true), - ]); - const remotes = new RemoteSet([ - new Remote('origin', 'git@github.com:aaa/bbb.git'), - ]); - - const addRemote = sinon.stub().resolves(new Remote('ccc', 'git@github.com:ccc/ddd.git')); - const fetch = sinon.stub().resolves(); - const checkout = sinon.stub().resolves(); - - const wrapper = shallow(buildApp({ - issueishNumber: 456, - pullRequestHeadRef: 'feature', - pullRequestHeadRepoOwner: 'ccc', - pullRequestHeadRepoName: 'ddd', - }, { - branches, - remotes, - addRemote, - fetch, - checkout, - })); - - sinon.spy(reporterProxy, 'incrementCounter'); - await wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp').run(); - - assert.isTrue(addRemote.calledWith('ccc', 'git@github.com:ccc/ddd.git')); - assert.isTrue(fetch.calledWith('refs/heads/feature', {remoteName: 'ccc'})); - assert.isTrue(checkout.calledWith('pr-456/ccc/feature', { - createNew: true, - track: true, - startPoint: 'refs/remotes/ccc/feature', - })); - - assert.isTrue(reporterProxy.incrementCounter.calledWith('checkout-pr')); + it('reports an event', function() { + assert.isTrue( + reporterProxy.addEvent.calledWith( + 'open-commit-in-pane', {package: 'github', from: 'BareIssueishDetailController'}, + ), + ); }); + }); - it('fetches a PR branch from an existing remote and checks it out into a new local branch', async function() { - const branches = new BranchSet([ - new Branch('current', nullBranch, nullBranch, true), - ]); - const remotes = new RemoteSet([ - new Remote('origin', 'git@github.com:aaa/bbb.git'), - new Remote('existing', 'git@github.com:ccc/ddd.git'), - ]); - - const fetch = sinon.stub().resolves(); - const checkout = sinon.stub().resolves(); + describe('openReviews', function() { + it('opens a ReviewsItem corresponding to our pull request', async function() { + const repository = repositoryBuilder(repositoryQuery) + .owner(o => o.login('me')) + .name('my-bullshit') + .issue(i => i.__typename('PullRequest')) + .build(); const wrapper = shallow(buildApp({ - issueishNumber: 789, - pullRequestHeadRef: 'clever-name', - pullRequestHeadRepoOwner: 'ccc', - pullRequestHeadRepoName: 'ddd', - }, { - branches, - remotes, - fetch, - checkout, - })); - - sinon.spy(reporterProxy, 'incrementCounter'); - await wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp').run(); - - assert.isTrue(fetch.calledWith('refs/heads/clever-name', {remoteName: 'existing'})); - assert.isTrue(checkout.calledWith('pr-789/ccc/clever-name', { - createNew: true, - track: true, - startPoint: 'refs/remotes/existing/clever-name', + repository, + endpoint: getEndpoint('github.enterprise.horse'), + issueishNumber: 100, + workdirPath: __dirname, })); + const checkoutWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')( + new EnableableOperation(() => {}), + ); + await checkoutWrapper.find(PullRequestDetailView).prop('openReviews')(); - assert.isTrue(reporterProxy.incrementCounter.calledWith('checkout-pr')); + assert.include( + atomEnv.workspace.getPaneItems().map(item => item.getURI()), + ReviewsItem.buildURI({ + host: 'github.enterprise.horse', + owner: 'me', + repo: 'my-bullshit', + number: 100, + workdir: __dirname, + }), + ); }); - it('checks out an existing local branch that corresponds to the pull request', async function() { - const currentUpstream = Branch.createRemoteTracking('remotes/origin/current', 'origin', 'refs/heads/current'); - const branches = new BranchSet([ - new Branch('current', currentUpstream, currentUpstream, true), - new Branch('existing', Branch.createRemoteTracking('remotes/upstream/pull/123', 'upstream', 'refs/heads/yes')), - new Branch('wrong/remote', Branch.createRemoteTracking('remotes/wrong/pull/123', 'wrong', 'refs/heads/yes')), - new Branch('wrong/ref', Branch.createRemoteTracking('remotes/upstream/pull/123', 'upstream', 'refs/heads/no')), - ]); - const remotes = new RemoteSet([ - new Remote('origin', 'git@github.com:aaa/bbb.git'), - new Remote('upstream', 'git@github.com:ccc/ddd.git'), - new Remote('wrong', 'git@github.com:eee/fff.git'), - ]); - - const pull = sinon.stub().resolves(); - const checkout = sinon.stub().resolves(); + it('opens a ReviewsItem for a pull request that has no local workdir', async function() { + const repository = repositoryBuilder(repositoryQuery) + .owner(o => o.login('me')) + .name('my-bullshit') + .issue(i => i.__typename('PullRequest')) + .build(); const wrapper = shallow(buildApp({ - issueishNumber: 456, - pullRequestHeadRef: 'yes', - pullRequestHeadRepoOwner: 'ccc', - pullRequestHeadRepoName: 'ddd', - }, { - branches, - remotes, - fetch, - pull, - checkout, + repository, + endpoint: getEndpoint('github.enterprise.horse'), + issueishNumber: 100, + workdirPath: null, })); - - sinon.spy(reporterProxy, 'incrementCounter'); - await wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp').run(); - - assert.isTrue(checkout.calledWith('existing')); - assert.isTrue(pull.calledWith('refs/heads/yes', {remoteName: 'upstream', ffOnly: true})); - assert.isTrue(reporterProxy.incrementCounter.calledWith('checkout-pr')); - }); - - it('squelches git errors', async function() { - const addRemote = sinon.stub().rejects(new GitError('handled by the pipeline')); - const wrapper = shallow(buildApp({}, {addRemote})); - - // Should not throw - await wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp').run(); - assert.isTrue(addRemote.called); - }); - - it('propagates non-git errors', async function() { - const addRemote = sinon.stub().rejects(new Error('not handled by the pipeline')); - const wrapper = shallow(buildApp({}, {addRemote})); - - await assert.isRejected( - wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('checkoutOp').run(), - /not handled by the pipeline/, + const checkoutWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')( + new EnableableOperation(() => {}), ); - assert.isTrue(addRemote.called); - }); - }); - - describe('openCommit', function() { - it('opens a CommitDetailItem in the workspace', async function() { - const wrapper = shallow(buildApp({}, {workdirPath: __dirname})); - await wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('openCommit')({sha: '1234'}); + await checkoutWrapper.find(PullRequestDetailView).prop('openReviews')(); assert.include( atomEnv.workspace.getPaneItems().map(item => item.getURI()), - CommitDetailItem.buildURI(__dirname, '1234'), - ); - }); - - it('reports an event', async function() { - sinon.stub(reporterProxy, 'addEvent'); - - const wrapper = shallow(buildApp({}, {workdirPath: __dirname})); - await wrapper.find('ForwardRef(Relay(BarePullRequestDetailView))').prop('openCommit')({sha: '1234'}); - - assert.isTrue( - reporterProxy.addEvent.calledWith( - 'open-commit-in-pane', {package: 'github', from: 'BareIssueishDetailController'}, - ), + ReviewsItem.buildURI({ + host: 'github.enterprise.horse', + owner: 'me', + repo: 'my-bullshit', + number: 100, + }), ); }); }); diff --git a/test/controllers/issueish-list-controller.test.js b/test/controllers/issueish-list-controller.test.js index d5b0970684..ea1eabe7cb 100644 --- a/test/controllers/issueish-list-controller.test.js +++ b/test/controllers/issueish-list-controller.test.js @@ -4,8 +4,11 @@ import {shallow} from 'enzyme'; import {createPullRequestResult} from '../fixtures/factories/pull-request-result'; import Issueish from '../../lib/models/issueish'; import {BareIssueishListController} from '../../lib/controllers/issueish-list-controller'; +import {getEndpoint} from '../../lib/models/endpoint'; +import * as reporterProxy from '../../lib/reporter-proxy'; describe('IssueishListController', function() { + function buildApp(overrideProps = {}) { return ( issueish.getNumber()), [11, 13, 12]); }); + + it('opens reviews', async function() { + const atomEnv = global.buildAtomEnvironment(); + sinon.stub(atomEnv.workspace, 'open').resolves(); + sinon.stub(reporterProxy, 'addEvent'); + const pr = createPullRequestResult({number: 1337}); + Object.assign(pr.repository, {owner: {login: 'owner'}, name: 'repo'}); + const wrapper = shallow(buildApp({ + workspace: atomEnv.workspace, + endpoint: getEndpoint('github.com'), + results: [pr], + })); + + await wrapper.find('IssueishListView').prop('openReviews')(); + assert.isTrue(atomEnv.workspace.open.called); + assert.isTrue(reporterProxy.addEvent.calledWith('open-reviews-tab', {package: 'github', from: 'BareIssueishListController'})); + + atomEnv.destroy(); + }); }); diff --git a/test/controllers/issueish-searches-controller.test.js b/test/controllers/issueish-searches-controller.test.js index 2f99034d47..96f917d3c8 100644 --- a/test/controllers/issueish-searches-controller.test.js +++ b/test/controllers/issueish-searches-controller.test.js @@ -1,9 +1,8 @@ import React from 'react'; import {shallow} from 'enzyme'; -import {createRepositoryResult} from '../fixtures/factories/repository-result'; -import {createPullRequestResult} from '../fixtures/factories/pull-request-result'; import IssueishSearchesController from '../../lib/controllers/issueish-searches-controller'; +import {queryBuilder} from '../builder/graphql/query'; import Remote from '../../lib/models/remote'; import RemoteSet from '../../lib/models/remote-set'; import Branch from '../../lib/models/branch'; @@ -13,6 +12,8 @@ import {getEndpoint} from '../../lib/models/endpoint'; import {nullOperationStateObserver} from '../../lib/models/operation-state-observer'; import * as reporterProxy from '../../lib/reporter-proxy'; +import remoteContainerQuery from '../../lib/containers/__generated__/remoteContainerQuery.graphql'; + describe('IssueishSearchesController', function() { let atomEnv; const origin = new Remote('origin', 'git@github.com:atom/github.git'); @@ -35,7 +36,7 @@ describe('IssueishSearchesController', function() { ; + } + + it('is disabled if the repository is loading or absent', function() { + const wrapper = shallow(buildApp({isAbsent: true})); + const [op] = children.lastCall.args; + assert.isFalse(op.isEnabled()); + assert.strictEqual(op.getMessage(), 'No repository found'); + + wrapper.setProps({isAbsent: false, isLoading: true}); + const [op1] = children.lastCall.args; + assert.isFalse(op1.isEnabled()); + assert.strictEqual(op1.getMessage(), 'Loading'); + + wrapper.setProps({isAbsent: false, isLoading: false, isPresent: false}); + const [op2] = children.lastCall.args; + assert.isFalse(op2.isEnabled()); + assert.strictEqual(op2.getMessage(), 'No repository found'); + }); + + it('is disabled if the local repository is merging or rebasing', function() { + const wrapper = shallow(buildApp({isMerging: true})); + const [op0] = children.lastCall.args; + assert.isFalse(op0.isEnabled()); + assert.strictEqual(op0.getMessage(), 'Merge in progress'); + + wrapper.setProps({isMerging: false, isRebasing: true}); + const [op1] = children.lastCall.args; + assert.isFalse(op1.isEnabled()); + assert.strictEqual(op1.getMessage(), 'Rebase in progress'); + }); + + it('is disabled if the pullRequest has no headRepository', function() { + shallow(buildApp({ + pullRequest: pullRequestBuilder(pullRequestQuery).nullHeadRepository().build(), + })); + + const [op] = children.lastCall.args; + assert.isFalse(op.isEnabled()); + assert.strictEqual(op.getMessage(), 'Pull request head repository does not exist'); + }); + + it('is disabled if the current branch already corresponds to the pull request', function() { + const upstream = Branch.createRemoteTracking('remotes/origin/feature', 'origin', 'refs/heads/feature'); + const branches = new BranchSet([ + new Branch('current', upstream, upstream, true), + ]); + const remotes = new RemoteSet([ + new Remote('origin', 'git@github.com:aaa/bbb.git'), + ]); + + const pullRequest = pullRequestBuilder(pullRequestQuery) + .headRefName('feature') + .headRepository(r => { + r.owner(o => o.login('aaa')); + r.name('bbb'); + }) + .build(); + + shallow(buildApp({ + pullRequest, + branches, + remotes, + })); + + const [op] = children.lastCall.args; + assert.isFalse(op.isEnabled()); + assert.strictEqual(op.getMessage(), 'Current'); + }); + + it('recognizes a current branch even if it was pulled from the refs/pull/... ref', function() { + const upstream = Branch.createRemoteTracking('remotes/origin/pull/123/head', 'origin', 'refs/pull/123/head'); + const branches = new BranchSet([ + new Branch('current', upstream, upstream, true), + ]); + const remotes = new RemoteSet([ + new Remote('origin', 'git@github.com:aaa/bbb.git'), + ]); + + const repository = repositoryBuilder(repositoryQuery) + .owner(o => o.login('aaa')) + .name('bbb') + .build(); + + const pullRequest = pullRequestBuilder(pullRequestQuery) + .number(123) + .headRefName('feature') + .headRepository(r => { + r.owner(o => o.login('ccc')); + r.name('ddd'); + }) + .build(); + + shallow(buildApp({ + repository, + pullRequest, + branches, + remotes, + })); + + const [op] = children.lastCall.args; + assert.isFalse(op.isEnabled()); + assert.strictEqual(op.getMessage(), 'Current'); + }); + + it('creates a new remote, fetches a PR branch, and checks it out into a new local branch', async function() { + const upstream = Branch.createRemoteTracking('remotes/origin/current', 'origin', 'refs/heads/current'); + const branches = new BranchSet([ + new Branch('current', upstream, upstream, true), + ]); + const remotes = new RemoteSet([ + new Remote('origin', 'git@github.com:aaa/bbb.git'), + ]); + + sinon.stub(localRepository, 'addRemote').resolves(new Remote('ccc', 'git@github.com:ccc/ddd.git')); + sinon.stub(localRepository, 'fetch').resolves(); + sinon.stub(localRepository, 'checkout').resolves(); + + const pullRequest = pullRequestBuilder(pullRequestQuery) + .number(456) + .headRefName('feature') + .headRepository(r => { + r.owner(o => o.login('ccc')); + r.name('ddd'); + }) + .build(); + + shallow(buildApp({ + pullRequest, + branches, + remotes, + })); + + sinon.spy(reporterProxy, 'incrementCounter'); + const [op] = children.lastCall.args; + await op.run(); + + assert.isTrue(localRepository.addRemote.calledWith('ccc', 'git@github.com:ccc/ddd.git')); + assert.isTrue(localRepository.fetch.calledWith('refs/heads/feature', {remoteName: 'ccc'})); + assert.isTrue(localRepository.checkout.calledWith('pr-456/ccc/feature', { + createNew: true, + track: true, + startPoint: 'refs/remotes/ccc/feature', + })); + + assert.isTrue(reporterProxy.incrementCounter.calledWith('checkout-pr')); + }); + + it('fetches a PR branch from an existing remote and checks it out into a new local branch', async function() { + sinon.stub(localRepository, 'fetch').resolves(); + sinon.stub(localRepository, 'checkout').resolves(); + + const branches = new BranchSet([ + new Branch('current', nullBranch, nullBranch, true), + ]); + const remotes = new RemoteSet([ + new Remote('origin', 'git@github.com:aaa/bbb.git'), + new Remote('existing', 'git@github.com:ccc/ddd.git'), + ]); + + const pullRequest = pullRequestBuilder(pullRequestQuery) + .number(789) + .headRefName('clever-name') + .headRepository(r => { + r.owner(o => o.login('ccc')); + r.name('ddd'); + }) + .build(); + + shallow(buildApp({ + pullRequest, + branches, + remotes, + })); + + sinon.spy(reporterProxy, 'incrementCounter'); + const [op] = children.lastCall.args; + await op.run(); + + assert.isTrue(localRepository.fetch.calledWith('refs/heads/clever-name', {remoteName: 'existing'})); + assert.isTrue(localRepository.checkout.calledWith('pr-789/ccc/clever-name', { + createNew: true, + track: true, + startPoint: 'refs/remotes/existing/clever-name', + })); + + assert.isTrue(reporterProxy.incrementCounter.calledWith('checkout-pr')); + }); + + it('checks out an existing local branch that corresponds to the pull request', async function() { + sinon.stub(localRepository, 'pull').resolves(); + sinon.stub(localRepository, 'checkout').resolves(); + + const currentUpstream = Branch.createRemoteTracking('remotes/origin/current', 'origin', 'refs/heads/current'); + const branches = new BranchSet([ + new Branch('current', currentUpstream, currentUpstream, true), + new Branch('existing', Branch.createRemoteTracking('remotes/upstream/pull/123', 'upstream', 'refs/heads/yes')), + new Branch('wrong/remote', Branch.createRemoteTracking('remotes/wrong/pull/123', 'wrong', 'refs/heads/yes')), + new Branch('wrong/ref', Branch.createRemoteTracking('remotes/upstream/pull/123', 'upstream', 'refs/heads/no')), + ]); + const remotes = new RemoteSet([ + new Remote('origin', 'git@github.com:aaa/bbb.git'), + new Remote('upstream', 'git@github.com:ccc/ddd.git'), + new Remote('wrong', 'git@github.com:eee/fff.git'), + ]); + + const pullRequest = pullRequestBuilder(pullRequestQuery) + .number(456) + .headRefName('yes') + .headRepository(r => { + r.owner(o => o.login('ccc')); + r.name('ddd'); + }) + .build(); + + shallow(buildApp({ + pullRequest, + branches, + remotes, + })); + + sinon.spy(reporterProxy, 'incrementCounter'); + const [op] = children.lastCall.args; + await op.run(); + + assert.isTrue(localRepository.checkout.calledWith('existing')); + assert.isTrue(localRepository.pull.calledWith('refs/heads/yes', {remoteName: 'upstream', ffOnly: true})); + assert.isTrue(reporterProxy.incrementCounter.calledWith('checkout-pr')); + }); + + it('squelches git errors', async function() { + sinon.stub(localRepository, 'addRemote').rejects(new GitError('handled by the pipeline')); + shallow(buildApp({})); + + // Should not throw + const [op] = children.lastCall.args; + await op.run(); + assert.isTrue(localRepository.addRemote.called); + }); + + it('propagates non-git errors', async function() { + sinon.stub(localRepository, 'addRemote').rejects(new Error('not handled by the pipeline')); + shallow(buildApp({})); + + const [op] = children.lastCall.args; + await assert.isRejected(op.run(), /not handled by the pipeline/); + assert.isTrue(localRepository.addRemote.called); + }); +}); diff --git a/test/controllers/pr-reviews-controller.test.js b/test/controllers/pr-reviews-controller.test.js deleted file mode 100644 index 60e2dbf2be..0000000000 --- a/test/controllers/pr-reviews-controller.test.js +++ /dev/null @@ -1,278 +0,0 @@ -import React from 'react'; -import {shallow} from 'enzyme'; -import {reviewBuilder} from '../builder/pr'; - -import PullRequestReviewsController from '../../lib/controllers/pr-reviews-controller'; - -import {PAGE_SIZE, PAGINATION_WAIT_TIME_MS} from '../../lib/helpers'; - -describe('PullRequestReviewsController', function() { - function buildApp(opts, overrideProps = {}) { - const o = { - relayHasMore: () => { return false; }, - relayLoadMore: () => {}, - relayIsLoading: () => { return false; }, - reviewSpecs: [], - reviewStartCursor: 0, - ...opts, - }; - - const reviews = { - edges: o.reviewSpecs.map((spec, i) => ({ - cursor: `result${i}`, - node: { - id: spec.id, - __typename: 'review', - }, - })), - pageInfo: { - startCursor: `result${o.reviewStartCursor}`, - endCursor: `result${o.reviewStartCursor + o.reviewSpecs.length}`, - hasNextPage: o.reviewStartCursor + o.reviewSpecs.length < o.reviewItemTotal, - hasPreviousPage: o.reviewStartCursor !== 0, - }, - totalCount: o.reviewItemTotal, - }; - - const props = { - relay: { - hasMore: o.relayHasMore, - loadMore: o.relayLoadMore, - isLoading: o.relayIsLoading, - }, - - switchToIssueish: () => {}, - getBufferRowForDiffPosition: () => {}, - isPatchVisible: () => true, - pullRequest: {reviews}, - ...overrideProps, - }; - return ; - } - it('returns null if props.pullRequest is falsy', function() { - const wrapper = shallow(buildApp({}, {pullRequest: null})); - assert.isNull(wrapper.getElement()); - }); - - it('returns null if props.pullRequest.reviews is falsy', function() { - const wrapper = shallow(buildApp({}, {pullRequest: {reviews: null}})); - assert.isNull(wrapper.getElement()); - }); - - it('renders a PullRequestReviewCommentsContainer for every review', function() { - const review1 = reviewBuilder().build(); - const review2 = reviewBuilder().build(); - - const reviewSpecs = [review1, review2]; - const wrapper = shallow(buildApp({reviewSpecs})); - const containers = wrapper.find('ForwardRef(Relay(BarePullRequestReviewCommentsContainer))'); - assert.strictEqual(containers.length, 2); - - assert.strictEqual(containers.at(0).prop('review').id, review1.id); - assert.strictEqual(containers.at(1).prop('review').id, review2.id); - }); - - it('renders a PullRequestReviewCommentsView and passes props through', function() { - const review1 = reviewBuilder().build(); - const review2 = reviewBuilder().build(); - - const reviewSpecs = [review1, review2]; - const passThroughProp = 'I only exist for the children'; - const wrapper = shallow(buildApp({reviewSpecs}, {passThroughProp})); - const view = wrapper.find('PullRequestCommentsView'); - assert.strictEqual(view.length, 1); - - assert.strictEqual(wrapper.instance().props.passThroughProp, view.prop('passThroughProp')); - }); - - describe('collectComments', function() { - it('sets this.reviewsById with correct data', function() { - const wrapper = shallow(buildApp()); - const args = {reviewId: 123, submittedAt: '2018-12-27T20:40:55Z', comments: ['a comment', - ], fetchingMoreComments: true}; - assert.strictEqual(wrapper.instance().reviewsById.size, 0); - wrapper.instance().collectComments(args); - const review = wrapper.instance().reviewsById.get(args.reviewId); - delete args.reviewId; - assert.deepEqual(review, args); - }); - - it('calls groupCommentsByThread if there are no more reviews or comments to be fetched', function() { - const wrapper = shallow(buildApp()); - const groupCommentsStub = sinon.stub(wrapper.instance(), 'groupCommentsByThread'); - assert.isFalse(groupCommentsStub.called); - const args = {reviewId: 123, submittedAt: '2018-12-27T20:40:55Z', comments: ['a comment', - ], fetchingMoreComments: false}; - wrapper.instance().collectComments(args); - assert.strictEqual(groupCommentsStub.callCount, 1); - }); - }); - - describe('attemptToLoadMoreReviews', function() { - it('does not call loadMore if hasMore is false', function() { - const relayLoadMoreStub = sinon.stub(); - const wrapper = shallow(buildApp({relayLoadMore: relayLoadMoreStub})); - relayLoadMoreStub.reset(); - - wrapper.instance()._attemptToLoadMoreReviews(); - assert.strictEqual(relayLoadMoreStub.callCount, 0); - }); - - it('calls loadMore immediately if hasMore is true and isLoading is false', function() { - const relayLoadMoreStub = sinon.stub(); - const relayHasMore = () => { return true; }; - const wrapper = shallow(buildApp({relayHasMore, relayLoadMore: relayLoadMoreStub})); - relayLoadMoreStub.reset(); - - wrapper.instance()._attemptToLoadMoreReviews(); - assert.strictEqual(relayLoadMoreStub.callCount, 1); - assert.deepEqual(relayLoadMoreStub.lastCall.args, [PAGE_SIZE, wrapper.instance().accumulateReviews]); - }); - - it('calls loadMore after a timeout if hasMore is true and isLoading is true', function() { - const clock = sinon.useFakeTimers(); - const relayLoadMoreStub = sinon.stub(); - const relayHasMore = () => { return true; }; - const relayIsLoading = () => { return true; }; - const wrapper = shallow(buildApp({relayHasMore, relayIsLoading, relayLoadMore: relayLoadMoreStub})); - // advancing the timer and resetting the stub to clear the initial calls of - // _attemptToLoadMoreReviews when the component is initially mounted. - clock.tick(PAGINATION_WAIT_TIME_MS); - relayLoadMoreStub.reset(); - - wrapper.instance()._attemptToLoadMoreReviews(); - assert.strictEqual(relayLoadMoreStub.callCount, 0); - - clock.tick(PAGINATION_WAIT_TIME_MS); - assert.strictEqual(relayLoadMoreStub.callCount, 1); - assert.deepEqual(relayLoadMoreStub.lastCall.args, [PAGE_SIZE, wrapper.instance().accumulateReviews]); - sinon.restore(); - }); - }); - - describe('_loadMoreReviews', function() { - it('calls this.props.relay.loadMore with correct args', function() { - const relayLoadMoreStub = sinon.stub(); - const wrapper = shallow(buildApp({relayLoadMore: relayLoadMoreStub})); - wrapper.instance()._loadMoreReviews(); - - assert.deepEqual(relayLoadMoreStub.lastCall.args, [PAGE_SIZE, wrapper.instance().accumulateReviews]); - }); - }); - - describe('grouping and ordering comments', function() { - it('groups the comments into threads based on replyId', function() { - const originalCommentId = 1; - const singleCommentId = 5; - const review1 = reviewBuilder() - .id(0) - .submittedAt('2018-12-27T20:40:55Z') - .addComment(c => c.id(originalCommentId).path('file0.txt').body('OG comment')) - .build(); - - const review2 = reviewBuilder() - .id(1) - .submittedAt('2018-12-28T20:40:55Z') - .addComment(c => c.id(2).path('file0.txt').replyTo(originalCommentId).body('reply to OG comment')) - .addComment(c => c.id(singleCommentId).path('file0.txt').body('I am single and free')) - .build(); - - const wrapper = shallow(buildApp({reviewSpecs: [review1, review2]})); - - // adding this manually to reviewsById because the last time you call collectComments it groups them, and we don't want to do that just yet. - wrapper.instance().reviewsById.set(review1.id, {submittedAt: review1.submittedAt, comments: review1.comments, fetchingMoreComments: false}); - - wrapper.instance().collectComments({reviewId: review2.id, submittedAt: review2.submittedAt, comments: review2.comments, fetchingMoreComments: false}); - const threadedComments = wrapper.instance().state[originalCommentId]; - assert.lengthOf(threadedComments, 2); - assert.strictEqual(threadedComments[0].body, 'OG comment'); - assert.strictEqual(threadedComments[1].body, 'reply to OG comment'); - - const singleComment = wrapper.instance().state[singleCommentId]; - assert.strictEqual(singleComment[0].body, 'I am single and free'); - }); - - it('comments are ordered based on the order in which their reviews were submitted', function() { - const originalCommentId = 1; - const review1 = reviewBuilder() - .id(0) - .submittedAt('2018-12-20T20:40:55Z') - .addComment(c => c.id(originalCommentId).path('file0.txt').body('OG comment')) - .build(); - - const review2 = reviewBuilder() - .id(1) - .submittedAt('2018-12-22T20:40:55Z') - .addComment(c => c.id(2).path('file0.txt').replyTo(originalCommentId).body('first reply to OG comment')) - .build(); - - const review3 = reviewBuilder() - .id(2) - .submittedAt('2018-12-25T20:40:55Z') - .addComment(c => c.id(3).path('file0.txt').replyTo(originalCommentId).body('second reply to OG comment')) - .build(); - - const wrapper = shallow(buildApp({reviewSpecs: [review1, review2, review3]})); - - // adding these manually to reviewsById because the last time you call collectComments it groups them, and we don't want to do that just yet. - wrapper.instance().reviewsById.set(review2.id, {submittedAt: review2.submittedAt, comments: review2.comments, fetchingMoreComments: false}); - wrapper.instance().reviewsById.set(review1.id, {submittedAt: review1.submittedAt, comments: review1.comments, fetchingMoreComments: false}); - - wrapper.instance().collectComments({reviewId: review3.id, submittedAt: review3.submittedAt, comments: review3.comments, fetchingMoreComments: false}); - const threadedComments = wrapper.instance().state[originalCommentId]; - assert.lengthOf(threadedComments, 3); - - assert.strictEqual(threadedComments[0].body, 'OG comment'); - assert.strictEqual(threadedComments[1].body, 'first reply to OG comment'); - assert.strictEqual(threadedComments[2].body, 'second reply to OG comment'); - }); - - it('comments with a replyTo id that does not point to an existing comment are threaded separately', function() { - const outdatedCommentId = 1; - const replyToOutdatedCommentId = 2; - const review = reviewBuilder() - .id(2) - .submittedAt('2018-12-28T20:40:55Z') - .addComment(c => c.id(replyToOutdatedCommentId).path('file0.txt').replyTo(outdatedCommentId).body('reply to outdated comment')) - .build(); - - const wrapper = shallow(buildApp({reviewSpecs: [review]})); - wrapper.instance().collectComments({reviewId: review.id, submittedAt: review.submittedAt, comments: review.comments, fetchingMoreComments: false}); - - const comments = wrapper.instance().state[replyToOutdatedCommentId]; - assert.lengthOf(comments, 1); - assert.strictEqual(comments[0].body, 'reply to outdated comment'); - }); - }); - - describe('accumulateReviews', function() { - it('attempts to load more reviews', function() { - const wrapper = shallow(buildApp()); - - const loadMoreStub = sinon.stub(wrapper.instance(), '_attemptToLoadMoreReviews'); - wrapper.instance().accumulateReviews(); - - assert.strictEqual(loadMoreStub.callCount, 1); - }); - }); - - describe('compareReviewsByDate', function() { - let wrapper; - const reviewA = reviewBuilder().submittedAt('2018-12-28T20:40:55Z').build(); - const reviewB = reviewBuilder().submittedAt('2018-12-27T20:40:55Z').build(); - - beforeEach(function() { - wrapper = shallow(buildApp()); - }); - - it('returns 1 if reviewA is older', function() { - assert.strictEqual(wrapper.instance().compareReviewsByDate(reviewA, reviewB), 1); - }); - it('return -1 if reviewB is older', function() { - assert.strictEqual(wrapper.instance().compareReviewsByDate(reviewB, reviewA), -1); - }); - it('returns 0 if reviews have the same date', function() { - assert.strictEqual(wrapper.instance().compareReviewsByDate(reviewA, reviewA), 0); - }); - }); -}); diff --git a/test/controllers/reaction-picker-controller.test.js b/test/controllers/reaction-picker-controller.test.js new file mode 100644 index 0000000000..f0a6b7e0da --- /dev/null +++ b/test/controllers/reaction-picker-controller.test.js @@ -0,0 +1,66 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import ReactionPickerController from '../../lib/controllers/reaction-picker-controller'; +import ReactionPickerView from '../../lib/views/reaction-picker-view'; +import RefHolder from '../../lib/models/ref-holder'; + +describe('ReactionPickerController', function() { + let atomEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const props = { + addReaction: () => Promise.resolve(), + removeReaction: () => Promise.resolve(), + tooltipHolder: new RefHolder(), + ...override, + }; + + return ; + } + + it('renders a ReactionPickerView and passes props', function() { + const extra = Symbol('extra'); + const wrapper = shallow(buildApp({extra})); + + assert.strictEqual(wrapper.find(ReactionPickerView).prop('extra'), extra); + }); + + it('adds a reaction, then closes the tooltip', async function() { + const addReaction = sinon.stub().resolves(); + + const mockTooltip = {dispose: sinon.spy()}; + const tooltipHolder = new RefHolder(); + tooltipHolder.setter(mockTooltip); + + const wrapper = shallow(buildApp({addReaction, tooltipHolder})); + + await wrapper.find(ReactionPickerView).prop('addReactionAndClose')('THUMBS_UP'); + + assert.isTrue(addReaction.calledWith('THUMBS_UP')); + assert.isTrue(mockTooltip.dispose.called); + }); + + it('removes a reaction, then closes the tooltip', async function() { + const removeReaction = sinon.stub().resolves(); + + const mockTooltip = {dispose: sinon.spy()}; + const tooltipHolder = new RefHolder(); + tooltipHolder.setter(mockTooltip); + + const wrapper = shallow(buildApp({removeReaction, tooltipHolder})); + + await wrapper.find(ReactionPickerView).prop('removeReactionAndClose')('THUMBS_DOWN'); + + assert.isTrue(removeReaction.calledWith('THUMBS_DOWN')); + assert.isTrue(mockTooltip.dispose.called); + }); +}); diff --git a/test/controllers/recent-commits-controller.test.js b/test/controllers/recent-commits-controller.test.js index 6cb9b040bb..875c2fb3fa 100644 --- a/test/controllers/recent-commits-controller.test.js +++ b/test/controllers/recent-commits-controller.test.js @@ -3,9 +3,8 @@ import {shallow, mount} from 'enzyme'; import RecentCommitsController from '../../lib/controllers/recent-commits-controller'; import CommitDetailItem from '../../lib/items/commit-detail-item'; -import URIPattern from '../../lib/atom/uri-pattern'; import {commitBuilder} from '../builder/commit'; -import {cloneRepository, buildRepository} from '../helpers'; +import {cloneRepository, buildRepository, registerGitHubOpener} from '../helpers'; import * as reporterProxy from '../../lib/reporter-proxy'; describe('RecentCommitsController', function() { @@ -210,16 +209,7 @@ describe('RecentCommitsController', function() { describe('workspace tracking', function() { beforeEach(function() { - const pattern = new URIPattern(CommitDetailItem.uriPattern); - // Prevent the Workspace from normalizing CommitDetailItem URIs - atomEnv.workspace.addOpener(uri => { - if (pattern.matches(uri).ok()) { - return { - getURI() { return uri; }, - }; - } - return undefined; - }); + registerGitHubOpener(atomEnv); }); it('updates the selected sha when its CommitDetailItem is activated', async function() { diff --git a/test/controllers/reviews-controller.test.js b/test/controllers/reviews-controller.test.js new file mode 100644 index 0000000000..35f6c90b63 --- /dev/null +++ b/test/controllers/reviews-controller.test.js @@ -0,0 +1,686 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import {BareReviewsController} from '../../lib/controllers/reviews-controller'; +import PullRequestCheckoutController from '../../lib/controllers/pr-checkout-controller'; +import ReviewsView from '../../lib/views/reviews-view'; +import IssueishDetailItem from '../../lib/items/issueish-detail-item'; +import BranchSet from '../../lib/models/branch-set'; +import RemoteSet from '../../lib/models/remote-set'; +import EnableableOperation from '../../lib/models/enableable-operation'; +import WorkdirContextPool from '../../lib/models/workdir-context-pool'; +import * as reporterProxy from '../../lib/reporter-proxy'; +import {getEndpoint} from '../../lib/models/endpoint'; +import {cloneRepository, buildRepository, registerGitHubOpener} from '../helpers'; +import {multiFilePatchBuilder} from '../builder/patch'; +import {userBuilder} from '../builder/graphql/user'; +import {pullRequestBuilder} from '../builder/graphql/pr'; +import RelayNetworkLayerManager, {expectRelayQuery} from '../../lib/relay-network-layer-manager'; +import {relayResponseBuilder} from '../builder/graphql/query'; + +import viewerQuery from '../../lib/controllers/__generated__/reviewsController_viewer.graphql'; +import pullRequestQuery from '../../lib/controllers/__generated__/reviewsController_pullRequest.graphql'; + +import addPrReviewMutation from '../../lib/mutations/__generated__/addPrReviewMutation.graphql'; +import addPrReviewCommentMutation from '../../lib/mutations/__generated__/addPrReviewCommentMutation.graphql'; +import deletePrReviewMutation from '../../lib/mutations/__generated__/deletePrReviewMutation.graphql'; +import submitPrReviewMutation from '../../lib/mutations/__generated__/submitPrReviewMutation.graphql'; +import resolveThreadMutation from '../../lib/mutations/__generated__/resolveReviewThreadMutation.graphql'; +import unresolveThreadMutation from '../../lib/mutations/__generated__/unresolveReviewThreadMutation.graphql'; + +describe('ReviewsController', function() { + let atomEnv, relayEnv, localRepository, noop, clock; + + beforeEach(async function() { + atomEnv = global.buildAtomEnvironment(); + registerGitHubOpener(atomEnv); + + localRepository = await buildRepository(await cloneRepository()); + + noop = new EnableableOperation(() => {}); + + relayEnv = RelayNetworkLayerManager.getEnvironmentForHost(getEndpoint('github.com'), '1234'); + }); + + afterEach(function() { + atomEnv.destroy(); + + if (clock) { + clock.restore(); + } + }); + + function buildApp(override = {}) { + const props = { + relay: {environment: relayEnv}, + viewer: userBuilder(viewerQuery).build(), + repository: {}, + pullRequest: pullRequestBuilder(pullRequestQuery).build(), + + workdirContextPool: new WorkdirContextPool(), + localRepository, + isAbsent: false, + isLoading: false, + isPresent: true, + isMerging: true, + isRebasing: true, + branches: new BranchSet(), + remotes: new RemoteSet(), + multiFilePatch: multiFilePatchBuilder().build(), + + endpoint: getEndpoint('github.com'), + + owner: 'atom', + repo: 'github', + number: 1995, + workdir: localRepository.getWorkingDirectoryPath(), + + workspace: atomEnv.workspace, + config: atomEnv.config, + commands: atomEnv.commands, + tooltips: atomEnv.tooltips, + reportMutationErrors: () => {}, + + ...override, + }; + + return ; + } + + it('renders a ReviewsView inside a PullRequestCheckoutController', function() { + const extra = Symbol('extra'); + const wrapper = shallow(buildApp({extra})); + const opWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + + assert.strictEqual(opWrapper.find(ReviewsView).prop('extra'), extra); + }); + + it('scrolls to a specific thread on mount', function() { + clock = sinon.useFakeTimers(); + const wrapper = shallow(buildApp({initThreadID: 'thread0'})); + let opWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + + assert.include(opWrapper.find(ReviewsView).prop('threadIDsOpen'), 'thread0'); + assert.strictEqual(opWrapper.find(ReviewsView).prop('scrollToThreadID'), 'thread0'); + + clock.tick(2000); + opWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + assert.isNull(opWrapper.find(ReviewsView).prop('scrollToThreadID')); + }); + + it('scrolls to a specific thread on update', function() { + clock = sinon.useFakeTimers(); + const wrapper = shallow(buildApp()); + let opWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + + assert.deepEqual(opWrapper.find(ReviewsView).prop('threadIDsOpen'), new Set([])); + wrapper.setProps({initThreadID: 'hang-by-a-thread'}); + opWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + + assert.include(opWrapper.find(ReviewsView).prop('threadIDsOpen'), 'hang-by-a-thread'); + assert.isTrue(opWrapper.find(ReviewsView).prop('commentSectionOpen')); + assert.strictEqual(opWrapper.find(ReviewsView).prop('scrollToThreadID'), 'hang-by-a-thread'); + + clock.tick(2000); + opWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + assert.isNull(opWrapper.find(ReviewsView).prop('scrollToThreadID')); + }); + + describe('openIssueish', function() { + it('opens an IssueishDetailItem for a different issueish', async function() { + const wrapper = shallow(buildApp({ + endpoint: getEndpoint('github.enterprise.horse'), + })); + const opWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + await opWrapper.find(ReviewsView).prop('openIssueish')('owner', 'repo', 10); + + assert.include( + atomEnv.workspace.getPaneItems().map(item => item.getURI()), + IssueishDetailItem.buildURI({host: 'github.enterprise.horse', owner: 'owner', repo: 'repo', number: 10}), + ); + }); + + it('locates a resident Repository in the context pool if exactly one is available', async function() { + const workdirContextPool = new WorkdirContextPool(); + + const otherDir = await cloneRepository(); + const otherRepo = workdirContextPool.add(otherDir).getRepository(); + await otherRepo.getLoadPromise(); + await otherRepo.addRemote('up', 'git@github.com:owner/repo.git'); + + const wrapper = shallow(buildApp({ + endpoint: getEndpoint('github.com'), + workdirContextPool, + })); + const opWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + await opWrapper.find(ReviewsView).prop('openIssueish')('owner', 'repo', 10); + + assert.include( + atomEnv.workspace.getPaneItems().map(item => item.getURI()), + IssueishDetailItem.buildURI({host: 'github.com', owner: 'owner', repo: 'repo', number: 10, workdir: otherDir}), + ); + }); + + it('prefers the current Repository if it matches', async function() { + const workdirContextPool = new WorkdirContextPool(); + + const currentDir = await cloneRepository(); + const currentRepo = workdirContextPool.add(currentDir).getRepository(); + await currentRepo.getLoadPromise(); + await currentRepo.addRemote('up', 'git@github.com:owner/repo.git'); + + const otherDir = await cloneRepository(); + const otherRepo = workdirContextPool.add(otherDir).getRepository(); + await otherRepo.getLoadPromise(); + await otherRepo.addRemote('up', 'git@github.com:owner/repo.git'); + + const wrapper = shallow(buildApp({ + endpoint: getEndpoint('github.com'), + workdirContextPool, + localRepository: currentRepo, + })); + + const opWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + await opWrapper.find(ReviewsView).prop('openIssueish')('owner', 'repo', 10); + + assert.include( + atomEnv.workspace.getPaneItems().map(item => item.getURI()), + IssueishDetailItem.buildURI({ + host: 'github.com', owner: 'owner', repo: 'repo', number: 10, workdir: currentDir, + }), + ); + }); + }); + + describe('context lines', function() { + it('defaults to 4 lines of context', function() { + const wrapper = shallow(buildApp()); + const opWrapper = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + + assert.strictEqual(opWrapper.find(ReviewsView).prop('contextLines'), 4); + }); + + it('increases context lines with moreContext', function() { + const wrapper = shallow(buildApp()); + const opWrapper0 = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + + opWrapper0.find(ReviewsView).prop('moreContext')(); + + const opWrapper1 = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + assert.strictEqual(opWrapper1.find(ReviewsView).prop('contextLines'), 5); + }); + + it('decreases context lines with lessContext', function() { + const wrapper = shallow(buildApp()); + const opWrapper0 = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + + opWrapper0.find(ReviewsView).prop('lessContext')(); + + const opWrapper1 = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + assert.strictEqual(opWrapper1.find(ReviewsView).prop('contextLines'), 3); + }); + + it('ensures that at least one context line is present', function() { + const wrapper = shallow(buildApp()); + const opWrapper0 = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + + for (let i = 0; i < 3; i++) { + opWrapper0.find(ReviewsView).prop('lessContext')(); + } + + const opWrapper1 = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + assert.strictEqual(opWrapper1.find(ReviewsView).prop('contextLines'), 1); + + opWrapper1.find(ReviewsView).prop('lessContext')(); + + const opWrapper2 = wrapper.find(PullRequestCheckoutController).renderProp('children')(noop); + assert.strictEqual(opWrapper2.find(ReviewsView).prop('contextLines'), 1); + }); + }); + + describe('adding a single comment', function() { + it('creates a review, attaches the comment, and submits it', async function() { + expectRelayQuery({ + name: addPrReviewMutation.operation.name, + variables: { + input: {pullRequestId: 'pr0'}, + }, + }, op => { + return relayResponseBuilder(op) + .addPullRequestReview(m => { + m.reviewEdge(e => e.node(r => r.id('review0'))); + }) + .build(); + }).resolve(); + + expectRelayQuery({ + name: addPrReviewCommentMutation.operation.name, + variables: { + input: {body: 'body', inReplyTo: 'comment1', pullRequestReviewId: 'review0'}, + }, + }, op => { + return relayResponseBuilder(op) + .addPullRequestReviewComment(m => { + m.commentEdge(e => e.node(c => c.id('comment2'))); + }) + .build(); + }).resolve(); + + expectRelayQuery({ + name: submitPrReviewMutation.operation.name, + variables: { + input: {pullRequestReviewId: 'review0', event: 'COMMENT'}, + }, + }, op => { + return relayResponseBuilder(op) + .submitPullRequestReview(m => { + m.pullRequestReview(r => r.id('review0')); + }) + .build(); + }).resolve(); + + const pullRequest = pullRequestBuilder(pullRequestQuery).id('pr0').build(); + const wrapper = shallow(buildApp({pullRequest})) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + + const didSubmitComment = sinon.spy(); + const didFailComment = sinon.spy(); + + await wrapper.find(ReviewsView).prop('addSingleComment')( + 'body', 'thread0', 'comment1', 'file0.txt', 10, {didSubmitComment, didFailComment}, + ); + + assert.isTrue(didSubmitComment.called); + assert.isFalse(didFailComment.called); + }); + + it('creates a notification when the review cannot be created', async function() { + const reportMutationErrors = sinon.spy(); + + expectRelayQuery({ + name: addPrReviewMutation.operation.name, + variables: { + input: {pullRequestId: 'pr0'}, + }, + }, op => { + return relayResponseBuilder(op) + .addError('Oh no') + .build(); + }).resolve(); + + const pullRequest = pullRequestBuilder(pullRequestQuery).id('pr0').build(); + const wrapper = shallow(buildApp({pullRequest, reportMutationErrors})) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + + const didSubmitComment = sinon.spy(); + const didFailComment = sinon.spy(); + + await wrapper.find(ReviewsView).prop('addSingleComment')( + 'body', 'thread0', 'comment1', 'file1.txt', 20, {didSubmitComment, didFailComment}, + ); + + assert.isTrue(reportMutationErrors.calledWith('Unable to submit your comment')); + assert.isFalse(didSubmitComment.called); + assert.isTrue(didFailComment.called); + }); + + it('creates a notification and deletes the review when the comment cannot be added', async function() { + const reportMutationErrors = sinon.spy(); + + expectRelayQuery({ + name: addPrReviewMutation.operation.name, + variables: { + input: {pullRequestId: 'pr0'}, + }, + }, op => { + return relayResponseBuilder(op) + .addPullRequestReview(m => { + m.reviewEdge(e => e.node(r => r.id('review0'))); + }) + .build(); + }).resolve(); + + expectRelayQuery({ + name: addPrReviewCommentMutation.operation.name, + variables: { + input: {body: 'body', inReplyTo: 'comment1', pullRequestReviewId: 'review0'}, + }, + }, op => { + return relayResponseBuilder(op) + .addError('Kerpow') + .addError('Wat') + .build(); + }).resolve(); + + expectRelayQuery({ + name: deletePrReviewMutation.operation.name, + variables: { + input: {pullRequestReviewId: 'review0'}, + }, + }, op => { + return relayResponseBuilder(op).build(); + }).resolve(); + + const pullRequest = pullRequestBuilder(pullRequestQuery).id('pr0').build(); + const wrapper = shallow(buildApp({pullRequest, reportMutationErrors})) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + + const didSubmitComment = sinon.spy(); + const didFailComment = sinon.spy(); + + await wrapper.find(ReviewsView).prop('addSingleComment')( + 'body', 'thread0', 'comment1', 'file2.txt', 5, {didSubmitComment, didFailComment}, + ); + + assert.isTrue(reportMutationErrors.calledWith('Unable to submit your comment')); + assert.isTrue(didSubmitComment.called); + assert.isTrue(didFailComment.called); + }); + + it('includes errors from the review deletion', async function() { + const reportMutationErrors = sinon.spy(); + + expectRelayQuery({ + name: addPrReviewMutation.operation.name, + variables: { + input: {pullRequestId: 'pr0'}, + }, + }, op => { + return relayResponseBuilder(op) + .addPullRequestReview(m => { + m.reviewEdge(e => e.node(r => r.id('review0'))); + }) + .build(); + }).resolve(); + + expectRelayQuery({ + name: addPrReviewCommentMutation.operation.name, + variables: { + input: {body: 'body', inReplyTo: 'comment1', pullRequestReviewId: 'review0'}, + }, + }, op => { + return relayResponseBuilder(op) + .addError('Kerpow') + .addError('Wat') + .build(); + }).resolve(); + + expectRelayQuery({ + name: deletePrReviewMutation.operation.name, + variables: { + input: {pullRequestReviewId: 'review0'}, + }, + }, op => { + return relayResponseBuilder(op) + .addError('Bam') + .build(); + }).resolve(); + + const pullRequest = pullRequestBuilder(pullRequestQuery).id('pr0').build(); + const wrapper = shallow(buildApp({pullRequest, reportMutationErrors})) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + + await wrapper.find(ReviewsView).prop('addSingleComment')( + 'body', 'thread0', 'comment1', 'file1.txt', 10, + ); + }); + + it('creates a notification when the review cannot be submitted', async function() { + const reportMutationErrors = sinon.spy(); + + expectRelayQuery({ + name: addPrReviewMutation.operation.name, + variables: { + input: {pullRequestId: 'pr0'}, + }, + }, op => { + return relayResponseBuilder(op) + .addPullRequestReview(m => { + m.reviewEdge(e => e.node(r => r.id('review0'))); + }) + .build(); + }).resolve(); + + expectRelayQuery({ + name: addPrReviewCommentMutation.operation.name, + variables: { + input: {body: 'body', inReplyTo: 'comment1', pullRequestReviewId: 'review0'}, + }, + }, op => { + return relayResponseBuilder(op) + .addPullRequestReviewComment(m => { + m.commentEdge(e => e.node(c => c.id('comment2'))); + }) + .build(); + }).resolve(); + + expectRelayQuery({ + name: submitPrReviewMutation.operation.name, + variables: { + input: {pullRequestReviewId: 'review0', event: 'COMMENT'}, + }, + }, op => { + return relayResponseBuilder(op) + .addError('Ouch') + .build(); + }).resolve(); + + const pullRequest = pullRequestBuilder(pullRequestQuery).id('pr0').build(); + const wrapper = shallow(buildApp({pullRequest, reportMutationErrors})) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + + const didSubmitComment = sinon.spy(); + const didFailComment = sinon.spy(); + + await wrapper.find(ReviewsView).prop('addSingleComment')( + 'body', 'thread0', 'comment1', 'file1.txt', 10, {didSubmitComment, didFailComment}, + ); + + assert.isTrue(reportMutationErrors.calledWith('Unable to submit your comment')); + assert.isTrue(didSubmitComment.called); + assert.isTrue(didFailComment.called); + }); + }); + + describe('resolving threads', function() { + it('hides the thread, then fires the mutation', async function() { + const reportMutationErrors = sinon.spy(); + + expectRelayQuery({ + name: resolveThreadMutation.operation.name, + variables: { + input: {threadId: 'thread0'}, + }, + }, op => relayResponseBuilder(op).build()).resolve(); + + const wrapper = shallow(buildApp({reportMutationErrors})) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + await wrapper.find(ReviewsView).prop('showThreadID')('thread0'); + + assert.isTrue(wrapper.find(ReviewsView).prop('threadIDsOpen').has('thread0')); + + await wrapper.find(ReviewsView).prop('resolveThread')({id: 'thread0', viewerCanResolve: true}); + + assert.isFalse(wrapper.find(ReviewsView).prop('threadIDsOpen').has('thread0')); + assert.isFalse(reportMutationErrors.called); + }); + + it('is a no-op if the viewer cannot resolve the thread', async function() { + const reportMutationErrors = sinon.spy(); + + const wrapper = shallow(buildApp({reportMutationErrors})) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + await wrapper.find(ReviewsView).prop('showThreadID')('thread0'); + + await wrapper.find(ReviewsView).prop('resolveThread')({id: 'thread0', viewerCanResolve: false}); + + assert.isTrue(wrapper.find(ReviewsView).prop('threadIDsOpen').has('thread0')); + assert.isFalse(reportMutationErrors.called); + }); + + it('re-shows the thread and creates a notification when the thread cannot be resolved', async function() { + const reportMutationErrors = sinon.spy(); + + expectRelayQuery({ + name: resolveThreadMutation.operation.name, + variables: { + input: {threadId: 'thread0'}, + }, + }, op => relayResponseBuilder(op).addError('boom').build()).resolve(); + + const wrapper = shallow(buildApp({reportMutationErrors})) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + await wrapper.find(ReviewsView).prop('showThreadID')('thread0'); + + await wrapper.find(ReviewsView).prop('resolveThread')({id: 'thread0', viewerCanResolve: true}); + + assert.isTrue(wrapper.find(ReviewsView).prop('threadIDsOpen').has('thread0')); + assert.isTrue(reportMutationErrors.calledWith('Unable to resolve the comment thread')); + }); + }); + + describe('unresolving threads', function() { + it('calls the unresolve mutation', async function() { + sinon.stub(atomEnv.notifications, 'addError').returns(); + + expectRelayQuery({ + name: unresolveThreadMutation.operation.name, + variables: { + input: {threadId: 'thread1'}, + }, + }, op => relayResponseBuilder(op).build()).resolve(); + + const wrapper = shallow(buildApp()) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + + await wrapper.find(ReviewsView).prop('unresolveThread')({id: 'thread1', viewerCanUnresolve: true}); + + assert.isFalse(atomEnv.notifications.addError.called); + }); + + it("is a no-op if the viewer can't unresolve the thread", async function() { + const reportMutationErrors = sinon.spy(); + + const wrapper = shallow(buildApp({reportMutationErrors})) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + + await wrapper.find(ReviewsView).prop('unresolveThread')({id: 'thread1', viewerCanUnresolve: false}); + + assert.isFalse(reportMutationErrors.called); + }); + + it('creates a notification if the thread cannot be unresolved', async function() { + const reportMutationErrors = sinon.spy(); + + expectRelayQuery({ + name: unresolveThreadMutation.operation.name, + variables: { + input: {threadId: 'thread1'}, + }, + }, op => relayResponseBuilder(op).addError('ow').build()).resolve(); + + const wrapper = shallow(buildApp({reportMutationErrors})) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + + await wrapper.find(ReviewsView).prop('unresolveThread')({id: 'thread1', viewerCanUnresolve: true}); + + assert.isTrue(reportMutationErrors.calledWith('Unable to unresolve the comment thread')); + }); + }); + + describe('action methods', function() { + let wrapper, openFilesTab, onTabSelected; + + beforeEach(function() { + openFilesTab = sinon.spy(); + onTabSelected = sinon.spy(); + sinon.stub(atomEnv.workspace, 'open').resolves({openFilesTab, onTabSelected}); + sinon.stub(reporterProxy, 'addEvent'); + wrapper = shallow(buildApp()) + .find(PullRequestCheckoutController) + .renderProp('children')(noop); + }); + + it('opens file on disk', async function() { + await wrapper.find(ReviewsView).prop('openFile')('filepath', 420); + assert.isTrue(atomEnv.workspace.open.calledWith( + 'filepath', { + initialLine: 420 - 1, + initialColumn: 0, + pending: true, + }, + )); + assert.isTrue(reporterProxy.addEvent.calledWith('reviews-dock-open-file', {package: 'github'})); + }); + + it('opens diff in PR detail item', async function() { + await wrapper.find(ReviewsView).prop('openDiff')('filepath', 420); + assert.isTrue(atomEnv.workspace.open.calledWith( + IssueishDetailItem.buildURI({ + host: 'github.com', + owner: 'atom', + repo: 'github', + number: 1995, + workdir: localRepository.getWorkingDirectoryPath(), + }), { + pending: true, + searchAllPanes: true, + }, + )); + assert.isTrue(openFilesTab.calledWith({changedFilePath: 'filepath', changedFilePosition: 420})); + assert.isTrue(reporterProxy.addEvent.calledWith('reviews-dock-open-diff', { + package: 'github', component: 'BareReviewsController', + })); + }); + + it('opens overview of a PR detail item', async function() { + await wrapper.find(ReviewsView).prop('openPR')(); + assert.isTrue(atomEnv.workspace.open.calledWith( + IssueishDetailItem.buildURI({ + host: 'github.com', + owner: 'atom', + repo: 'github', + number: 1995, + workdir: localRepository.getWorkingDirectoryPath(), + }), { + pending: true, + searchAllPanes: true, + }, + )); + assert.isTrue(reporterProxy.addEvent.calledWith('reviews-dock-open-pr', { + package: 'github', component: 'BareReviewsController', + })); + }); + + it('manages the open/close state of the summary section', async function() { + assert.isTrue(await wrapper.find(ReviewsView).prop('summarySectionOpen'), true); + + await wrapper.find(ReviewsView).prop('hideSummaries')(); + assert.isTrue(await wrapper.find(ReviewsView).prop('summarySectionOpen'), false); + + await wrapper.find(ReviewsView).prop('showSummaries')(); + assert.isTrue(await wrapper.find(ReviewsView).prop('summarySectionOpen'), true); + }); + + it('manages the open/close state of the comment section', async function() { + assert.isTrue(await wrapper.find(ReviewsView).prop('commentSectionOpen'), true); + + await wrapper.find(ReviewsView).prop('hideComments')(); + assert.isTrue(await wrapper.find(ReviewsView).prop('commentSectionOpen'), false); + + await wrapper.find(ReviewsView).prop('showComments')(); + assert.isTrue(await wrapper.find(ReviewsView).prop('commentSectionOpen'), true); + }); + }); +}); diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index ad00178c20..36a2912766 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -428,7 +428,12 @@ describe('RootController', function() { resolveOpenIssueish(); await promise; - const uri = IssueishDetailItem.buildURI('github.com', repoOwner, repoName, issueishNumber); + const uri = IssueishDetailItem.buildURI({ + host: 'github.com', + owner: repoOwner, + repo: repoName, + number: issueishNumber, + }); assert.isTrue(openIssueishDetails.calledWith(uri)); @@ -1227,7 +1232,13 @@ describe('RootController', function() { describe('opening an IssueishDetailItem', function() { it('registers an opener for IssueishPaneItems', async function() { - const uri = IssueishDetailItem.buildURI('https://api.github.com', 'owner', 'repo', 123, __dirname); + const uri = IssueishDetailItem.buildURI({ + host: 'github.com', + owner: 'owner', + repo: 'repo', + number: 123, + workdir: __dirname, + }); const wrapper = mount(app); const item = await atomEnv.workspace.open(uri); diff --git a/test/fixtures/diffs/raw-diff.js b/test/fixtures/diffs/raw-diff.js index 473df75cb5..65ba7e578f 100644 --- a/test/fixtures/diffs/raw-diff.js +++ b/test/fixtures/diffs/raw-diff.js @@ -11,6 +11,7 @@ const rawDiff = dedent` +new line line3 `; + const rawDiffWithPathPrefix = dedent` diff --git a/bad/path.txt b/bad/path.txt index af607bb..cfac420 100644 @@ -22,4 +23,30 @@ const rawDiffWithPathPrefix = dedent` +line1.5 +line2 `; -export {rawDiff, rawDiffWithPathPrefix}; + +const rawDeletionDiff = dedent` + diff --git a/deleted b/deleted + deleted file mode 100644 + index 0065a01..0000000 + --- a/deleted + +++ /dev/null + @@ -1,4 +0,0 @@ + -this + -file + -was + -deleted +` + +const rawAdditionDiff = dedent` + diff --git a/added b/added + new file mode 100644 + index 0000000..4cb29ea + --- /dev/null + +++ b/added + @@ -0,0 +1,3 @@ + +one + +two + +three +` + +export {rawDiff, rawDiffWithPathPrefix, rawDeletionDiff, rawAdditionDiff}; diff --git a/test/fixtures/props/issueish-pane-props.js b/test/fixtures/props/issueish-pane-props.js index cabc7e4140..fa9124b24c 100644 --- a/test/fixtures/props/issueish-pane-props.js +++ b/test/fixtures/props/issueish-pane-props.js @@ -3,6 +3,7 @@ import WorkdirContextPool from '../../../lib/models/workdir-context-pool'; import BranchSet from '../../../lib/models/branch-set'; import RemoteSet from '../../../lib/models/remote-set'; import {getEndpoint} from '../../../lib/models/endpoint'; +import RefHolder from '../../../lib/models/ref-holder'; import {InMemoryStrategy} from '../../../lib/shared/keytar-strategy'; import EnableableOperation from '../../../lib/models/enableable-operation'; import IssueishDetailItem from '../../../lib/items/issueish-detail-item'; @@ -171,6 +172,7 @@ export function pullRequestDetailViewProps(opts, overrides = {}) { switchToIssueish: () => {}, destroy: () => {}, openCommit: () => {}, + openReviews: () => {}, // atom env props workspace: {}, @@ -188,6 +190,10 @@ export function pullRequestDetailViewProps(opts, overrides = {}) { }, itemType: IssueishDetailItem, + selectedTab: 0, + onTabSelected: () => {}, + onOpenFilesTab: () => {}, + refEditor: new RefHolder(), ...overrides, }; @@ -262,6 +268,7 @@ export function issueDetailViewProps(opts, overrides = {}) { }, switchToIssueish: () => {}, + reportMutationErrors: () => {}, ...overrides, }; diff --git a/test/generation.snapshot.js b/test/generation.snapshot.js index bc6934852a..68d69ab1ea 100644 --- a/test/generation.snapshot.js +++ b/test/generation.snapshot.js @@ -41,6 +41,7 @@ describe('snapshot generation', function() { if (requiredModuleRelativePath.endsWith(path.join('node_modules/temp/lib/temp.js'))) { return true; } if (requiredModuleRelativePath.endsWith(path.join('node_modules/graceful-fs/graceful-fs.js'))) { return true; } if (requiredModuleRelativePath.endsWith(path.join('node_modules/fs-extra/lib/index.js'))) { return true; } + if (requiredModuleRelativePath.endsWith(path.join('node_modules/superstring/index.js'))) { return true; } return false; }, diff --git a/test/helpers.js b/test/helpers.js index aeb6e88a62..5409a3f39c 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -198,6 +198,12 @@ export function assertInFilePatch(filePatch, buffer) { return assertInPatch(filePatch.getPatch(), buffer); } +export function assertMarkerRanges(markerLayer, ...expectedRanges) { + const bufferLayer = markerLayer.bufferMarkerLayer || markerLayer; + const actualRanges = bufferLayer.getMarkers().map(m => m.getRange().serialize()); + assert.deepEqual(actualRanges, expectedRanges); +} + let activeRenderers = []; export function createRenderer() { let instance; @@ -444,3 +450,32 @@ export function expectEvents(repository, ...suffixes) { } }); } + +// Atom environment utilities + +// Ensure the Workspace doesn't mangle atom-github://... URIs. +// If you don't have an opener registered for a non-standard URI protocol, the Workspace coerces it into a file URI +// and tries to open it with a TextEditor. In the process, the URI gets mangled: +// +// atom.workspace.open('atom-github://unknown/whatever').then(item => console.log(item.getURI())) +// > 'atom-github:/unknown/whatever' +// +// Adding an opener that creates fake items prevents it from doing this and keeps the URIs unchanged. +export function registerGitHubOpener(atomEnv) { + atomEnv.workspace.addOpener(uri => { + if (uri.startsWith('atom-github://')) { + return { + getURI() { return uri; }, + + getElement() { + if (!this.element) { + this.element = document.createElement('div'); + } + return this.element; + }, + }; + } else { + return undefined; + } + }); +} diff --git a/test/integration/checkout-pr.test.js b/test/integration/checkout-pr.test.js index 6509d22b8a..129c72df01 100644 --- a/test/integration/checkout-pr.test.js +++ b/test/integration/checkout-pr.test.js @@ -5,57 +5,11 @@ import {setup, teardown} from './helpers'; import {PAGE_SIZE} from '../../lib/helpers'; import {expectRelayQuery} from '../../lib/relay-network-layer-manager'; import GitShellOutStrategy from '../../lib/git-shell-out-strategy'; -import {createRepositoryResult} from '../fixtures/factories/repository-result'; -import IDGenerator from '../fixtures/factories/id-generator'; -import {createPullRequestsResult, createPullRequestDetailResult} from '../fixtures/factories/pull-request-result'; +import {relayResponseBuilder} from '../builder/graphql/query'; describe('integration: check out a pull request', function() { - let context, wrapper, atomEnv, workspaceElement, git, idGen, repositoryID; - - beforeEach(async function() { - context = await setup({ - initialRoots: ['three-files'], - }); - wrapper = context.wrapper; - atomEnv = context.atomEnv; - workspaceElement = context.workspaceElement; - idGen = new IDGenerator(); - repositoryID = idGen.generate('repository'); - - await context.loginModel.setToken('https://api.github.com', 'good-token'); - - const root = atomEnv.project.getPaths()[0]; - git = new GitShellOutStrategy(root); - await git.exec(['remote', 'add', 'dotcom', 'https://github.com/owner/repo.git']); - - const mockGitServer = hock.createHock(); - - const uploadPackAdvertisement = '001e# service=git-upload-pack\n' + - '0000' + - '005b66d11860af6d28eb38349ef83de475597cb0e8b4 HEAD\0multi_ack symref=HEAD:refs/heads/pr-head\n' + - '004066d11860af6d28eb38349ef83de475597cb0e8b4 refs/heads/pr-head\n' + - '0000'; - - mockGitServer - .get('/owner/repo.git/info/refs?service=git-upload-pack') - .reply(200, uploadPackAdvertisement, {'Content-Type': 'application/x-git-upload-pack-advertisement'}) - .get('/owner/repo.git/info/refs?service=git-upload-pack') - .reply(400); - - const server = http.createServer(mockGitServer.handler); - return new Promise(resolve => { - server.listen(0, '127.0.0.1', async () => { - const {address, port} = server.address(); - await git.setConfig(`url.http://${address}:${port}/.insteadOf`, 'https://github.com/'); - - resolve(); - }); - }); - }); - - afterEach(async function() { - await teardown(context); - }); + let context, wrapper, atomEnv, workspaceElement, git; + let repositoryID, headRefID; function expectRepositoryQuery() { return expectRelayQuery({ @@ -64,8 +18,10 @@ describe('integration: check out a pull request', function() { owner: 'owner', name: 'repo', }, - }, { - repository: createRepositoryResult({id: repositoryID}), + }, op => { + return relayResponseBuilder(op) + .repository(r => r.id(repositoryID)) + .build(); }); } @@ -78,17 +34,13 @@ describe('integration: check out a pull request', function() { headRef: 'refs/heads/pr-head', first: 5, }, - }, { - repository: { - id: repositoryID, - ref: { - id: idGen.generate('ref'), - associatedPullRequests: { - totalCount: 0, - nodes: [], - }, - }, - }, + }, op => { + return relayResponseBuilder(op) + .repository(r => { + r.id(repositoryID); + r.ref(ref => ref.id(headRefID)); + }) + .build(); }); } @@ -99,38 +51,19 @@ describe('integration: check out a pull request', function() { query: 'repo:owner/repo type:pr state:open', first: 20, }, - }, { - search: { - issueCount: 10, - nodes: createPullRequestsResult( - {number: 0}, - {number: 1}, - {number: 2}, - ), - }, + }, op => { + return relayResponseBuilder(op) + .search(s => { + s.issueCount(10); + for (const n of [0, 1, 2]) { + s.addNode(r => r.bePullRequest(pr => pr.number(n))); + } + }) + .build(); }); } function expectIssueishDetailQuery() { - const result = { - repository: { - id: repositoryID, - name: 'repo', - owner: { - __typename: 'User', - id: 'user0', - login: 'owner', - }, - pullRequest: createPullRequestDetailResult({ - number: 1, - title: 'Pull Request 1', - headRefName: 'pr-head', - headRepositoryName: 'repo', - headRepositoryLogin: 'owner', - }), - }, - }; - return expectRelayQuery({ name: 'issueishDetailContainerQuery', variables: { @@ -143,10 +76,37 @@ describe('integration: check out a pull request', function() { commitCursor: null, reviewCount: PAGE_SIZE, reviewCursor: null, + threadCount: PAGE_SIZE, + threadCursor: null, commentCount: PAGE_SIZE, commentCursor: null, }, - }, result); + }, op => { + return relayResponseBuilder(op) + .repository(r => { + r.id(repositoryID); + r.name('repo'); + r.owner(o => o.login('owner')); + r.issueish(b => { + b.bePullRequest(pr => { + pr.id('pr1'); + }); + }); + r.nullIssue(); + r.pullRequest(pr => { + pr.id('pr1'); + pr.number(1); + pr.title('Pull Request 1'); + pr.headRefName('pr-head'); + pr.headRepository(hr => { + hr.name('repo'); + hr.owner(o => o.login('owner')); + }); + pr.recentCommits(conn => conn.addEdge()); + }); + }) + .build(); + }); } function expectMentionableUsersQuery() { @@ -168,30 +128,85 @@ describe('integration: check out a pull request', function() { }); } - // achtung! this test is flaky - it('opens a pane item for a pull request by clicking on an entry in the GitHub tab', async function() { - this.retries(5); // FLAKE + function expectCommentDecorationsQuery() { + return expectRelayQuery({ + name: 'commentDecorationsContainerQuery', + variables: { + headOwner: 'owner', + headName: 'repo', + headRef: 'refs/heads/pr-head', + reviewCount: 50, + reviewCursor: null, + threadCount: 50, + threadCursor: null, + commentCount: 50, + commentCursor: null, + first: 1, + }, + }, op => { + return relayResponseBuilder(op) + .repository(r => { + r.id(repositoryID); + r.ref(ref => ref.id(headRefID)); + }) + .build(); + }); + } + + beforeEach(async function() { + repositoryID = 'repository0'; + headRefID = 'headref0'; + + expectRepositoryQuery().resolve(); + expectIssueishSearchQuery().resolve(); + expectIssueishDetailQuery().resolve(); + expectMentionableUsersQuery().resolve(); + expectCurrentPullRequestQuery().resolve(); + expectCommentDecorationsQuery().resolve(); + + context = await setup({ + initialRoots: ['three-files'], + }); + wrapper = context.wrapper; + atomEnv = context.atomEnv; + workspaceElement = context.workspaceElement; + + await context.loginModel.setToken('https://api.github.com', 'good-token'); + + const root = atomEnv.project.getPaths()[0]; + git = new GitShellOutStrategy(root); + await git.exec(['remote', 'add', 'dotcom', 'https://github.com/owner/repo.git']); - const {resolve: resolve0, promise: promise0} = expectRepositoryQuery(); - resolve0(); - await promise0; + const mockGitServer = hock.createHock(); - const {resolve: resolve1, promise: promise1} = expectIssueishSearchQuery(); - resolve1(); - await promise1; + const uploadPackAdvertisement = '001e# service=git-upload-pack\n' + + '0000' + + '005b66d11860af6d28eb38349ef83de475597cb0e8b4 HEAD\0multi_ack symref=HEAD:refs/heads/pr-head\n' + + '004066d11860af6d28eb38349ef83de475597cb0e8b4 refs/heads/pr-head\n' + + '0000'; - const {resolve: resolve2, promise: promise2} = expectIssueishDetailQuery(); - resolve2(); - await promise2; + mockGitServer + .get('/owner/repo.git/info/refs?service=git-upload-pack') + .reply(200, uploadPackAdvertisement, {'Content-Type': 'application/x-git-upload-pack-advertisement'}) + .get('/owner/repo.git/info/refs?service=git-upload-pack') + .reply(400); - const {resolve: resolve3, promise: promise3} = expectMentionableUsersQuery(); - resolve3(); - await promise3; + const server = http.createServer(mockGitServer.handler); + return new Promise(resolve => { + server.listen(0, '127.0.0.1', async () => { + const {address, port} = server.address(); + await git.setConfig(`url.http://${address}:${port}/.insteadOf`, 'https://github.com/'); - const {resolve: resolve4, promise: promise4} = expectCurrentPullRequestQuery(); - resolve4(); - await promise4; + resolve(); + }); + }); + }); + + afterEach(async function() { + await teardown(context); + }); + it('opens a pane item for a pull request by clicking on an entry in the GitHub tab', async function() { // Open the GitHub tab and wait for results to be rendered await atomEnv.commands.dispatch(workspaceElement, 'github:toggle-github-tab'); await assert.async.isTrue(wrapper.update().find('.github-IssueishList-item').exists()); diff --git a/test/items/issueish-detail-item.test.js b/test/items/issueish-detail-item.test.js index e9d36e26c1..675068e028 100644 --- a/test/items/issueish-detail-item.test.js +++ b/test/items/issueish-detail-item.test.js @@ -6,14 +6,17 @@ import {cloneRepository, deferSetState} from '../helpers'; import IssueishDetailItem from '../../lib/items/issueish-detail-item'; import PaneItem from '../../lib/atom/pane-item'; import WorkdirContextPool from '../../lib/models/workdir-context-pool'; -import {issueishPaneItemProps} from '../fixtures/props/issueish-pane-props'; +import GithubLoginModel from '../../lib/models/github-login-model'; +import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; import * as reporterProxy from '../../lib/reporter-proxy'; describe('IssueishDetailItem', function() { - let atomEnv, subs; + let atomEnv, workdirContextPool, subs; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); + workdirContextPool = new WorkdirContextPool(); + subs = new CompositeDisposable(); }); @@ -22,9 +25,20 @@ describe('IssueishDetailItem', function() { atomEnv.destroy(); }); - function buildApp(overrideProps = {}) { - const props = issueishPaneItemProps(overrideProps); + function buildApp(override = {}) { + const props = { + workdirContextPool, + loginModel: new GithubLoginModel(InMemoryStrategy), + + workspace: atomEnv.workspace, + commands: atomEnv.commands, + keymaps: atomEnv.keymaps, + tooltips: atomEnv.tooltips, + config: atomEnv.config, + reportMutationErrors: () => {}, + ...override, + }; return ( {({itemHolder, params}) => ( @@ -32,6 +46,7 @@ describe('IssueishDetailItem', function() { ref={itemHolder.setter} {...params} issueishNumber={parseInt(params.issueishNumber, 10)} + selectedTab={props.selectedTab || parseInt(params.selectedTab, 10)} {...props} /> )} @@ -42,7 +57,9 @@ describe('IssueishDetailItem', function() { it('renders within the workspace center', async function() { const wrapper = mount(buildApp({})); - const uri = IssueishDetailItem.buildURI('one.com', 'me', 'code', 400, __dirname); + const uri = IssueishDetailItem.buildURI({ + host: 'one.com', owner: 'me', repo: 'code', number: 400, workdir: __dirname, + }); const item = await atomEnv.workspace.open(uri); assert.lengthOf(wrapper.update().find('IssueishDetailItem'), 1); @@ -55,11 +72,9 @@ describe('IssueishDetailItem', function() { }); describe('issueish switching', function() { - let workdirContextPool, atomGithubRepo, atomAtomRepo; + let atomGithubRepo, atomAtomRepo; beforeEach(async function() { - workdirContextPool = new WorkdirContextPool(); - const atomGithubWorkdir = await cloneRepository(); atomGithubRepo = workdirContextPool.add(atomGithubWorkdir).getRepository(); await atomGithubRepo.getLoadPromise(); @@ -73,7 +88,7 @@ describe('IssueishDetailItem', function() { it('automatically switches when opened with an empty workdir', async function() { const wrapper = mount(buildApp({workdirContextPool})); - const uri = IssueishDetailItem.buildURI('host.com', 'atom', 'atom', 500); + const uri = IssueishDetailItem.buildURI({host: 'host.com', owner: 'atom', repo: 'atom', number: 500}); await atomEnv.workspace.open(uri); const item = wrapper.update().find('IssueishDetailItem'); @@ -86,7 +101,13 @@ describe('IssueishDetailItem', function() { it('switches to a different issueish', async function() { const wrapper = mount(buildApp({workdirContextPool})); - await atomEnv.workspace.open(IssueishDetailItem.buildURI('host.com', 'me', 'original', 1, __dirname)); + await atomEnv.workspace.open(IssueishDetailItem.buildURI({ + host: 'host.com', + owner: 'me', + repo: 'original', + number: 1, + workdir: __dirname, + })); const before = wrapper.update().find('IssueishDetailContainer'); assert.strictEqual(before.prop('endpoint').getHost(), 'host.com'); @@ -105,7 +126,13 @@ describe('IssueishDetailItem', function() { it('changes the active repository when its issueish changes', async function() { const wrapper = mount(buildApp({workdirContextPool})); - await atomEnv.workspace.open(IssueishDetailItem.buildURI('host.com', 'me', 'original', 1, __dirname)); + await atomEnv.workspace.open(IssueishDetailItem.buildURI({ + host: 'host.com', + owner: 'me', + repo: 'original', + number: 1, + workdir: __dirname, + })); wrapper.update(); @@ -121,7 +148,13 @@ describe('IssueishDetailItem', function() { it('reverts to an absent repository when no matching repository is found', async function() { const workdir = atomAtomRepo.getWorkingDirectoryPath(); const wrapper = mount(buildApp({workdirContextPool})); - await atomEnv.workspace.open(IssueishDetailItem.buildURI('github.com', 'atom', 'atom', 5, workdir)); + await atomEnv.workspace.open(IssueishDetailItem.buildURI({ + host: 'github.com', + owner: 'atom', + repo: 'atom', + number: 5, + workdir, + })); wrapper.update(); assert.strictEqual(wrapper.find('IssueishDetailContainer').prop('repository'), atomAtomRepo); @@ -133,7 +166,9 @@ describe('IssueishDetailItem', function() { it('aborts a repository swap when pre-empted', async function() { const wrapper = mount(buildApp({workdirContextPool})); - const item = await atomEnv.workspace.open(IssueishDetailItem.buildURI('github.com', 'another', 'repo', 5, __dirname)); + const item = await atomEnv.workspace.open(IssueishDetailItem.buildURI({ + host: 'github.com', owner: 'another', repo: 'repo', number: 5, workdir: __dirname, + })); wrapper.update(); @@ -160,7 +195,9 @@ describe('IssueishDetailItem', function() { await repo.addRemote('upstream', 'https://github.com/atom/atom.git'); const wrapper = mount(buildApp({workdirContextPool})); - await atomEnv.workspace.open(IssueishDetailItem.buildURI('host.com', 'me', 'original', 1, __dirname)); + await atomEnv.workspace.open(IssueishDetailItem.buildURI({ + host: 'host.com', owner: 'me', repo: 'original', number: 1, workdir: __dirname, + })); wrapper.update(); await wrapper.find('IssueishDetailContainer').prop('switchToIssueish')('atom', 'atom', 100); @@ -172,9 +209,32 @@ describe('IssueishDetailItem', function() { assert.isTrue(wrapper.find('IssueishDetailContainer').prop('repository').isAbsent()); }); + it('preserves the current repository when switching if possible, even if others match', async function() { + const workdir = await cloneRepository(); + const repo = workdirContextPool.add(workdir).getRepository(); + await repo.getLoadPromise(); + await repo.addRemote('upstream', 'https://github.com/atom/atom.git'); + + const wrapper = mount(buildApp({workdirContextPool})); + await atomEnv.workspace.open(IssueishDetailItem.buildURI({ + host: 'github.com', owner: 'atom', repo: 'atom', number: 1, workdir, + })); + wrapper.update(); + + await wrapper.find('IssueishDetailContainer').prop('switchToIssueish')('atom', 'atom', 100); + wrapper.update(); + + assert.strictEqual(wrapper.find('IssueishDetailContainer').prop('owner'), 'atom'); + assert.strictEqual(wrapper.find('IssueishDetailContainer').prop('repo'), 'atom'); + assert.strictEqual(wrapper.find('IssueishDetailContainer').prop('issueishNumber'), 100); + assert.strictEqual(wrapper.find('IssueishDetailContainer').prop('repository'), repo); + }); + it('records an event after switching', async function() { const wrapper = mount(buildApp({workdirContextPool})); - await atomEnv.workspace.open(IssueishDetailItem.buildURI('host.com', 'me', 'original', 1, __dirname)); + await atomEnv.workspace.open(IssueishDetailItem.buildURI({ + host: 'host.com', owner: 'me', repo: 'original', number: 1, workdir: __dirname, + })); wrapper.update(); @@ -187,7 +247,9 @@ describe('IssueishDetailItem', function() { it('reconstitutes its original URI', async function() { const wrapper = mount(buildApp({})); - const uri = IssueishDetailItem.buildURI('host.com', 'me', 'original', 1, __dirname); + const uri = IssueishDetailItem.buildURI({ + host: 'host.com', owner: 'me', repo: 'original', number: 1337, workdir: __dirname, selectedTab: 1, + }); const item = await atomEnv.workspace.open(uri); assert.strictEqual(item.getURI(), uri); assert.strictEqual(item.serialize().uri, uri); @@ -200,7 +262,13 @@ describe('IssueishDetailItem', function() { it('broadcasts title changes', async function() { const wrapper = mount(buildApp({})); - const item = await atomEnv.workspace.open(IssueishDetailItem.buildURI('host.com', 'user', 'repo', 1, __dirname)); + const item = await atomEnv.workspace.open(IssueishDetailItem.buildURI({ + host: 'host.com', + owner: 'user', + repo: 'repo', + number: 1, + workdir: __dirname, + })); assert.strictEqual(item.getTitle(), 'user/repo#1'); const handler = sinon.stub(); @@ -216,7 +284,13 @@ describe('IssueishDetailItem', function() { it('tracks pending state termination', async function() { mount(buildApp({})); - const item = await atomEnv.workspace.open(IssueishDetailItem.buildURI('host.com', 'user', 'repo', 1, __dirname)); + const item = await atomEnv.workspace.open(IssueishDetailItem.buildURI({ + host: 'host.com', + owner: 'user', + repo: 'repo', + number: 1, + workdir: __dirname, + })); const handler = sinon.stub(); subs.add(item.onDidTerminatePendingState(handler)); @@ -233,7 +307,13 @@ describe('IssueishDetailItem', function() { beforeEach(function() { editor = Symbol('editor'); - uri = IssueishDetailItem.buildURI('one.com', 'me', 'code', 400, __dirname); + uri = IssueishDetailItem.buildURI({ + host: 'one.com', + owner: 'me', + repo: 'code', + number: 400, + workdir: __dirname, + }); }); afterEach(function() { @@ -262,4 +342,43 @@ describe('IssueishDetailItem', function() { assert.isTrue(cb.calledWith(editor)); }); }); + + describe('tab navigation', function() { + let wrapper, item, onTabSelected; + + beforeEach(async function() { + onTabSelected = sinon.spy(); + wrapper = mount(buildApp({onTabSelected})); + item = await atomEnv.workspace.open(IssueishDetailItem.buildURI({ + host: 'host.com', + owner: 'dolphin', + repo: 'fish', + number: 1337, + workdir: __dirname, + })); + wrapper.update(); + }); + + afterEach(function() { + item.destroy(); + wrapper.unmount(); + }); + + it('open files tab if it isn\'t already opened', async function() { + await item.openFilesTab({changedFilePath: 'dir/file', changedFilePosition: 100}); + + assert.strictEqual(wrapper.find('IssueishDetailItem').state('initChangedFilePath'), 'dir/file'); + assert.strictEqual(wrapper.find('IssueishDetailItem').state('initChangedFilePosition'), 100); + }); + + it('resets initChangedFilePath & initChangedFilePosition when navigating between tabs', async function() { + await item.openFilesTab({changedFilePath: 'anotherfile', changedFilePosition: 420}); + assert.strictEqual(wrapper.find('IssueishDetailItem').state('initChangedFilePath'), 'anotherfile'); + assert.strictEqual(wrapper.find('IssueishDetailItem').state('initChangedFilePosition'), 420); + + wrapper.find('IssueishDetailContainer').prop('onTabSelected')(IssueishDetailItem.tabs.BUILD_STATUS); + assert.strictEqual(wrapper.find('IssueishDetailItem').state('initChangedFilePath'), ''); + assert.strictEqual(wrapper.find('IssueishDetailItem').state('initChangedFilePosition'), 0); + }); + }); }); diff --git a/test/items/reviews-item.test.js b/test/items/reviews-item.test.js new file mode 100644 index 0000000000..d374c183e2 --- /dev/null +++ b/test/items/reviews-item.test.js @@ -0,0 +1,138 @@ +import React from 'react'; +import {mount} from 'enzyme'; + +import ReviewsItem from '../../lib/items/reviews-item'; +import {cloneRepository} from '../helpers'; +import PaneItem from '../../lib/atom/pane-item'; +import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; +import GithubLoginModel from '../../lib/models/github-login-model'; +import WorkdirContextPool from '../../lib/models/workdir-context-pool'; + +describe('ReviewsItem', function() { + let atomEnv, repository, pool; + + beforeEach(async function() { + atomEnv = global.buildAtomEnvironment(); + const workdir = await cloneRepository(); + + pool = new WorkdirContextPool({ + workspace: atomEnv.workspace, + }); + + repository = pool.add(workdir).getRepository(); + }); + + afterEach(function() { + atomEnv.destroy(); + pool.clear(); + }); + + function buildPaneApp(override = {}) { + const props = { + workdirContextPool: pool, + loginModel: new GithubLoginModel(InMemoryStrategy), + + workspace: atomEnv.workspace, + config: atomEnv.config, + commands: atomEnv.commands, + tooltips: atomEnv.tooltips, + reportMutationErrors: () => {}, + + ...override, + }; + + return ( + + {({itemHolder, params}) => ( + + )} + + ); + } + + async function open(wrapper, options = {}) { + const opts = { + host: 'github.com', + owner: 'atom', + repo: 'github', + number: 1848, + workdir: repository.getWorkingDirectoryPath(), + ...options, + }; + + const uri = ReviewsItem.buildURI(opts); + const item = await atomEnv.workspace.open(uri); + wrapper.update(); + return item; + } + + it('constructs and opens the correct URI', async function() { + const wrapper = mount(buildPaneApp()); + assert.isFalse(wrapper.exists('ReviewsItem')); + await open(wrapper); + assert.isTrue(wrapper.exists('ReviewsItem')); + }); + + it('locates the repository from the context pool', async function() { + const wrapper = mount(buildPaneApp()); + await open(wrapper); + + assert.strictEqual(wrapper.find('ReviewsContainer').prop('repository'), repository); + }); + + it('uses an absent repository if no workdir is provided', async function() { + const wrapper = mount(buildPaneApp()); + await open(wrapper, {workdir: null}); + + assert.isTrue(wrapper.find('ReviewsContainer').prop('repository').isAbsent()); + }); + + it('returns a title containing the pull request number', async function() { + const wrapper = mount(buildPaneApp()); + const item = await open(wrapper, {number: 1234}); + + assert.strictEqual(item.getTitle(), 'Reviews #1234'); + }); + + it('may be destroyed once', async function() { + const wrapper = mount(buildPaneApp()); + + const item = await open(wrapper); + const callback = sinon.spy(); + const sub = item.onDidDestroy(callback); + + assert.strictEqual(callback.callCount, 0); + item.destroy(); + assert.strictEqual(callback.callCount, 1); + + sub.dispose(); + }); + + it('serializes itself as a ReviewsItemStub', async function() { + const wrapper = mount(buildPaneApp()); + const item = await open(wrapper, {host: 'github.horse', owner: 'atom', repo: 'atom', number: 12, workdir: '/here'}); + assert.deepEqual(item.serialize(), { + deserializer: 'ReviewsStub', + uri: 'atom-github://reviews/github.horse/atom/atom/12?workdir=%2Fhere', + }); + }); + + it('jumps to thread', async function() { + const wrapper = mount(buildPaneApp()); + const item = await open(wrapper); + assert.isNull(item.state.initThreadID); + + await item.jumpToThread('an-id'); + assert.strictEqual(item.state.initThreadID, 'an-id'); + + // Jumping to the same ID toggles initThreadID to null and back, but we can't really test the intermediate + // state there so OH WELL + await item.jumpToThread('an-id'); + assert.strictEqual(item.state.initThreadID, 'an-id'); + }); +}); diff --git a/test/models/patch/multi-file-patch.test.js b/test/models/patch/multi-file-patch.test.js index a3515b7020..68ca8fa58a 100644 --- a/test/models/patch/multi-file-patch.test.js +++ b/test/models/patch/multi-file-patch.test.js @@ -6,7 +6,7 @@ import {TOO_LARGE, COLLAPSED, EXPANDED} from '../../../lib/models/patch/patch'; import MultiFilePatch from '../../../lib/models/patch/multi-file-patch'; import PatchBuffer from '../../../lib/models/patch/patch-buffer'; -import {assertInFilePatch} from '../../helpers'; +import {assertInFilePatch, assertMarkerRanges} from '../../helpers'; describe('MultiFilePatch', function() { it('creates an empty patch', function() { @@ -709,6 +709,103 @@ describe('MultiFilePatch', function() { }); }); + describe('getPreviewPatchBuffer', function() { + it('returns a PatchBuffer containing nearby rows of the MultiFilePatch', function() { + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file.txt')); + fp.addHunk(h => h.unchanged('0').added('1', '2').unchanged('3').deleted('4', '5').unchanged('6')); + fp.addHunk(h => h.unchanged('7').deleted('8').unchanged('9', '10')); + }) + .build(); + + const subPatch = multiFilePatch.getPreviewPatchBuffer('file.txt', 6, 4); + assert.strictEqual(subPatch.getBuffer().getText(), dedent` + 2 + 3 + 4 + 5 + `); + assertMarkerRanges(subPatch.getLayer('patch'), [[0, 0], [3, 1]]); + assertMarkerRanges(subPatch.getLayer('hunk'), [[0, 0], [3, 1]]); + assertMarkerRanges(subPatch.getLayer('unchanged'), [[1, 0], [1, 1]]); + assertMarkerRanges(subPatch.getLayer('addition'), [[0, 0], [0, 1]]); + assertMarkerRanges(subPatch.getLayer('deletion'), [[2, 0], [3, 1]]); + assertMarkerRanges(subPatch.getLayer('nonewline')); + }); + + it('truncates the returned buffer at hunk boundaries', function() { + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file.txt')); + fp.addHunk(h => h.unchanged('0').added('1', '2').unchanged('3')); + fp.addHunk(h => h.unchanged('7').deleted('8').unchanged('9', '10')); + }) + .build(); + + // diff row 8 = buffer row 9 + const subPatch = multiFilePatch.getPreviewPatchBuffer('file.txt', 8, 4); + + assert.strictEqual(subPatch.getBuffer().getText(), dedent` + 7 + 8 + 9 + `); + assertMarkerRanges(subPatch.getLayer('patch'), [[0, 0], [2, 1]]); + assertMarkerRanges(subPatch.getLayer('hunk'), [[0, 0], [2, 1]]); + assertMarkerRanges(subPatch.getLayer('unchanged'), [[0, 0], [0, 1]], [[2, 0], [2, 1]]); + assertMarkerRanges(subPatch.getLayer('addition')); + assertMarkerRanges(subPatch.getLayer('deletion'), [[1, 0], [1, 1]]); + assertMarkerRanges(subPatch.getLayer('nonewline')); + }); + + it('excludes zero-length markers from adjacent patches, hunks, and regions', function() { + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('mode-change-0.txt')); + fp.setNewFile(f => f.path('mode-change-0.txt').executable()); + fp.empty(); + }) + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file.txt')); + fp.addHunk(h => h.unchanged('0').added('1', '2').unchanged('3')); + }) + .addFilePatch(fp => { + fp.setOldFile(f => f.path('mode-change-1.txt').executable()); + fp.setNewFile(f => f.path('mode-change-1.txt')); + fp.empty(); + }) + .build(); + + // diff row 4 = buffer row 3 + const subPatch = multiFilePatch.getPreviewPatchBuffer('file.txt', 4, 4); + + assert.strictEqual(subPatch.getBuffer().getText(), dedent` + 0 + 1 + 2 + 3 + `); + assertMarkerRanges(subPatch.getLayer('patch'), [[0, 0], [3, 1]]); + assertMarkerRanges(subPatch.getLayer('hunk'), [[0, 0], [3, 1]]); + assertMarkerRanges(subPatch.getLayer('unchanged'), [[0, 0], [0, 1]], [[3, 0], [3, 1]]); + assertMarkerRanges(subPatch.getLayer('addition'), [[1, 0], [2, 1]]); + assertMarkerRanges(subPatch.getLayer('deletion')); + assertMarkerRanges(subPatch.getLayer('nonewline')); + }); + + it('logs and returns an empty buffer when called with invalid arguments', function() { + sinon.stub(console, 'error'); + + const {multiFilePatch} = multiFilePatchBuilder().build(); + const subPatch = multiFilePatch.getPreviewPatchBuffer('file.txt', 6, 4); + assert.strictEqual(subPatch.getBuffer().getText(), ''); + + // eslint-disable-next-line no-console + assert.isTrue(console.error.called); + }); + }); + describe('diff position translation', function() { it('offsets rows in the first hunk by the first hunk header', function() { const {multiFilePatch} = multiFilePatchBuilder() @@ -815,6 +912,34 @@ describe('MultiFilePatch', function() { multiFilePatch.expandFilePatch(fp); assert.isFalse(stub.called); }); + + it('returns null when called with an unrecognized filename', function() { + sinon.stub(console, 'error'); + + const {multiFilePatch} = multiFilePatchBuilder().build(); + assert.isNull(multiFilePatch.getBufferRowForDiffPosition('file.txt', 1)); + + // eslint-disable-next-line no-console + assert.isTrue(console.error.called); + }); + + it('returns null when called with an out of range diff row', function() { + sinon.stub(console, 'error'); + + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file.txt')); + fp.addHunk(h => { + h.unchanged('0').added('1').unchanged('2'); + }); + }) + .build(); + + assert.isNull(multiFilePatch.getBufferRowForDiffPosition('file.txt', 5)); + + // eslint-disable-next-line no-console + assert.isTrue(console.error.called); + }); }); describe('collapsing and expanding file patches', function() { diff --git a/test/models/patch/patch-buffer.test.js b/test/models/patch/patch-buffer.test.js index 44d9e8f48b..ebf8470822 100644 --- a/test/models/patch/patch-buffer.test.js +++ b/test/models/patch/patch-buffer.test.js @@ -1,6 +1,7 @@ import dedent from 'dedent-js'; import PatchBuffer from '../../../lib/models/patch/patch-buffer'; +import {assertMarkerRanges} from '../../helpers'; describe('PatchBuffer', function() { let patchBuffer; @@ -35,6 +36,76 @@ describe('PatchBuffer', function() { assert.lengthOf(patchBuffer.findMarkers('hunk', {}), 0); }); + describe('createSubBuffer', function() { + it('creates a new PatchBuffer containing a subset of the original', function() { + const m0 = patchBuffer.markRange('patch', [[1, 0], [3, 0]]); // before + const m1 = patchBuffer.markRange('hunk', [[2, 0], [4, 2]]); // before, ending at the extraction point + const m2 = patchBuffer.markRange('hunk', [[4, 2], [5, 0]]); // within + const m3 = patchBuffer.markRange('patch', [[6, 0], [7, 1]]); // within + const m4 = patchBuffer.markRange('hunk', [[7, 1], [9, 0]]); // after, starting at the extraction point + const m5 = patchBuffer.markRange('patch', [[8, 0], [10, 0]]); // after + + const {patchBuffer: subPatchBuffer, markerMap} = patchBuffer.createSubBuffer([[4, 2], [7, 1]]); + + // Original PatchBuffer should not be modified + assert.strictEqual(patchBuffer.getBuffer().getText(), TEXT); + assert.deepEqual( + patchBuffer.findMarkers('patch', {}).map(m => m.getRange().serialize()), + [[[1, 0], [3, 0]], [[6, 0], [7, 1]], [[8, 0], [10, 0]]], + ); + assert.deepEqual( + patchBuffer.findMarkers('hunk', {}).map(m => m.getRange().serialize()), + [[[2, 0], [4, 2]], [[4, 2], [5, 0]], [[7, 1], [9, 0]]], + ); + assert.deepEqual(m0.getRange().serialize(), [[1, 0], [3, 0]]); + assert.deepEqual(m1.getRange().serialize(), [[2, 0], [4, 2]]); + assert.deepEqual(m2.getRange().serialize(), [[4, 2], [5, 0]]); + assert.deepEqual(m3.getRange().serialize(), [[6, 0], [7, 1]]); + assert.deepEqual(m4.getRange().serialize(), [[7, 1], [9, 0]]); + assert.deepEqual(m5.getRange().serialize(), [[8, 0], [10, 0]]); + + assert.isFalse(markerMap.has(m0)); + assert.isFalse(markerMap.has(m1)); + assert.isFalse(markerMap.has(m4)); + assert.isFalse(markerMap.has(m5)); + + assert.strictEqual(subPatchBuffer.getBuffer().getText(), dedent` + 04 + 0005 + 0006 + 0 + `); + assert.deepEqual( + subPatchBuffer.findMarkers('hunk', {}).map(m => m.getRange().serialize()), + [[[0, 0], [1, 0]]], + ); + assert.deepEqual( + subPatchBuffer.findMarkers('patch', {}).map(m => m.getRange().serialize()), + [[[2, 0], [3, 1]]], + ); + assert.deepEqual(markerMap.get(m2).getRange().serialize(), [[0, 0], [1, 0]]); + assert.deepEqual(markerMap.get(m3).getRange().serialize(), [[2, 0], [3, 1]]); + }); + + it('includes markers that cross into or across the chosen range', function() { + patchBuffer.markRange('patch', [[0, 0], [4, 2]]); // before -> within + patchBuffer.markRange('hunk', [[6, 1], [8, 0]]); // within -> after + patchBuffer.markRange('addition', [[1, 0], [9, 4]]); // before -> after + + const {patchBuffer: subPatchBuffer} = patchBuffer.createSubBuffer([[3, 1], [7, 3]]); + assert.strictEqual(subPatchBuffer.getBuffer().getText(), dedent` + 003 + 0004 + 0005 + 0006 + 000 + `); + assertMarkerRanges(subPatchBuffer.getLayer('patch'), [[0, 0], [1, 2]]); + assertMarkerRanges(subPatchBuffer.getLayer('hunk'), [[3, 1], [4, 3]]); + assertMarkerRanges(subPatchBuffer.getLayer('addition'), [[0, 0], [4, 3]]); + }); + }); + describe('extractPatchBuffer', function() { it('extracts a subset of the buffer and layers as a new PatchBuffer', function() { const m0 = patchBuffer.markRange('patch', [[1, 0], [3, 0]]); // before diff --git a/test/models/repository.test.js b/test/models/repository.test.js index 28988ed9ea..bd23072c58 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -159,6 +159,46 @@ describe('Repository', function() { assert.isTrue(repository.showStatusBarTiles()); }); + describe('getCurrentGitHubRemote', function() { + let workdir, repository; + beforeEach(async function() { + workdir = await cloneRepository('three-files'); + repository = new Repository(workdir); + await repository.getLoadPromise(); + }); + it('gets current GitHub remote if remote is configured', async function() { + await repository.addRemote('yes0', 'git@github.com:atom/github.git'); + + const remote = await repository.getCurrentGitHubRemote(); + assert.strictEqual(remote.url, 'git@github.com:atom/github.git'); + assert.strictEqual(remote.name, 'yes0'); + }); + + it('returns null remote no remotes exist', async function() { + const remote = await repository.getCurrentGitHubRemote(); + assert.isFalse(remote.isPresent()); + }); + + it('returns null remote if only non-GitHub remotes exist', async function() { + await repository.addRemote('no0', 'https://sourceforge.net/some/repo.git'); + const remote = await repository.getCurrentGitHubRemote(); + assert.isFalse(remote.isPresent()); + }); + + it('returns null remote if no remotes are configured and multiple GitHub remotes exist', async function() { + await repository.addRemote('yes0', 'git@github.com:atom/github.git'); + await repository.addRemote('yes1', 'git@github.com:smashwilson/github.git'); + const remote = await repository.getCurrentGitHubRemote(); + assert.isFalse(remote.isPresent()); + }); + + it('returns null remote before repository has loaded', async function() { + const loadingRepository = new Repository(workdir); + const remote = await loadingRepository.getCurrentGitHubRemote(); + assert.isFalse(remote.isPresent()); + }); + }); + describe('getGitDirectoryPath', function() { it('returns the correct git directory path', async function() { const workingDirPath = await cloneRepository('three-files'); @@ -1727,6 +1767,10 @@ describe('Repository', function() { `getFilePatchForPath {staged} ${fileName}`, () => repository.getFilePatchForPath(fileName, {staged: true}), ); + calls.set( + `getDiffsForFilePath ${fileName}`, + () => repository.getDiffsForFilePath(fileName, 'HEAD^'), + ); calls.set( `readFileFromIndex ${fileName}`, () => repository.readFileFromIndex(fileName), @@ -2366,6 +2410,12 @@ describe('Repository', function() { `getFilePatchForPath {unstaged} ${path.join('subdir-1/a.txt')}`, `getFilePatchForPath {unstaged} ${path.join('subdir-1/b.txt')}`, `getFilePatchForPath {unstaged} ${path.join('subdir-1/c.txt')}`, + 'getDiffsForFilePath a.txt', + 'getDiffsForFilePath b.txt', + 'getDiffsForFilePath c.txt', + `getDiffsForFilePath ${path.join('subdir-1/a.txt')}`, + `getDiffsForFilePath ${path.join('subdir-1/b.txt')}`, + `getDiffsForFilePath ${path.join('subdir-1/c.txt')}`, ]); }); }); diff --git a/test/models/workdir-context-pool.test.js b/test/models/workdir-context-pool.test.js index ff69df6b89..02baaeebde 100644 --- a/test/models/workdir-context-pool.test.js +++ b/test/models/workdir-context-pool.test.js @@ -183,6 +183,57 @@ describe('WorkdirContextPool', function() { }); }); + describe('getMatchingContext', function() { + let dirs; + + beforeEach(async function() { + dirs = await Promise.all( + [1, 2, 3].map(() => cloneRepository()), + ); + }); + + async function addRepoRemote(context, name, url) { + const repo = context.getRepository(); + await repo.getLoadPromise(); + await repo.addRemote(name, url); + } + + it('returns a single resident context that has a repository with a matching remote', async function() { + const matchingContext = pool.add(dirs[0]); + await addRepoRemote(matchingContext, 'upstream', 'git@github.com:atom/github.git'); + + const nonMatchingContext0 = pool.add(dirs[1]); + await addRepoRemote(nonMatchingContext0, 'up', 'git@github.com:atom/atom.git'); + + pool.add(dirs[2]); + + assert.strictEqual(await pool.getMatchingContext('github.com', 'atom', 'github'), matchingContext); + }); + + it('returns a null context when no contexts have suitable repositories', async function() { + const context0 = pool.add(dirs[0]); + await addRepoRemote(context0, 'upstream', 'git@github.com:atom/atom.git'); + + pool.add(dirs[1]); + + const match = await pool.getMatchingContext('github.com', 'atom', 'github'); + assert.isFalse(match.isPresent()); + }); + + it('returns a null context when more than one context has a suitable repository', async function() { + const context0 = pool.add(dirs[0]); + await addRepoRemote(context0, 'upstream', 'git@github.com:atom/github.git'); + + const context1 = pool.add(dirs[1]); + await addRepoRemote(context1, 'upstream', 'git@github.com:atom/github.git'); + + pool.add(dirs[2]); + + const match = await pool.getMatchingContext('github.com', 'atom', 'github'); + assert.isFalse(match.isPresent()); + }); + }); + describe('clear', function() { it('removes all resident contexts', async function() { const [dir0, dir1, dir2] = await Promise.all([ diff --git a/test/views/checkout-button.test.js b/test/views/checkout-button.test.js new file mode 100644 index 0000000000..d328c868dd --- /dev/null +++ b/test/views/checkout-button.test.js @@ -0,0 +1,66 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import EnableableOperation from '../../lib/models/enableable-operation'; +import {checkoutStates} from '../../lib/controllers/pr-checkout-controller'; +import CheckoutButton from '../../lib/views/checkout-button'; + +describe('Checkout button', function() { + + function buildApp(overrideProps = {}) { + return ( + {}).disable(checkoutStates.CURRENT)} + classNamePrefix="" + {...overrideProps} + /> + ); + } + + it('renders checkout button with proper class names', function() { + const button = shallow(buildApp({ + classNames: ['not', 'necessary'], + classNamePrefix: 'prefix--', + })).find('.checkoutButton'); + assert.isTrue(button.hasClass('prefix--current')); + assert.isTrue(button.hasClass('not')); + assert.isTrue(button.hasClass('necessary')); + }); + + it('triggers its operation callback on click', function() { + const cb = sinon.spy(); + const checkoutOp = new EnableableOperation(cb); + const wrapper = shallow(buildApp({checkoutOp})); + + const button = wrapper.find('.checkoutButton'); + assert.strictEqual(button.text(), 'Checkout'); + button.simulate('click'); + assert.isTrue(cb.called); + }); + + it('renders as disabled with hover text set to the disablement message', function() { + const checkoutOp = new EnableableOperation(() => {}).disable(checkoutStates.DISABLED, 'message'); + const wrapper = shallow(buildApp({checkoutOp})); + + const button = wrapper.find('.checkoutButton'); + assert.isTrue(button.prop('disabled')); + assert.strictEqual(button.text(), 'Checkout'); + assert.strictEqual(button.prop('title'), 'message'); + }); + + it('changes the button text when disabled because the PR is the current branch', function() { + const checkoutOp = new EnableableOperation(() => {}).disable(checkoutStates.CURRENT, 'message'); + const wrapper = shallow(buildApp({checkoutOp})); + + const button = wrapper.find('.checkoutButton'); + assert.isTrue(button.prop('disabled')); + assert.strictEqual(button.text(), 'Checked out'); + assert.strictEqual(button.prop('title'), 'message'); + }); + + it('renders hidden', function() { + const checkoutOp = new EnableableOperation(() => {}).disable(checkoutStates.HIDDEN, 'message'); + const wrapper = shallow(buildApp({checkoutOp})); + + assert.isFalse(wrapper.find('.checkoutButton').exists()); + }); +}); diff --git a/test/views/emoji-reactions-view.test.js b/test/views/emoji-reactions-view.test.js index 767116e6fa..3815492638 100644 --- a/test/views/emoji-reactions-view.test.js +++ b/test/views/emoji-reactions-view.test.js @@ -1,30 +1,153 @@ import React from 'react'; import {shallow} from 'enzyme'; -import EmojiReactionsView from '../../lib/views/emoji-reactions-view'; + +import {BareEmojiReactionsView} from '../../lib/views/emoji-reactions-view'; +import ReactionPickerController from '../../lib/controllers/reaction-picker-controller'; +import {issueBuilder} from '../builder/graphql/issue'; + +import reactableQuery from '../../lib/views/__generated__/emojiReactionsView_reactable.graphql'; describe('EmojiReactionsView', function() { - let wrapper; - const reactionGroups = [ - {content: 'THUMBS_UP', users: {totalCount: 10}}, - {content: 'THUMBS_DOWN', users: {totalCount: 5}}, - {content: 'ROCKET', users: {totalCount: 42}}, - {content: 'EYES', users: {totalCount: 13}}, - {content: 'AVOCADO', users: {totalCount: 11}}, - {content: 'LAUGH', users: {totalCount: 0}}]; + let atomEnv; + beforeEach(function() { - wrapper = shallow(); + atomEnv = global.buildAtomEnvironment(); }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + return ( + {}} + removeReaction={() => {}} + tooltips={atomEnv.tooltips} + {...override} + /> + ); + } + it('renders reaction groups', function() { - const groups = wrapper.find('.github-IssueishDetailView-reactionsGroup'); + const reactable = issueBuilder(reactableQuery) + .addReactionGroup(group => group.content('THUMBS_UP').users(u => u.totalCount(10))) + .addReactionGroup(group => group.content('THUMBS_DOWN').users(u => u.totalCount(5))) + .addReactionGroup(group => group.content('ROCKET').users(u => u.totalCount(42))) + .addReactionGroup(group => group.content('EYES').users(u => u.totalCount(13))) + .addReactionGroup(group => group.content('LAUGH').users(u => u.totalCount(0))) + .build(); + + const wrapper = shallow(buildApp({reactable})); + + const groups = wrapper.find('.github-EmojiReactions-group'); assert.lengthOf(groups.findWhere(n => /👍/u.test(n.text()) && /\b10\b/.test(n.text())), 1); assert.lengthOf(groups.findWhere(n => /👎/u.test(n.text()) && /\b5\b/.test(n.text())), 1); assert.lengthOf(groups.findWhere(n => /🚀/u.test(n.text()) && /\b42\b/.test(n.text())), 1); assert.lengthOf(groups.findWhere(n => /👀/u.test(n.text()) && /\b13\b/.test(n.text())), 1); assert.isFalse(groups.someWhere(n => /😆/u.test(n.text()))); }); + it('gracefully skips unknown emoji', function() { - assert.isFalse(wrapper.text().includes(11)); - const groups = wrapper.find('.github-IssueishDetailView-reactionsGroup'); - assert.lengthOf(groups, 4); + const reactable = issueBuilder(reactableQuery) + .addReactionGroup(group => group.content('AVOCADO').users(u => u.totalCount(11))) + .build(); + + const wrapper = shallow(buildApp({reactable})); + assert.notMatch(wrapper.text(), /\b11\b/); + }); + + it("shows which reactions you've personally given", function() { + const reactable = issueBuilder(reactableQuery) + .addReactionGroup(group => group.content('ROCKET').users(u => u.totalCount(5)).viewerHasReacted(true)) + .addReactionGroup(group => group.content('EYES').users(u => u.totalCount(7)).viewerHasReacted(false)) + .build(); + + const wrapper = shallow(buildApp({reactable})); + + assert.isTrue(wrapper.find('.github-EmojiReactions-group.rocket').hasClass('selected')); + assert.isFalse(wrapper.find('.github-EmojiReactions-group.eyes').hasClass('selected')); + }); + + it('adds a reaction to an existing emoji on click', function() { + const addReaction = sinon.spy(); + + const reactable = issueBuilder(reactableQuery) + .addReactionGroup(group => group.content('THUMBS_UP').users(u => u.totalCount(2)).viewerHasReacted(false)) + .build(); + + const wrapper = shallow(buildApp({addReaction, reactable})); + + wrapper.find('.github-EmojiReactions-group.thumbs_up').simulate('click'); + assert.isTrue(addReaction.calledWith('THUMBS_UP')); + }); + + it('removes a reaction from an existing emoji on click', function() { + const removeReaction = sinon.spy(); + + const reactable = issueBuilder(reactableQuery) + .addReactionGroup(group => group.content('THUMBS_DOWN').users(u => u.totalCount(3)).viewerHasReacted(true)) + .build(); + + const wrapper = shallow(buildApp({removeReaction, reactable})); + + wrapper.find('.github-EmojiReactions-group.thumbs_down').simulate('click'); + assert.isTrue(removeReaction.calledWith('THUMBS_DOWN')); + }); + + it('disables the reaction toggle buttons if the viewer cannot react', function() { + const reactable = issueBuilder(reactableQuery) + .viewerCanReact(false) + .addReactionGroup(group => group.content('THUMBS_UP').users(u => u.totalCount(2))) + .build(); + + const wrapper = shallow(buildApp({reactable})); + + assert.isTrue(wrapper.find('.github-EmojiReactions-group.thumbs_up').prop('disabled')); + }); + + it('displays an "add emoji" control if at least one reaction group is empty', function() { + const reactable = issueBuilder(reactableQuery) + .addReactionGroup(group => group.content('THUMBS_UP').users(u => u.totalCount(2))) + .addReactionGroup(group => group.content('THUMBS_DOWN').users(u => u.totalCount(0))) + .build(); + + const wrapper = shallow(buildApp({reactable})); + assert.isTrue(wrapper.exists('.github-EmojiReactions-add')); + assert.isTrue(wrapper.find(ReactionPickerController).exists()); + }); + + it('displays an "add emoji" control when no reaction groups are present', function() { + // This happens when the Reactable is optimistically rendered. + const reactable = issueBuilder(reactableQuery).build(); + + const wrapper = shallow(buildApp({reactable})); + assert.isTrue(wrapper.exists('.github-EmojiReactions-add')); + assert.isTrue(wrapper.find(ReactionPickerController).exists()); + }); + + it('does not display the "add emoji" control if all reaction groups are nonempty', function() { + const reactable = issueBuilder(reactableQuery) + .addReactionGroup(group => group.content('THUMBS_UP').users(u => u.totalCount(1))) + .addReactionGroup(group => group.content('THUMBS_DOWN').users(u => u.totalCount(1))) + .addReactionGroup(group => group.content('ROCKET').users(u => u.totalCount(1))) + .addReactionGroup(group => group.content('EYES').users(u => u.totalCount(1))) + .build(); + + const wrapper = shallow(buildApp({reactable})); + assert.isFalse(wrapper.exists('.github-EmojiReactions-add')); + assert.isFalse(wrapper.find(ReactionPickerController).exists()); + }); + + it('disables the "add emoji" control if the viewer cannot react', function() { + const reactable = issueBuilder(reactableQuery) + .viewerCanReact(false) + .addReactionGroup(group => group.content('THUMBS_UP').users(u => u.totalCount(1))) + .addReactionGroup(group => group.content('THUMBS_DOWN').users(u => u.totalCount(0))) + .build(); + + const wrapper = shallow(buildApp({reactable})); + assert.isTrue(wrapper.find('.github-EmojiReactions-add').prop('disabled')); }); }); diff --git a/test/views/issue-detail-view.test.js b/test/views/issue-detail-view.test.js index 9417b269c6..d722b68006 100644 --- a/test/views/issue-detail-view.test.js +++ b/test/views/issue-detail-view.test.js @@ -2,7 +2,7 @@ import React from 'react'; import {shallow} from 'enzyme'; import {BareIssueDetailView} from '../../lib/views/issue-detail-view'; -import EmojiReactionsView from '../../lib/views/emoji-reactions-view'; +import EmojiReactionsController from '../../lib/controllers/emoji-reactions-controller'; import {issueDetailViewProps} from '../fixtures/props/issueish-pane-props'; import * as reporterProxy from '../../lib/reporter-proxy'; @@ -47,7 +47,7 @@ describe('IssueDetailView', function() { assert.isTrue(wrapper.find('GithubDotcomMarkdown').someWhere(n => n.prop('html') === 'nope')); - assert.lengthOf(wrapper.find(EmojiReactionsView), 1); + assert.lengthOf(wrapper.find(EmojiReactionsController), 1); assert.isNotNull(wrapper.find('ForwardRef(Relay(IssueishTimelineView))').prop('issue')); assert.notOk(wrapper.find('ForwardRef(Relay(IssueishTimelineView))').prop('pullRequest')); diff --git a/test/views/issueish-list-view.test.js b/test/views/issueish-list-view.test.js index 3851273828..b4a94087ee 100644 --- a/test/views/issueish-list-view.test.js +++ b/test/views/issueish-list-view.test.js @@ -142,4 +142,15 @@ describe('IssueishListView', function() { assert.isTrue(onMoreClick.called); }); }); + + it('renders review button only if needed', function() { + const openReviews = sinon.spy(); + const wrapper = mount(buildApp({total: 1, openReviews})); + assert.isFalse(wrapper.find('.github-IssueishList-openReviewsButton').exists()); + + wrapper.setProps({needReviewsButton: true}); + assert.isTrue(wrapper.find('.github-IssueishList-openReviewsButton').exists()); + wrapper.find('.github-IssueishList-openReviewsButton').simulate('click'); + assert.isTrue(openReviews.called); + }); }); diff --git a/test/views/multi-file-patch-view.test.js b/test/views/multi-file-patch-view.test.js index eaabc059d6..927c9bf3e9 100644 --- a/test/views/multi-file-patch-view.test.js +++ b/test/views/multi-file-patch-view.test.js @@ -7,9 +7,11 @@ import {cloneRepository, buildRepository} from '../helpers'; import {EXPANDED, COLLAPSED, TOO_LARGE} from '../../lib/models/patch/patch'; import MultiFilePatchView from '../../lib/views/multi-file-patch-view'; import {multiFilePatchBuilder} from '../builder/patch'; +import {aggregatedReviewsBuilder} from '../builder/graphql/aggregated-reviews-builder'; import {nullFile} from '../../lib/models/patch/file'; import FilePatch from '../../lib/models/patch/file-patch'; import RefHolder from '../../lib/models/ref-holder'; +import CommentGutterDecorationController from '../../lib/controllers/comment-gutter-decoration-controller'; import CommitPreviewItem from '../../lib/items/commit-preview-item'; import ChangedFileItem from '../../lib/items/changed-file-item'; import IssueishDetailItem from '../../lib/items/issueish-detail-item'; @@ -64,6 +66,7 @@ describe('MultiFilePatchView', function() { tooltips: atomEnv.tooltips, selectedRowsChanged: () => {}, + switchToIssueish: () => {}, diveIntoMirrorPatch: () => {}, surface: () => {}, @@ -88,6 +91,19 @@ describe('MultiFilePatchView', function() { assert.isTrue(wrapper.find('FilePatchHeaderView').exists()); }); + it('passes the new path of renamed files', function() { + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.status('renamed'); + fp.setOldFile(f => f.path('old.txt')); + fp.setNewFile(f => f.path('new.txt')); + }) + .build(); + + const wrapper = shallow(buildApp({multiFilePatch})); + assert.strictEqual(wrapper.find('FilePatchHeaderView').prop('newPath'), 'new.txt'); + }); + it('populates an externally provided refEditor', async function() { const refEditor = new RefHolder(); mount(buildApp({refEditor})); @@ -266,9 +282,124 @@ describe('MultiFilePatchView', function() { assert.isTrue(multiFilePatch.expandFilePatch.calledWith(fp0)); }); - it('renders a PullRequestsReviewsContainer if itemType is IssueishDetailItem', function() { - const wrapper = shallow(buildApp({itemType: IssueishDetailItem})); - assert.lengthOf(wrapper.find('ForwardRef(Relay(PullRequestReviewsController))'), 1); + describe('review comments', function() { + it('renders a gutter decoration for each review thread if itemType is IssueishDetailItem', function() { + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file0.txt')); + fp.addHunk(h => h.unchanged('0', '1', '2').added('3').unchanged('4', '5')); + }) + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file1.txt')); + fp.addHunk(h => h.unchanged('6').deleted('7', '8').unchanged('9')); + }) + .build(); + + const payload = aggregatedReviewsBuilder() + .addReviewThread(b => { + b.thread(t => t.id('thread0').isResolved(true)); + b.addComment(c => c.path('file0.txt').position(1)); + }) + .addReviewThread(b => { + b.thread(t => t.id('thread1').isResolved(false)); + b.addComment(c => c.path('file1.txt').position(2)); + b.addComment(c => c.path('ignored').position(999)); + b.addComment(c => c.path('file1.txt').position(0)); + }) + .addReviewThread(b => { + b.thread(t => t.id('thread2').isResolved(false)); + b.addComment(c => c.path('file0.txt').position(4)); + b.addComment(c => c.path('file0.txt').position(4)); + }) + .build(); + + const wrapper = shallow(buildApp({ + multiFilePatch, + reviewCommentsLoading: false, + reviewCommentsTotalCount: 3, + reviewCommentsResolvedCount: 1, + reviewCommentThreads: payload.commentThreads, + selectedRows: new Set([7]), + itemType: IssueishDetailItem, + })); + + const controllers = wrapper.find(CommentGutterDecorationController); + assert.lengthOf(controllers, 3); + + const controller0 = controllers.at(0); + assert.strictEqual(controller0.prop('commentRow'), 0); + assert.strictEqual(controller0.prop('threadId'), 'thread0'); + assert.lengthOf(controller0.prop('extraClasses'), 0); + + const controller1 = controllers.at(1); + assert.strictEqual(controller1.prop('commentRow'), 7); + assert.strictEqual(controller1.prop('threadId'), 'thread1'); + assert.deepEqual(controller1.prop('extraClasses'), ['github-FilePatchView-line--selected']); + + const controller2 = controllers.at(2); + assert.strictEqual(controller2.prop('commentRow'), 3); + assert.strictEqual(controller2.prop('threadId'), 'thread2'); + assert.lengthOf(controller2.prop('extraClasses'), 0); + }); + + it('does not render threads until they finish loading', function() { + const payload = aggregatedReviewsBuilder() + .addReviewThread(b => { + b.thread(t => t.isResolved(true)); + b.addComment(); + }) + .addReviewThread(b => { + b.thread(t => t.isResolved(false)); + b.addComment(); + b.addComment(); + b.addComment(); + }) + .build(); + + const wrapper = shallow(buildApp({ + reviewCommentsLoading: true, + reviewCommentsTotalCount: 3, + reviewCommentsResolvedCount: 1, + reviewCommentThreads: payload.commentThreads, + itemType: IssueishDetailItem, + })); + + assert.isFalse(wrapper.find(CommentGutterDecorationController).exists()); + }); + + it('omit threads that have an invalid path or position', function() { + sinon.stub(console, 'error'); + + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file.txt')); + fp.addHunk(h => h.unchanged('0').added('1').unchanged('2')); + }) + .build(); + + const payload = aggregatedReviewsBuilder() + .addReviewThread(b => { + b.addComment(c => c.path('bad-path.txt').position(1)); + }) + .addReviewThread(b => { + b.addComment(c => c.path('file.txt').position(100)); + }) + .build(); + + const wrapper = shallow(buildApp({ + multiFilePatch, + reviewCommentsLoading: false, + reviewCommentsTotalCount: 2, + reviewCommentsResolvedCount: 0, + reviewCommentThreads: payload.commentThreads, + itemType: IssueishDetailItem, + })); + + assert.isFalse(wrapper.find(CommentGutterDecorationController).exists()); + + // eslint-disable-next-line no-console + assert.strictEqual(console.error.callCount, 1); + }); }); it('renders the file patch within an editor', function() { @@ -1169,6 +1300,28 @@ describe('MultiFilePatchView', function() { assert.sameMembers(Array.from(selectedRowsChanged.lastCall.args[0]), [6]); assert.strictEqual(selectedRowsChanged.lastCall.args[1], 'hunk'); assert.isFalse(selectedRowsChanged.lastCall.args[2]); + + selectedRowsChanged.resetHistory(); + + // Same rows + editor.setSelectedBufferRange([[5, 0], [6, 3]]); + assert.isFalse(selectedRowsChanged.called); + + // Start row is different + editor.setSelectedBufferRange([[4, 0], [6, 3]]); + + assert.sameMembers(Array.from(selectedRowsChanged.lastCall.args[0]), [6]); + assert.strictEqual(selectedRowsChanged.lastCall.args[1], 'hunk'); + assert.isFalse(selectedRowsChanged.lastCall.args[2]); + + selectedRowsChanged.resetHistory(); + + // End row is different + editor.setSelectedBufferRange([[4, 0], [7, 3]]); + + assert.sameMembers(Array.from(selectedRowsChanged.lastCall.args[0]), [6, 7]); + assert.strictEqual(selectedRowsChanged.lastCall.args[1], 'hunk'); + assert.isFalse(selectedRowsChanged.lastCall.args[2]); }); it('notifies a callback when cursors span multiple files', function() { @@ -1323,6 +1476,41 @@ describe('MultiFilePatchView', function() { assert.isFalse(toggleSymlinkChange.calledWith(fp4)); }); + it('registers file mode and symlink commands depending on the staging status', function() { + const {multiFilePatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.status('deleted'); + fp.setOldFile(f => f.path('f0').symlinkTo('elsewhere')); + fp.nullNewFile(); + }) + .addFilePatch(fp => { + fp.status('added'); + fp.nullOldFile(); + fp.setNewFile(f => f.path('f0')); + fp.addHunk(h => h.added('0')); + }) + .addFilePatch(fp => { + fp.setOldFile(f => f.path('f1')); + fp.setNewFile(f => f.path('f1').executable()); + fp.addHunk(h => h.added('0')); + }) + .build(); + + const wrapper = shallow(buildApp({multiFilePatch, stagingStatus: 'unstaged'})); + + assert.isTrue(wrapper.exists('Command[command="github:stage-file-mode-change"]')); + assert.isTrue(wrapper.exists('Command[command="github:stage-symlink-change"]')); + assert.isFalse(wrapper.exists('Command[command="github:unstage-file-mode-change"]')); + assert.isFalse(wrapper.exists('Command[command="github:unstage-symlink-change"]')); + + wrapper.setProps({stagingStatus: 'staged'}); + + assert.isFalse(wrapper.exists('Command[command="github:stage-file-mode-change"]')); + assert.isFalse(wrapper.exists('Command[command="github:stage-symlink-change"]')); + assert.isTrue(wrapper.exists('Command[command="github:unstage-file-mode-change"]')); + assert.isTrue(wrapper.exists('Command[command="github:unstage-symlink-change"]')); + }); + it('toggles the current selection', function() { const toggleRows = sinon.spy(); const wrapper = mount(buildApp({toggleRows})); diff --git a/test/views/observe-model.test.js b/test/views/observe-model.test.js index 1565d68978..39bfdc4750 100644 --- a/test/views/observe-model.test.js +++ b/test/views/observe-model.test.js @@ -1,7 +1,7 @@ import {Emitter} from 'event-kit'; import React from 'react'; -import {mount} from 'enzyme'; +import {mount, shallow} from 'enzyme'; import ObserveModel from '../../lib/views/observe-model'; @@ -59,4 +59,33 @@ describe('ObserveModel', function() { wrapper.setProps({testModel: model2}); await assert.async.equal(wrapper.text(), '1 - 2'); }); + + describe('fetch parameters', function() { + let model, fetchData, children; + + beforeEach(function() { + model = new TestModel({one: 'a', two: 'b'}); + fetchData = async (m, a, b, c) => { + const data = await m.getData(); + return {a, b, c, ...data}; + }; + children = sinon.spy(); + }); + + it('are provided as additional arguments to the fetchData call', async function() { + shallow(); + + await assert.async.isTrue(children.calledWith({a: 1, b: 2, c: 3, one: 'a', two: 'b'})); + }); + + it('trigger a re-fetch when any change referential equality', async function() { + const wrapper = shallow( + , + ); + await assert.async.isTrue(children.calledWith({a: 1, b: 2, c: 3, one: 'a', two: 'b'})); + + wrapper.setProps({fetchParams: [1, 5, 3]}); + await assert.async.isTrue(children.calledWith({a: 1, b: 5, c: 3, one: 'a', two: 'b'})); + }); + }); }); diff --git a/test/views/patch-preview-view.test.js b/test/views/patch-preview-view.test.js new file mode 100644 index 0000000000..b5f33ca4db --- /dev/null +++ b/test/views/patch-preview-view.test.js @@ -0,0 +1,128 @@ +import React from 'react'; +import {mount} from 'enzyme'; +import dedent from 'dedent-js'; + +import PatchPreviewView from '../../lib/views/patch-preview-view'; +import {multiFilePatchBuilder} from '../builder/patch'; +import {assertMarkerRanges} from '../helpers'; + +describe('PatchPreviewView', function() { + let atomEnv, multiFilePatch; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + multiFilePatch = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file.txt')); + fp.addHunk(h => h.unchanged('000').added('001', '002').deleted('003', '004')); + fp.addHunk(h => h.unchanged('005').deleted('006').added('007').unchanged('008')); + }) + .build() + .multiFilePatch; + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const props = { + multiFilePatch, + fileName: 'file.txt', + diffRow: 3, + maxRowCount: 4, + config: atomEnv.config, + ...override, + }; + + return ; + } + + function getEditor(wrapper) { + return wrapper.find('AtomTextEditor').instance().getRefModel().getOr(null); + } + + it('builds and renders sub-PatchBuffer content within a TextEditor', function() { + const wrapper = mount(buildApp({fileName: 'file.txt', diffRow: 5, maxRowCount: 4})); + const editor = getEditor(wrapper); + + assert.strictEqual(editor.getText(), dedent` + 001 + 002 + 003 + 004 + `); + }); + + it('retains sub-PatchBuffers, adopting new content if the MultiFilePatch changes', function() { + const wrapper = mount(buildApp({fileName: 'file.txt', diffRow: 4, maxRowCount: 4})); + const previewPatchBuffer = wrapper.state('previewPatchBuffer'); + assert.strictEqual(previewPatchBuffer.getBuffer().getText(), dedent` + 000 + 001 + 002 + 003 + `); + + wrapper.setProps({}); + assert.strictEqual(wrapper.state('previewPatchBuffer'), previewPatchBuffer); + assert.strictEqual(previewPatchBuffer.getBuffer().getText(), dedent` + 000 + 001 + 002 + 003 + `); + + const {multiFilePatch: newPatch} = multiFilePatchBuilder() + .addFilePatch(fp => { + fp.setOldFile(f => f.path('file.txt')); + fp.addHunk(h => h.unchanged('000').added('001').unchanged('changed').deleted('003', '004')); + fp.addHunk(h => h.unchanged('005').deleted('006').added('007').unchanged('008')); + }) + .build(); + wrapper.setProps({multiFilePatch: newPatch}); + + assert.strictEqual(wrapper.state('previewPatchBuffer'), previewPatchBuffer); + assert.strictEqual(previewPatchBuffer.getBuffer().getText(), dedent` + 000 + 001 + changed + 003 + `); + }); + + it('decorates the addition and deletion marker layers', function() { + const wrapper = mount(buildApp({fileName: 'file.txt', diffRow: 5, maxRowCount: 4})); + + const additionDecoration = wrapper.find( + 'BareDecoration[className="github-FilePatchView-line--added"][type="line"]', + ); + const additionLayer = additionDecoration.prop('decorableHolder').get(); + assertMarkerRanges(additionLayer, [[0, 0], [1, 3]]); + + const deletionDecoration = wrapper.find( + 'BareDecoration[className="github-FilePatchView-line--deleted"][type="line"]', + ); + const deletionLayer = deletionDecoration.prop('decorableHolder').get(); + assertMarkerRanges(deletionLayer, [[2, 0], [3, 3]]); + }); + + it('includes a diff icon gutter when enabled', function() { + atomEnv.config.set('github.showDiffIconGutter', true); + + const wrapper = mount(buildApp()); + + const gutter = wrapper.find('BareGutter'); + assert.strictEqual(gutter.prop('name'), 'diff-icons'); + assert.strictEqual(gutter.prop('type'), 'line-number'); + assert.strictEqual(gutter.prop('className'), 'icons'); + assert.strictEqual(gutter.prop('labelFn')(), '\u00a0'); + }); + + it('omits the diff icon gutter when disabled', function() { + atomEnv.config.set('github.showDiffIconGutter', false); + + const wrapper = mount(buildApp()); + assert.isFalse(wrapper.exists('BareGutter')); + }); +}); diff --git a/test/views/pr-comments-view.test.js b/test/views/pr-comments-view.test.js deleted file mode 100644 index ff563441fc..0000000000 --- a/test/views/pr-comments-view.test.js +++ /dev/null @@ -1,158 +0,0 @@ -import React from 'react'; -import {shallow} from 'enzyme'; - -import {multiFilePatchBuilder} from '../builder/patch'; -import {pullRequestBuilder} from '../builder/pr'; -import PullRequestCommentsView, {PullRequestCommentView} from '../../lib/views/pr-review-comments-view'; - -describe('PullRequestCommentsView', function() { - function buildApp(multiFilePatch, pullRequest, overrideProps) { - const relay = { - hasMore: () => {}, - loadMore: () => {}, - isLoading: () => {}, - }; - return shallow( - {}} - {...overrideProps} - />, - ); - } - it('adjusts the position for comments after hunk headers', function() { - const {multiFilePatch} = multiFilePatchBuilder() - .addFilePatch(fp => { - fp.setOldFile(f => f.path('file0.txt')); - fp.addHunk(h => h.oldRow(5).unchanged('0 (1)').added('1 (2)', '2 (3)', '3 (4)').unchanged('4 (5)')); - fp.addHunk(h => h.oldRow(20).unchanged('5 (7)').deleted('6 (8)', '7 (9)', '8 (10)').unchanged('9 (11)')); - fp.addHunk(h => { - h.oldRow(30).unchanged('10 (13)').added('11 (14)', '12 (15)').deleted('13 (16)').unchanged('14 (17)'); - }); - }) - .addFilePatch(fp => { - fp.setOldFile(f => f.path('file1.txt')); - fp.addHunk(h => h.oldRow(5).unchanged('15 (1)').added('16 (2)').unchanged('17 (3)')); - fp.addHunk(h => h.oldRow(20).unchanged('18 (5)').deleted('19 (6)', '20 (7)', '21 (8)').unchanged('22 (9)')); - }) - .build(); - - const pr = pullRequestBuilder() - .addReview(r => { - r.addComment(c => c.id(0).path('file0.txt').position(2).body('one')); - r.addComment(c => c.id(1).path('file0.txt').position(15).body('three')); - r.addComment(c => c.id(2).path('file1.txt').position(7).body('three')); - }) - .build(); - - const wrapper = buildApp(multiFilePatch, pr, {}); - - assert.deepEqual(wrapper.find('Marker').at(0).prop('bufferRange').serialize(), [[1, 0], [1, 0]]); - assert.deepEqual(wrapper.find('Marker').at(1).prop('bufferRange').serialize(), [[12, 0], [12, 0]]); - assert.deepEqual(wrapper.find('Marker').at(2).prop('bufferRange').serialize(), [[20, 0], [20, 0]]); - }); - - it('does not render comment if patch is too large or collapsed', function() { - const {multiFilePatch} = multiFilePatchBuilder().build(); - - const pr = pullRequestBuilder() - .addReview(r => { - r.addComment(c => c.id(0).path('file0.txt').position(2).body('one')); - }) - .build(); - - const wrapper = buildApp(multiFilePatch, pr, {isPatchVisible: () => { return false; }}); - const comments = wrapper.find('PullRequestCommentView'); - assert.lengthOf(comments, 0); - }); - - it('does not render comment if position is null', function() { - const {multiFilePatch} = multiFilePatchBuilder() - .addFilePatch(fp => { - fp.setOldFile(f => f.path('file0.txt')); - fp.addHunk(h => h.oldRow(5).unchanged('0 (1)').added('1 (2)', '2 (3)', '3 (4)').unchanged('4 (5)')); - fp.addHunk(h => h.oldRow(20).unchanged('5 (7)').deleted('6 (8)', '7 (9)', '8 (10)').unchanged('9 (11)')); - fp.addHunk(h => { - h.oldRow(30).unchanged('10 (13)').added('11 (14)', '12 (15)').deleted('13 (16)').unchanged('14 (17)'); - }); - }) - .build(); - - const pr = pullRequestBuilder() - .addReview(r => { - r.addComment(c => c.id(0).path('file0.txt').position(2).body('one')); - r.addComment(c => c.id(1).path('file0.txt').position(null).body('three')); - }) - .build(); - - const wrapper = buildApp(multiFilePatch, pr, {}); - - const comments = wrapper.find('PullRequestCommentView'); - assert.lengthOf(comments, 1); - assert.strictEqual(comments.at(0).prop('comment').body, 'one'); - }); -}); - -describe('PullRequestCommentView', function() { - const avatarUrl = 'https://avatars3.githubusercontent.com/u/3781742?s=40&v=4'; - const login = 'annthurium'; - const commentUrl = 'https://github.com/kuychaco/test-repo/pull/4#discussion_r244214873'; - const createdAt = '2018-12-27T17:51:17Z'; - const bodyHTML = '
yo yo
'; - const switchToIssueish = () => {}; - - function buildApp(commentOverrideProps = {}, opts = {}) { - const props = { - comment: { - bodyHTML, - url: commentUrl, - createdAt, - author: { - avatarUrl, - login, - }, - ...commentOverrideProps, - }, - switchToIssueish, - ...opts, - }; - - return ( - - ); - } - - it('renders the PullRequestCommentReview information', function() { - const wrapper = shallow(buildApp()); - const avatar = wrapper.find('.github-PrComment-avatar'); - - assert.strictEqual(avatar.getElement('src').props.src, avatarUrl); - assert.strictEqual(avatar.getElement('alt').props.alt, login); - - assert.isTrue(wrapper.text().includes(`${login} commented`)); - - const a = wrapper.find('.github-PrComment-timeAgo'); - assert.strictEqual(a.getElement('href').props.href, commentUrl); - - const timeAgo = wrapper.find('Timeago'); - assert.strictEqual(timeAgo.prop('time'), createdAt); - - const githubDotcomMarkdown = wrapper.find('GithubDotcomMarkdown'); - assert.strictEqual(githubDotcomMarkdown.prop('html'), bodyHTML); - assert.strictEqual(githubDotcomMarkdown.prop('switchToIssueish'), switchToIssueish); - }); - - it('contains the text `someone commented` for null authors', function() { - const wrapper = shallow(buildApp({author: null})); - assert.isTrue(wrapper.text().includes('someone commented')); - }); - - it('hides minimized comment', function() { - const wrapper = shallow(buildApp({isMinimized: true})); - assert.isTrue(wrapper.find('.github-PrComment-hidden').exists()); - assert.isFalse(wrapper.find('.github-PrComment-header').exists()); - }); -}); diff --git a/test/views/pr-detail-view.test.js b/test/views/pr-detail-view.test.js index 62364fe02d..767559ad47 100644 --- a/test/views/pr-detail-view.test.js +++ b/test/views/pr-detail-view.test.js @@ -2,35 +2,110 @@ import React from 'react'; import {shallow} from 'enzyme'; import {Tab, Tabs, TabList, TabPanel} from 'react-tabs'; -import {BarePullRequestDetailView, checkoutStates} from '../../lib/views/pr-detail-view'; -import EmojiReactionsView from '../../lib/views/emoji-reactions-view'; -import {pullRequestDetailViewProps} from '../fixtures/props/issueish-pane-props'; +import {BarePullRequestDetailView} from '../../lib/views/pr-detail-view'; +import {checkoutStates} from '../../lib/controllers/pr-checkout-controller'; +import EmojiReactionsController from '../../lib/controllers/emoji-reactions-controller'; +import PullRequestCommitsView from '../../lib/views/pr-commits-view'; +import PullRequestStatusesView from '../../lib/views/pr-statuses-view'; +import PullRequestTimelineController from '../../lib/controllers/pr-timeline-controller'; +import IssueishDetailItem from '../../lib/items/issueish-detail-item'; import EnableableOperation from '../../lib/models/enableable-operation'; +import RefHolder from '../../lib/models/ref-holder'; +import {getEndpoint} from '../../lib/models/endpoint'; import * as reporterProxy from '../../lib/reporter-proxy'; +import {repositoryBuilder} from '../builder/graphql/repository'; +import {pullRequestBuilder} from '../builder/graphql/pr'; +import {cloneRepository, buildRepository} from '../helpers'; + +import repositoryQuery from '../../lib/views/__generated__/prDetailView_repository.graphql'; +import pullRequestQuery from '../../lib/views/__generated__/prDetailView_pullRequest.graphql'; describe('PullRequestDetailView', function() { - function buildApp(opts, overrideProps = {}) { - return ; + let atomEnv, localRepository; + + beforeEach(async function() { + atomEnv = global.buildAtomEnvironment(); + localRepository = await buildRepository(await cloneRepository()); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(overrides = {}) { + const props = { + relay: {refetch() {}}, + repository: repositoryBuilder(repositoryQuery).build(), + pullRequest: pullRequestBuilder(pullRequestQuery).build(), + + localRepository, + checkoutOp: new EnableableOperation(() => {}), + + reviewCommentsLoading: false, + reviewCommentsTotalCount: 0, + reviewCommentsResolvedCount: 0, + reviewCommentThreads: [], + + endpoint: getEndpoint('github.com'), + token: '1234', + + workspace: atomEnv.workspace, + commands: atomEnv.commands, + keymaps: atomEnv.keymaps, + tooltips: atomEnv.tooltips, + config: atomEnv.config, + + openCommit: () => {}, + openReviews: () => {}, + switchToIssueish: () => {}, + destroy: () => {}, + reportMutationErrors: () => {}, + + itemType: IssueishDetailItem, + refEditor: new RefHolder(), + + selectedTab: 0, + onTabSelected: () => {}, + onOpenFilesTab: () => {}, + + ...overrides, + }; + + return ; + } + + function findTabIndex(wrapper, tabText) { + let finalIndex; + let tempIndex = 0; + wrapper.find('Tab').forEach(t => { + t.children().forEach(child => { + if (child.text() === tabText) { + finalIndex = tempIndex; + } + }); + tempIndex++; + }); + return finalIndex; } it('renders pull request information', function() { - const baseRefName = 'master'; - const headRefName = 'tt/heck-yes'; - const wrapper = shallow(buildApp({ - repositoryName: 'repo', - ownerLogin: 'user0', - - pullRequestKind: 'PullRequest', - pullRequestTitle: 'PR title', - pullRequestBaseRef: baseRefName, - pullRequestHeadRef: headRefName, - pullRequestBodyHTML: 'stuff', - pullRequestAuthorLogin: 'author0', - pullRequestAuthorAvatarURL: 'https://avatars3.githubusercontent.com/u/1', - issueishNumber: 100, - pullRequestState: 'MERGED', - pullRequestReactions: [{content: 'THUMBS_UP', count: 10}, {content: 'THUMBS_DOWN', count: 5}, {content: 'LAUGH', count: 0}], - })); + const repository = repositoryBuilder(repositoryQuery) + .name('repo') + .owner(o => o.login('user0')) + .build(); + + const pullRequest = pullRequestBuilder(pullRequestQuery) + .number(100) + .title('PR title') + .state('MERGED') + .bodyHTML('stuff') + .baseRefName('master') + .headRefName('tt/heck-yes') + .url('https://github.com/user0/repo/pull/100') + .author(a => a.login('author0').avatarUrl('https://avatars3.githubusercontent.com/u/1')) + .build(); + + const wrapper = shallow(buildApp({repository, pullRequest})); const badge = wrapper.find('IssueishBadge'); assert.strictEqual(badge.prop('type'), 'PullRequest'); @@ -40,9 +115,9 @@ describe('PullRequestDetailView', function() { assert.strictEqual(link.text(), 'user0/repo#100'); assert.strictEqual(link.prop('href'), 'https://github.com/user0/repo/pull/100'); - assert.isTrue(wrapper.find('.github-IssueishDetailView-checkoutButton').exists()); + assert.isTrue(wrapper.find('CheckoutButton').exists()); - assert.isDefined(wrapper.find('ForwardRef(Relay(BarePrStatusesView))[displayType="check"]').prop('pullRequest')); + assert.isDefined(wrapper.find(PullRequestStatusesView).find('[displayType="check"]').prop('pullRequest')); const avatarLink = wrapper.find('.github-IssueishDetailView-avatar'); assert.strictEqual(avatarLink.prop('href'), 'https://github.com/author0'); @@ -54,20 +129,42 @@ describe('PullRequestDetailView', function() { assert.isTrue(wrapper.find('GithubDotcomMarkdown').someWhere(n => n.prop('html') === 'stuff')); - assert.lengthOf(wrapper.find(EmojiReactionsView), 1); + assert.lengthOf(wrapper.find(EmojiReactionsController), 1); + + assert.notOk(wrapper.find(PullRequestTimelineController).prop('issue')); + assert.isNotNull(wrapper.find(PullRequestTimelineController).prop('pullRequest')); + assert.isNotNull(wrapper.find(PullRequestStatusesView).find('[displayType="full"]').prop('pullRequest')); + + assert.strictEqual(wrapper.find('.github-IssueishDetailView-baseRefName').text(), 'master'); + assert.strictEqual(wrapper.find('.github-IssueishDetailView-headRefName').text(), 'tt/heck-yes'); + }); + + it('renders footer and passes review thread props through', function() { + const openReviews = sinon.spy(); + + const wrapper = shallow(buildApp({ + reviewCommentsLoading: false, + reviewCommentsTotalCount: 10, + reviewCommentsResolvedCount: 5, + openReviews, + })); + + const footer = wrapper.find('ReviewsFooterView'); - assert.notOk(wrapper.find('ForwardRef(Relay(IssueishTimelineView))').prop('issue')); - assert.isNotNull(wrapper.find('ForwardRef(Relay(IssueishTimelineView))').prop('pullRequest')); - assert.isNotNull(wrapper.find('ForwardRef(Relay(BarePrStatusesView))[displayType="full"]').prop('pullRequest')); + assert.strictEqual(footer.prop('commentsResolved'), 5); + assert.strictEqual(footer.prop('totalComments'), 10); - assert.strictEqual(wrapper.find('.github-IssueishDetailView-baseRefName').text(), baseRefName); - assert.strictEqual(wrapper.find('.github-IssueishDetailView-headRefName').text(), headRefName); + footer.prop('openReviews')(); + assert.isTrue(openReviews.called); }); it('renders tabs', function() { - const pullRequestCommitCount = 11; - const pullRequestChangedFileCount = 22; - const wrapper = shallow(buildApp({pullRequestCommitCount, pullRequestChangedFileCount})); + const pullRequest = pullRequestBuilder(pullRequestQuery) + .changedFiles(22) + .countedCommits(conn => conn.totalCount(11)) + .build(); + + const wrapper = shallow(buildApp({pullRequest})); assert.lengthOf(wrapper.find(Tabs), 1); assert.lengthOf(wrapper.find(TabList), 1); @@ -93,70 +190,85 @@ describe('PullRequestDetailView', function() { const tabCounts = wrapper.find('.github-IssueishDetailView-tab-count'); assert.lengthOf(tabCounts, 2); - assert.strictEqual(tabCounts.at(0).text(), `${pullRequestCommitCount}`); - assert.strictEqual(tabCounts.at(1).text(), `${pullRequestChangedFileCount}`); + assert.strictEqual(tabCounts.at(0).text(), '11'); + assert.strictEqual(tabCounts.at(1).text(), '22'); assert.lengthOf(wrapper.find(TabPanel), 4); }); + it('passes selected tab index to tabs', function() { + const onTabSelected = sinon.spy(); + + const wrapper = shallow(buildApp({selectedTab: 0, onTabSelected})); + assert.strictEqual(wrapper.find('Tabs').prop('selectedIndex'), 0); + + const index = findTabIndex(wrapper, 'Commits'); + wrapper.find('Tabs').prop('onSelect')(index); + assert.isTrue(onTabSelected.calledWith(index)); + }); + it('tells its tabs when the pull request is currently checked out', function() { - const wrapper = shallow(buildApp({}, { + const wrapper = shallow(buildApp({ checkoutOp: new EnableableOperation(() => {}).disable(checkoutStates.CURRENT), })); - assert.isTrue(wrapper.find('ForwardRef(Relay(IssueishTimelineView))').prop('onBranch')); - assert.isTrue(wrapper.find('ForwardRef(Relay(IssueishTimelineView))').prop('onBranch')); - assert.isTrue(wrapper.find('ForwardRef(Relay(PrCommitsView))').prop('onBranch')); + assert.isTrue(wrapper.find(PullRequestTimelineController).prop('onBranch')); + assert.isTrue(wrapper.find(PullRequestCommitsView).prop('onBranch')); }); it('tells its tabs when the pull request is not checked out', function() { const checkoutOp = new EnableableOperation(() => {}); - const wrapper = shallow(buildApp({}, {checkoutOp})); - assert.isFalse(wrapper.find('ForwardRef(Relay(IssueishTimelineView))').prop('onBranch')); - assert.isFalse(wrapper.find('ForwardRef(Relay(PrCommitsView))').prop('onBranch')); + const wrapper = shallow(buildApp({checkoutOp})); + assert.isFalse(wrapper.find(PullRequestTimelineController).prop('onBranch')); + assert.isFalse(wrapper.find(PullRequestCommitsView).prop('onBranch')); wrapper.setProps({checkoutOp: checkoutOp.disable(checkoutStates.HIDDEN, 'message')}); - assert.isFalse(wrapper.find('ForwardRef(Relay(IssueishTimelineView))').prop('onBranch')); - assert.isFalse(wrapper.find('ForwardRef(Relay(PrCommitsView))').prop('onBranch')); + assert.isFalse(wrapper.find(PullRequestTimelineController).prop('onBranch')); + assert.isFalse(wrapper.find(PullRequestCommitsView).prop('onBranch')); wrapper.setProps({checkoutOp: checkoutOp.disable(checkoutStates.DISABLED, 'message')}); - assert.isFalse(wrapper.find('ForwardRef(Relay(IssueishTimelineView))').prop('onBranch')); - assert.isFalse(wrapper.find('ForwardRef(Relay(PrCommitsView))').prop('onBranch')); + assert.isFalse(wrapper.find(PullRequestTimelineController).prop('onBranch')); + assert.isFalse(wrapper.find(PullRequestCommitsView).prop('onBranch')); wrapper.setProps({checkoutOp: checkoutOp.disable(checkoutStates.BUSY, 'message')}); - assert.isFalse(wrapper.find('ForwardRef(Relay(IssueishTimelineView))').prop('onBranch')); - assert.isFalse(wrapper.find('ForwardRef(Relay(PrCommitsView))').prop('onBranch')); + assert.isFalse(wrapper.find(PullRequestTimelineController).prop('onBranch')); + assert.isFalse(wrapper.find(PullRequestCommitsView).prop('onBranch')); }); - it('renders pull request information for cross repository PR', function() { - const baseRefName = 'master'; - const headRefName = 'tt-heck-yes'; - const ownerLogin = 'user0'; - const authorLogin = 'author0'; - const wrapper = shallow(buildApp({ - ownerLogin, - pullRequestBaseRef: baseRefName, - pullRequestHeadRef: headRefName, - pullRequestAuthorLogin: authorLogin, - pullRequestCrossRepository: true, - })); + it('renders pull request information for a cross repository PR', function() { + const repository = repositoryBuilder(repositoryQuery) + .owner(o => o.login('user0')) + .build(); + + const pullRequest = pullRequestBuilder(pullRequestQuery) + .isCrossRepository(true) + .author(a => a.login('author0')) + .baseRefName('master') + .headRefName('tt-heck-yes') + .build(); - assert.strictEqual(wrapper.find('.github-IssueishDetailView-baseRefName').text(), `${ownerLogin}/${baseRefName}`); - assert.strictEqual(wrapper.find('.github-IssueishDetailView-headRefName').text(), `${authorLogin}/${headRefName}`); + const wrapper = shallow(buildApp({repository, pullRequest})); + + assert.strictEqual(wrapper.find('.github-IssueishDetailView-baseRefName').text(), 'user0/master'); + assert.strictEqual(wrapper.find('.github-IssueishDetailView-headRefName').text(), 'author0/tt-heck-yes'); }); it('renders a placeholder issueish body', function() { - const wrapper = shallow(buildApp({pullRequestBodyHTML: null})); + const pullRequest = pullRequestBuilder(pullRequestQuery) + .nullBodyHTML() + .build(); + const wrapper = shallow(buildApp({pullRequest})); + assert.isTrue(wrapper.find('GithubDotcomMarkdown').someWhere(n => /No description/.test(n.prop('html')))); }); it('refreshes on click', function() { let callback = null; - const relayRefetch = sinon.stub().callsFake((_0, _1, cb) => { + const refetch = sinon.stub().callsFake((_0, _1, cb) => { callback = cb; }); - const wrapper = shallow(buildApp({relayRefetch}, {})); + const wrapper = shallow(buildApp({relay: {refetch}})); wrapper.find('Octicon[icon="repo-sync"]').simulate('click', {preventDefault: () => {}}); assert.isTrue(wrapper.find('Octicon[icon="repo-sync"]').hasClass('refreshing')); @@ -169,22 +281,22 @@ describe('PullRequestDetailView', function() { it('disregards a double refresh', function() { let callback = null; - const relayRefetch = sinon.stub().callsFake((_0, _1, cb) => { + const refetch = sinon.stub().callsFake((_0, _1, cb) => { callback = cb; }); - const wrapper = shallow(buildApp({relayRefetch}, {})); + const wrapper = shallow(buildApp({relay: {refetch}})); wrapper.find('Octicon[icon="repo-sync"]').simulate('click', {preventDefault: () => {}}); - assert.strictEqual(relayRefetch.callCount, 1); + assert.strictEqual(refetch.callCount, 1); wrapper.find('Octicon[icon="repo-sync"]').simulate('click', {preventDefault: () => {}}); - assert.strictEqual(relayRefetch.callCount, 1); + assert.strictEqual(refetch.callCount, 1); callback(); wrapper.update(); wrapper.find('Octicon[icon="repo-sync"]').simulate('click', {preventDefault: () => {}}); - assert.strictEqual(relayRefetch.callCount, 2); + assert.strictEqual(refetch.callCount, 2); }); it('configures the refresher with a 5 minute polling interval', function() { @@ -204,87 +316,45 @@ describe('PullRequestDetailView', function() { assert.isTrue(refresher.destroy.called); }); - describe('Checkout button', function() { - it('triggers its operation callback on click', function() { - const cb = sinon.spy(); - const checkoutOp = new EnableableOperation(cb); - const wrapper = shallow(buildApp({}, {checkoutOp})); - - const button = wrapper.find('.github-IssueishDetailView-checkoutButton'); - assert.strictEqual(button.text(), 'Checkout'); - button.simulate('click'); - assert.isTrue(cb.called); - }); - - it('renders as disabled with hover text set to the disablement message', function() { - const checkoutOp = new EnableableOperation(() => {}).disable(checkoutStates.DISABLED, 'message'); - const wrapper = shallow(buildApp({}, {checkoutOp})); - - const button = wrapper.find('.github-IssueishDetailView-checkoutButton'); - assert.isTrue(button.prop('disabled')); - assert.strictEqual(button.text(), 'Checkout'); - assert.strictEqual(button.prop('title'), 'message'); - }); - - it('changes the button text when disabled because the PR is the current branch', function() { - const checkoutOp = new EnableableOperation(() => {}).disable(checkoutStates.CURRENT, 'message'); - const wrapper = shallow(buildApp({}, {checkoutOp})); - - const button = wrapper.find('.github-IssueishDetailView-checkoutButton'); - assert.isTrue(button.prop('disabled')); - assert.strictEqual(button.text(), 'Checked out'); - assert.strictEqual(button.prop('title'), 'message'); - }); - - it('renders hidden', function() { - const checkoutOp = new EnableableOperation(() => {}).disable(checkoutStates.HIDDEN, 'message'); - const wrapper = shallow(buildApp({}, {checkoutOp})); - - assert.isFalse(wrapper.find('.github-IssueishDetailView-checkoutButton').exists()); - }); - }); - describe('metrics', function() { beforeEach(function() { sinon.stub(reporterProxy, 'addEvent'); }); it('records clicking the link to view an issueish', function() { - const wrapper = shallow(buildApp({ - repositoryName: 'repo', - ownerLogin: 'user0', - issueishNumber: 100, - })); + const repository = repositoryBuilder(repositoryQuery) + .name('repo') + .owner(o => o.login('user0')) + .build(); + + const pullRequest = pullRequestBuilder(pullRequestQuery) + .number(100) + .url('https://github.com/user0/repo/pull/100') + .build(); + + const wrapper = shallow(buildApp({repository, pullRequest})); const link = wrapper.find('a.github-IssueishDetailView-headerLink'); assert.strictEqual(link.text(), 'user0/repo#100'); assert.strictEqual(link.prop('href'), 'https://github.com/user0/repo/pull/100'); link.simulate('click'); - assert.isTrue(reporterProxy.addEvent.calledWith('open-pull-request-in-browser', {package: 'github', component: 'BarePullRequestDetailView'})); + assert.isTrue(reporterProxy.addEvent.calledWith( + 'open-pull-request-in-browser', + {package: 'github', component: 'BarePullRequestDetailView'}, + )); }); - function findTabIndex(wrapper, tabText) { - let finalIndex; - let tempIndex = 0; - wrapper.find('Tab').forEach(t => { - t.children().forEach(child => { - if (child.text() === tabText) { - finalIndex = tempIndex; - } - }); - tempIndex++; - }); - return finalIndex; - } - it('records opening the Overview tab', function() { const wrapper = shallow(buildApp()); const index = findTabIndex(wrapper, 'Overview'); wrapper.find('Tabs').prop('onSelect')(index); - assert.isTrue(reporterProxy.addEvent.calledWith('open-pr-tab-overview', {package: 'github', component: 'BarePullRequestDetailView'})); + assert.isTrue(reporterProxy.addEvent.calledWith( + 'open-pr-tab-overview', + {package: 'github', component: 'BarePullRequestDetailView'}, + )); }); it('records opening the Build Status tab', function() { @@ -293,7 +363,10 @@ describe('PullRequestDetailView', function() { wrapper.find('Tabs').prop('onSelect')(index); - assert.isTrue(reporterProxy.addEvent.calledWith('open-pr-tab-build-status', {package: 'github', component: 'BarePullRequestDetailView'})); + assert.isTrue(reporterProxy.addEvent.calledWith( + 'open-pr-tab-build-status', + {package: 'github', component: 'BarePullRequestDetailView'}, + )); }); it('records opening the Commits tab', function() { @@ -302,7 +375,10 @@ describe('PullRequestDetailView', function() { wrapper.find('Tabs').prop('onSelect')(index); - assert.isTrue(reporterProxy.addEvent.calledWith('open-pr-tab-commits', {package: 'github', component: 'BarePullRequestDetailView'})); + assert.isTrue(reporterProxy.addEvent.calledWith( + 'open-pr-tab-commits', + {package: 'github', component: 'BarePullRequestDetailView'}, + )); }); it('records opening the "Files Changed" tab', function() { @@ -311,7 +387,10 @@ describe('PullRequestDetailView', function() { wrapper.find('Tabs').prop('onSelect')(index); - assert.isTrue(reporterProxy.addEvent.calledWith('open-pr-tab-files-changed', {package: 'github', component: 'BarePullRequestDetailView'})); + assert.isTrue(reporterProxy.addEvent.calledWith( + 'open-pr-tab-files-changed', + {package: 'github', component: 'BarePullRequestDetailView'}, + )); }); }); }); diff --git a/test/views/reaction-picker-view.test.js b/test/views/reaction-picker-view.test.js new file mode 100644 index 0000000000..51a6bcd249 --- /dev/null +++ b/test/views/reaction-picker-view.test.js @@ -0,0 +1,77 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import ReactionPickerView from '../../lib/views/reaction-picker-view'; +import {reactionTypeToEmoji} from '../../lib/helpers'; + +describe('ReactionPickerView', function() { + let atomEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const props = { + viewerReacted: [], + addReactionAndClose: () => {}, + removeReactionAndClose: () => {}, + ...override, + }; + + return ; + } + + it('renders a button for each known content type', function() { + const knownTypes = Object.keys(reactionTypeToEmoji); + assert.include(knownTypes, 'THUMBS_UP'); + + const wrapper = shallow(buildApp()); + + for (const contentType of knownTypes) { + assert.isTrue( + wrapper + .find('.github-ReactionPicker-reaction') + .someWhere(w => w.text().includes(reactionTypeToEmoji[contentType])), + `does not include a button for ${contentType}`, + ); + } + }); + + it('adds the "selected" class to buttons included in viewerReacted', function() { + const viewerReacted = ['THUMBS_UP', 'ROCKET']; + const wrapper = shallow(buildApp({viewerReacted})); + + const reactions = wrapper.find('.github-ReactionPicker-reaction'); + const selectedReactions = reactions.find('.selected').map(w => w.text()); + assert.sameMembers(selectedReactions, [reactionTypeToEmoji.ROCKET, reactionTypeToEmoji.THUMBS_UP]); + }); + + it('calls addReactionAndClose when clicking a reaction button', function() { + const addReactionAndClose = sinon.spy(); + const wrapper = shallow(buildApp({addReactionAndClose})); + + wrapper + .find('.github-ReactionPicker-reaction') + .filterWhere(w => w.text().includes(reactionTypeToEmoji.LAUGH)) + .simulate('click'); + + assert.isTrue(addReactionAndClose.calledWith('LAUGH')); + }); + + it('calls removeReactionAndClose when clicking a reaction in viewerReacted', function() { + const removeReactionAndClose = sinon.spy(); + const wrapper = shallow(buildApp({viewerReacted: ['CONFUSED', 'HEART'], removeReactionAndClose})); + + wrapper + .find('.github-ReactionPicker-reaction') + .filterWhere(w => w.text().includes(reactionTypeToEmoji.CONFUSED)) + .simulate('click'); + + assert.isTrue(removeReactionAndClose.calledWith('CONFUSED')); + }); +}); diff --git a/test/views/reviews-footer-view.test.js b/test/views/reviews-footer-view.test.js new file mode 100644 index 0000000000..6bb73a3e58 --- /dev/null +++ b/test/views/reviews-footer-view.test.js @@ -0,0 +1,55 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import * as reporterProxy from '../../lib/reporter-proxy'; +import ReviewsFooterView from '../../lib/views/reviews-footer-view'; + +describe('ReviewsFooterView', function() { + let atomEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const props = { + commentsResolved: 3, + totalComments: 4, + openReviews: () => {}, + ...override, + }; + + return ; + } + + it('renders the resolved and total comment counts', function() { + const wrapper = shallow(buildApp({commentsResolved: 4, totalComments: 7})); + + assert.strictEqual(wrapper.find('.github-ReviewsFooterView-commentsResolved').text(), '4'); + assert.strictEqual(wrapper.find('.github-ReviewsFooterView-totalComments').text(), '7'); + assert.strictEqual(wrapper.find('.github-ReviewsFooterView-progessBar').prop('value'), 4); + assert.strictEqual(wrapper.find('.github-ReviewsFooterView-progessBar').prop('max'), 7); + }); + + it('triggers openReviews on button click', function() { + const openReviews = sinon.spy(); + const wrapper = shallow(buildApp({openReviews})); + + wrapper.find('.github-ReviewsFooterView-openReviewsButton').simulate('click'); + + assert.isTrue(openReviews.called); + }); + + it('records an event when review is started from footer', function() { + sinon.stub(reporterProxy, 'addEvent'); + const wrapper = shallow(buildApp()); + + wrapper.find('.github-ReviewsFooterView-reviewChangesButton').simulate('click'); + + assert.isTrue(reporterProxy.addEvent.calledWith('start-pr-review', {package: 'github', component: 'ReviewsFooterView'})); + }); +}); diff --git a/test/views/reviews-view.test.js b/test/views/reviews-view.test.js new file mode 100644 index 0000000000..8b91f053c1 --- /dev/null +++ b/test/views/reviews-view.test.js @@ -0,0 +1,422 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import path from 'path'; + +import {Command} from '../../lib/atom/commands'; +import ReviewsView from '../../lib/views/reviews-view'; +import EnableableOperation from '../../lib/models/enableable-operation'; +import {aggregatedReviewsBuilder} from '../builder/graphql/aggregated-reviews-builder'; +import {multiFilePatchBuilder} from '../builder/patch'; +import {checkoutStates} from '../../lib/controllers/pr-checkout-controller'; +import * as reporterProxy from '../../lib/reporter-proxy'; + +describe('ReviewsView', function() { + let atomEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const props = { + relay: {environment: {}}, + repository: {}, + pullRequest: {}, + summaries: [], + commentThreads: [], + commentTranslations: null, + multiFilePatch: multiFilePatchBuilder().build().multiFilePatch, + contextLines: 4, + checkoutOp: new EnableableOperation(() => {}).disable(checkoutStates.CURRENT), + summarySectionOpen: true, + commentSectionOpen: true, + threadIDsOpen: new Set(), + + number: 100, + repo: 'github', + owner: 'atom', + workdir: __dirname, + + workspace: atomEnv.workspace, + config: atomEnv.config, + commands: atomEnv.commands, + tooltips: atomEnv.tooltips, + + openFile: () => {}, + openDiff: () => {}, + openPR: () => {}, + moreContext: () => {}, + lessContext: () => {}, + openIssueish: () => {}, + showSummaries: () => {}, + hideSummaries: () => {}, + showComments: () => {}, + hideComments: () => {}, + showThreadID: () => {}, + hideThreadID: () => {}, + resolveThread: () => {}, + unresolveThread: () => {}, + addSingleComment: () => {}, + reportMutationErrors: () => {}, + refetch: () => {}, + ...override, + }; + + return ; + } + + it('registers atom commands', async function() { + const moreContext = sinon.stub(); + const lessContext = sinon.stub(); + const wrapper = shallow(buildApp({moreContext, lessContext})); + assert.lengthOf(wrapper.find(Command), 3); + + assert.isFalse(moreContext.called); + await wrapper.find(Command).at(0).prop('callback')(); + assert.isTrue(moreContext.called); + + assert.isFalse(lessContext.called); + await wrapper.find(Command).at(1).prop('callback')(); + assert.isTrue(lessContext.called); + }); + + it('renders empty state if there is no review', function() { + sinon.stub(reporterProxy, 'addEvent'); + const wrapper = shallow(buildApp()); + assert.lengthOf(wrapper.find('.github-Reviews-section.summaries'), 0); + assert.lengthOf(wrapper.find('.github-Reviews-section.comments'), 0); + assert.lengthOf(wrapper.find('.github-Reviews-emptyState'), 1); + + wrapper.find('.github-Reviews-emptyCallToActionButton a').simulate('click'); + assert.isTrue(reporterProxy.addEvent.calledWith( + 'start-pr-review', + {package: 'github', component: 'ReviewsView'}, + )); + }); + + it('renders summary and comment sections', function() { + const {summaries, commentThreads} = aggregatedReviewsBuilder() + .addReviewSummary(r => r.id(0)) + .addReviewThread(t => t.addComment()) + .addReviewThread(t => t.addComment()) + .build(); + + const wrapper = shallow(buildApp({summaries, commentThreads})); + + assert.lengthOf(wrapper.find('.github-Reviews-section.summaries'), 1); + assert.lengthOf(wrapper.find('.github-Reviews-section.comments'), 1); + assert.lengthOf(wrapper.find('.github-ReviewSummary'), 1); + assert.lengthOf(wrapper.find('details.github-Review'), 2); + }); + + it('calls openIssueish when clicking on an issueish link in a review summary', function() { + const openIssueish = sinon.spy(); + + const {summaries} = aggregatedReviewsBuilder() + .addReviewSummary(r => { + r.bodyHTML('hey look a link #123').id(0); + }) + .build(); + + const wrapper = shallow(buildApp({openIssueish, summaries})); + + wrapper.find('GithubDotcomMarkdown').prop('switchToIssueish')('aaa', 'bbb', 123); + assert.isTrue(openIssueish.calledWith('aaa', 'bbb', 123)); + + wrapper.find('GithubDotcomMarkdown').prop('openIssueishLinkInNewTab')({ + target: {dataset: {url: 'https://github.com/ccc/ddd/issues/654'}}, + }); + assert.isTrue(openIssueish.calledWith('ccc', 'ddd', 654)); + }); + + describe('refresh', function() { + it('calls refetch when refresh button is clicked', function() { + const refetch = sinon.stub().returns({dispose() {}}); + const wrapper = shallow(buildApp({refetch})); + assert.isFalse(refetch.called); + + wrapper.find('.icon-repo-sync').simulate('click'); + assert.isTrue(refetch.called); + assert.isTrue(wrapper.find('.icon-repo-sync').hasClass('refreshing')); + + // Trigger the completion callback + refetch.lastCall.args[0](); + assert.isFalse(wrapper.find('.icon-repo-sync').hasClass('refreshing')); + }); + + it('does not call refetch if already fetching', function() { + const refetch = sinon.stub().returns({dispose() {}}); + const wrapper = shallow(buildApp({refetch})); + assert.isFalse(refetch.called); + + wrapper.instance().state.isRefreshing = true; + wrapper.find('.icon-repo-sync').simulate('click'); + assert.isFalse(refetch.called); + }); + + it('cancels a refetch in progress on unmount', function() { + const refetchInProgress = {dispose: sinon.spy()}; + const refetch = sinon.stub().returns(refetchInProgress); + + const wrapper = shallow(buildApp({refetch})); + assert.isFalse(refetch.called); + + wrapper.find('.icon-repo-sync').simulate('click'); + wrapper.unmount(); + + assert.isTrue(refetchInProgress.dispose.called); + }); + }); + + describe('checkout button', function() { + it('passes checkoutOp prop through to CheckoutButon', function() { + const wrapper = shallow(buildApp()); + const checkoutOpProp = (wrapper.find('CheckoutButton').prop('checkoutOp')); + assert.deepEqual(checkoutOpProp.disablement, {reason: {name: 'current'}, message: 'disabled'}); + }); + }); + + describe('comment threads', function() { + const {summaries, commentThreads} = aggregatedReviewsBuilder() + .addReviewSummary(r => r.id(0)) + .addReviewThread(t => { + t.thread(t0 => t0.id('abcd')); + t.addComment(c => + c.id(0).path('dir/file0').position(10).bodyHTML('i have opinions.').author(a => a.login('user0').avatarUrl('user0.jpg')), + ); + t.addComment(c => + c.id(1).path('file0').position(10).bodyHTML('i disagree.').author(a => a.login('user1').avatarUrl('user1.jpg')).isMinimized(true), + ); + }).addReviewThread(t => { + t.addComment(c => + c.id(2).path('file1').position(20).bodyHTML('thanks for all the fish').author(a => a.login('dolphin').avatarUrl('pic-of-dolphin')), + ); + t.addComment(c => + c.id(3).path('file1').position(20).bodyHTML('shhhh').state('PENDING'), + ); + }).addReviewThread(t => { + t.thread(t0 => t0.isResolved(true)); + t.addComment(); + return t; + }) + .build(); + + let wrapper, openIssueish, resolveThread, unresolveThread, addSingleComment; + + beforeEach(function() { + openIssueish = sinon.spy(); + resolveThread = sinon.spy(); + unresolveThread = sinon.spy(); + addSingleComment = sinon.stub().returns(new Promise((resolve, reject) => {})); + + const commentTranslations = new Map(); + commentThreads.forEach(thread => { + const rootComment = thread.comments[0]; + const diffToFilePosition = new Map(); + diffToFilePosition.set(rootComment.position, rootComment.position); + commentTranslations.set(rootComment.path, {diffToFilePosition}); + }); + + wrapper = shallow(buildApp({openIssueish, summaries, commentThreads, resolveThread, unresolveThread, addSingleComment, commentTranslations})); + }); + + it('renders threads with comments', function() { + const threads = wrapper.find('details.github-Review'); + assert.lengthOf(threads, 3); + assert.lengthOf(threads.at(0).find('.github-Review-comment'), 2); + assert.lengthOf(threads.at(1).find('.github-Review-comment'), 2); + assert.lengthOf(threads.at(2).find('.github-Review-comment'), 1); + }); + + it('hides minimized comment content', function() { + const thread = wrapper.find('details.github-Review').at(0); + const comment = thread.find('.github-Review-comment--hidden'); + assert.strictEqual(comment.find('em').text(), 'This comment was hidden'); + }); + + describe('each thread', function() { + it('displays correct data', function() { + const thread = wrapper.find('details.github-Review').at(0); + assert.strictEqual(thread.find('.github-Review-path').text(), 'dir'); + assert.strictEqual(thread.find('.github-Review-file').text(), `${path.sep}file0`); + + assert.strictEqual(thread.find('.github-Review-lineNr').text(), '10'); + }); + + it('displays a resolve button for unresolved threads', function() { + const thread = wrapper.find('details.github-Review').at(0); + const button = thread.find('.github-Review-resolveButton'); + assert.strictEqual(button.text(), 'Resolve conversation'); + + assert.isFalse(resolveThread.called); + button.simulate('click'); + assert.isTrue(resolveThread.called); + }); + + it('displays an unresolve button for resolved threads', function() { + const thread = wrapper.find('details.github-Review').at(2); + + const button = thread.find('.github-Review-resolveButton'); + assert.strictEqual(button.text(), 'Unresolve conversation'); + + assert.isFalse(unresolveThread.called); + button.simulate('click'); + assert.isTrue(unresolveThread.called); + }); + + it('displays a pending badge when the comment is part of a pending review', function() { + const thread = wrapper.find('details.github-Review').at(1); + + const comment0 = thread.find('.github-Review-comment').at(0); + assert.isFalse(comment0.hasClass('github-Review-comment--pending')); + assert.isFalse(comment0.exists('.github-Review-pendingBadge')); + + const comment1 = thread.find('.github-Review-comment').at(1); + assert.isTrue(comment1.hasClass('github-Review-comment--pending')); + assert.isTrue(comment1.exists('.github-Review-pendingBadge')); + }); + + it('omits the / when there is no directory', function() { + const thread = wrapper.find('details.github-Review').at(1); + assert.isFalse(thread.exists('.github-Review-path')); + assert.strictEqual(thread.find('.github-Review-file').text(), 'file1'); + }); + + it('renders a PatchPreviewView per comment thread', function() { + assert.isTrue(wrapper.find('details.github-Review').everyWhere(thread => thread.find('PatchPreviewView').length === 1)); + assert.include(wrapper.find('PatchPreviewView').at(0).props(), { + fileName: path.join('dir/file0'), + diffRow: 10, + maxRowCount: 4, + }); + }); + + describe('navigation buttons', function() { + it('a pair of "Open Diff" and "Jump To File" buttons per thread', function() { + assert.isTrue(wrapper.find('details.github-Review').everyWhere(thread => + thread.find('.github-Review-navButton.icon-code').length === 1 && + thread.find('.github-Review-navButton.icon-diff').length === 1, + )); + }); + + describe('when PR is checked out', function() { + let openFile, openDiff; + + beforeEach(function() { + openFile = sinon.spy(); + openDiff = sinon.spy(); + wrapper = shallow(buildApp({openFile, openDiff, summaries, commentThreads})); + }); + + it('calls openDiff with correct params when "Open Diff" is clicked', function() { + wrapper.find('details.github-Review').at(0).find('.icon-diff').simulate('click', {currentTarget: {dataset: {path: 'dir/file0', line: 10}}}); + assert(openDiff.calledWith('dir/file0', 10)); + }); + + it('calls openFile with correct params when when "Jump To File" is clicked', function() { + wrapper.find('details.github-Review').at(0).find('.icon-code').simulate('click', { + currentTarget: {dataset: {path: 'dir/file0', line: 10}}, + }); + assert.isTrue(openFile.calledWith('dir/file0', 10)); + }); + }); + + describe('when PR is not checked out', function() { + let openFile, openDiff; + + beforeEach(function() { + openFile = sinon.spy(); + openDiff = sinon.spy(); + const checkoutOp = new EnableableOperation(() => {}); + wrapper = shallow(buildApp({openFile, openDiff, checkoutOp, summaries, commentThreads})); + }); + + it('"Jump To File" button is disabled', function() { + assert.isTrue(wrapper.find('button.icon-code').everyWhere(button => button.prop('disabled') === true)); + }); + + it('does not calls openFile when when "Jump To File" is clicked', function() { + wrapper.find('details.github-Review').at(0).find('.icon-code').simulate('click', {currentTarget: {dataset: {path: 'dir/file0', line: 10}}}); + assert.isFalse(openFile.called); + }); + + it('"Open Diff" still works', function() { + wrapper.find('details.github-Review').at(0).find('.icon-diff').simulate('click', {currentTarget: {dataset: {path: 'dir/file0', line: 10}}}); + assert(openDiff.calledWith('dir/file0', 10)); + }); + }); + }); + }); + + it('each comment displays correct data', function() { + const comment = wrapper.find('.github-Review-comment').at(0); + assert.strictEqual(comment.find('.github-Review-avatar').prop('src'), 'user0.jpg'); + assert.strictEqual(comment.find('.github-Review-avatar').prop('alt'), 'user0'); + assert.strictEqual(comment.find('.github-Review-username').prop('href'), 'https://github.com/user0'); + assert.strictEqual(comment.find('.github-Review-username').text(), 'user0'); + assert.strictEqual(comment.find('GithubDotcomMarkdown').prop('html'), 'i have opinions.'); + }); + + it('each comment displays reply button', function() { + const submitSpy = sinon.spy(wrapper.instance(), 'submitReply'); + const buttons = wrapper.find('.github-Review-replyButton'); + assert.lengthOf(buttons, 3); + const button = buttons.at(0); + assert.strictEqual(button.text(), 'Comment'); + button.simulate('click'); + const submitArgs = submitSpy.lastCall.args; + assert.strictEqual(submitArgs[1].id, 'abcd'); + assert.strictEqual(submitArgs[2].bodyHTML, 'i disagree.'); + + + const addSingleCommentArgs = addSingleComment.lastCall.args; + assert.strictEqual(addSingleCommentArgs[1], 'abcd'); + assert.strictEqual(addSingleCommentArgs[2], 1); + }); + + it('registers a github:submit-comment command that submits the focused reply comment', async function() { + addSingleComment.resolves(); + const command = wrapper.find('Command[command="github:submit-comment"]'); + + const evt = { + currentTarget: { + dataset: {threadId: 'abcd'}, + }, + }; + + const miniEditor = { + getText: () => 'content', + setText: () => {}, + }; + wrapper.instance().replyHolders.get('abcd').setter(miniEditor); + + await command.prop('callback')(evt); + + assert.isTrue(addSingleComment.calledWith( + 'content', 'abcd', 1, 'file0', 10, {didSubmitComment: sinon.match.func, didFailComment: sinon.match.func}, + )); + }); + + it('handles issueish link clicks on comment bodies', function() { + const comment = wrapper.find('.github-Review-comment').at(2); + + comment.find('GithubDotcomMarkdown').prop('switchToIssueish')('aaa', 'bbb', 100); + assert.isTrue(openIssueish.calledWith('aaa', 'bbb', 100)); + + comment.find('GithubDotcomMarkdown').prop('openIssueishLinkInNewTab')({ + target: {dataset: {url: 'https://github.com/ccc/ddd/pulls/1'}}, + }); + assert.isTrue(openIssueish.calledWith('ccc', 'ddd', 1)); + }); + + it('renders progress bar', function() { + assert.isTrue(wrapper.find('.github-Reviews-progress').exists()); + assert.strictEqual(wrapper.find('.github-Reviews-count').text(), 'Resolved 1 of 3'); + assert.include(wrapper.find('progress.github-Reviews-progessBar').props(), {value: 1, max: 3}); + }); + }); +}); diff --git a/test/watch-workspace-item.test.js b/test/watch-workspace-item.test.js index 0d63a4c95d..fd32509a2f 100644 --- a/test/watch-workspace-item.test.js +++ b/test/watch-workspace-item.test.js @@ -1,6 +1,8 @@ import {watchWorkspaceItem} from '../lib/watch-workspace-item'; import URIPattern from '../lib/atom/uri-pattern'; +import {registerGitHubOpener} from './helpers'; + describe('watchWorkspaceItem', function() { let sub, atomEnv, workspace, component; @@ -13,22 +15,7 @@ describe('watchWorkspaceItem', function() { setState: sinon.stub().callsFake((updater, cb) => cb && cb()), }; - workspace.addOpener(uri => { - if (uri.startsWith('atom-github://')) { - return { - getURI() { return uri; }, - - getElement() { - if (!this.element) { - this.element = document.createElement('div'); - } - return this.element; - }, - }; - } else { - return undefined; - } - }); + registerGitHubOpener(atomEnv); }); afterEach(function() {