@@ -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{|
@@ -40,7 +44,15 @@ export type issueishListController_results = $ReadOnlyArray<{|
*/
-const node/*: ReaderFragment*/ = {
+const node/*: ReaderFragment*/ = (function(){
+var v0 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "login",
+ "args": null,
+ "storageKey": null
+};
+return {
"kind": "Fragment",
"name": "issueishListController_results",
"type": "PullRequest",
@@ -79,13 +91,7 @@ const node/*: ReaderFragment*/ = {
"concreteType": null,
"plural": false,
"selections": [
- {
- "kind": "ScalarField",
- "alias": null,
- "name": "login",
- "args": null,
- "storageKey": null
- },
+ (v0/*: any*/),
{
"kind": "ScalarField",
"alias": null,
@@ -124,6 +130,25 @@ const node/*: ReaderFragment*/ = {
"name": "id",
"args": null,
"storageKey": null
+ },
+ {
+ "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": [
+ (v0/*: any*/)
+ ]
}
]
},
@@ -198,6 +223,7 @@ const node/*: ReaderFragment*/ = {
}
]
};
+})();
// prettier-ignore
-(node/*: any*/).hash = '5de45944d2555aea09c239d314a9fefd';
+(node/*: any*/).hash = '5cd992367edc948370db9a3182449e06';
module.exports = node;
diff --git a/lib/controllers/__generated__/prCheckoutController_pullRequest.graphql.js b/lib/controllers/__generated__/prCheckoutController_pullRequest.graphql.js
new file mode 100644
index 0000000000..390df307f8
--- /dev/null
+++ b/lib/controllers/__generated__/prCheckoutController_pullRequest.graphql.js
@@ -0,0 +1,104 @@
+/**
+ * @flow
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ReaderFragment } from 'relay-runtime';
+import type { FragmentReference } from "relay-runtime";
+declare export opaque type prCheckoutController_pullRequest$ref: FragmentReference;
+export type prCheckoutController_pullRequest = {|
+ +number: number,
+ +headRefName: string,
+ +headRepository: ?{|
+ +name: string,
+ +url: any,
+ +sshUrl: any,
+ +owner: {|
+ +login: string
+ |},
+ |},
+ +$refType: prCheckoutController_pullRequest$ref,
+|};
+*/
+
+
+const node/*: ReaderFragment*/ = {
+ "kind": "Fragment",
+ "name": "prCheckoutController_pullRequest",
+ "type": "PullRequest",
+ "metadata": null,
+ "argumentDefinitions": [],
+ "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
+ },
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "url",
+ "args": null,
+ "storageKey": null
+ },
+ {
+ "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": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "login",
+ "args": null,
+ "storageKey": null
+ }
+ ]
+ }
+ ]
+ }
+ ]
+};
+// prettier-ignore
+(node/*: any*/).hash = '66e001f389a2c4f74c1369cf69b31268';
+module.exports = node;
diff --git a/lib/controllers/__generated__/prCheckoutController_repository.graphql.js b/lib/controllers/__generated__/prCheckoutController_repository.graphql.js
new file mode 100644
index 0000000000..7a392c3595
--- /dev/null
+++ b/lib/controllers/__generated__/prCheckoutController_repository.graphql.js
@@ -0,0 +1,59 @@
+/**
+ * @flow
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ReaderFragment } from 'relay-runtime';
+import type { FragmentReference } from "relay-runtime";
+declare export opaque type prCheckoutController_repository$ref: FragmentReference;
+export type prCheckoutController_repository = {|
+ +name: string,
+ +owner: {|
+ +login: string
+ |},
+ +$refType: prCheckoutController_repository$ref,
+|};
+*/
+
+
+const node/*: ReaderFragment*/ = {
+ "kind": "Fragment",
+ "name": "prCheckoutController_repository",
+ "type": "Repository",
+ "metadata": null,
+ "argumentDefinitions": [],
+ "selections": [
+ {
+ "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
+ }
+ ]
+ }
+ ]
+};
+// prettier-ignore
+(node/*: any*/).hash = 'b2212745240c03ff8fc7cb13dfc63183';
+module.exports = node;
diff --git a/lib/controllers/__generated__/reviewsController_pullRequest.graphql.js b/lib/controllers/__generated__/reviewsController_pullRequest.graphql.js
new file mode 100644
index 0000000000..afd33e3b2c
--- /dev/null
+++ b/lib/controllers/__generated__/reviewsController_pullRequest.graphql.js
@@ -0,0 +1,45 @@
+/**
+ * @flow
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ReaderFragment } from 'relay-runtime';
+type prCheckoutController_pullRequest$ref = any;
+import type { FragmentReference } from "relay-runtime";
+declare export opaque type reviewsController_pullRequest$ref: FragmentReference;
+export type reviewsController_pullRequest = {|
+ +id: string,
+ +$fragmentRefs: prCheckoutController_pullRequest$ref,
+ +$refType: reviewsController_pullRequest$ref,
+|};
+*/
+
+
+const node/*: ReaderFragment*/ = {
+ "kind": "Fragment",
+ "name": "reviewsController_pullRequest",
+ "type": "PullRequest",
+ "metadata": null,
+ "argumentDefinitions": [],
+ "selections": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "id",
+ "args": null,
+ "storageKey": null
+ },
+ {
+ "kind": "FragmentSpread",
+ "name": "prCheckoutController_pullRequest",
+ "args": null
+ }
+ ]
+};
+// prettier-ignore
+(node/*: any*/).hash = '9d67f9908ab4ed776af5f1ee14f61ccb';
+module.exports = node;
diff --git a/lib/controllers/__generated__/reviewsController_repository.graphql.js b/lib/controllers/__generated__/reviewsController_repository.graphql.js
new file mode 100644
index 0000000000..caedf48ffa
--- /dev/null
+++ b/lib/controllers/__generated__/reviewsController_repository.graphql.js
@@ -0,0 +1,37 @@
+/**
+ * @flow
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ReaderFragment } from 'relay-runtime';
+type prCheckoutController_repository$ref = any;
+import type { FragmentReference } from "relay-runtime";
+declare export opaque type reviewsController_repository$ref: FragmentReference;
+export type reviewsController_repository = {|
+ +$fragmentRefs: prCheckoutController_repository$ref,
+ +$refType: reviewsController_repository$ref,
+|};
+*/
+
+
+const node/*: ReaderFragment*/ = {
+ "kind": "Fragment",
+ "name": "reviewsController_repository",
+ "type": "Repository",
+ "metadata": null,
+ "argumentDefinitions": [],
+ "selections": [
+ {
+ "kind": "FragmentSpread",
+ "name": "prCheckoutController_repository",
+ "args": null
+ }
+ ]
+};
+// prettier-ignore
+(node/*: any*/).hash = '1e0016aed6db6035651ff6213eb38ff6';
+module.exports = node;
diff --git a/lib/controllers/__generated__/reviewsController_viewer.graphql.js b/lib/controllers/__generated__/reviewsController_viewer.graphql.js
new file mode 100644
index 0000000000..9385de5b4e
--- /dev/null
+++ b/lib/controllers/__generated__/reviewsController_viewer.graphql.js
@@ -0,0 +1,54 @@
+/**
+ * @flow
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ReaderFragment } from 'relay-runtime';
+import type { FragmentReference } from "relay-runtime";
+declare export opaque type reviewsController_viewer$ref: FragmentReference;
+export type reviewsController_viewer = {|
+ +id: string,
+ +login: string,
+ +avatarUrl: any,
+ +$refType: reviewsController_viewer$ref,
+|};
+*/
+
+
+const node/*: ReaderFragment*/ = {
+ "kind": "Fragment",
+ "name": "reviewsController_viewer",
+ "type": "User",
+ "metadata": null,
+ "argumentDefinitions": [],
+ "selections": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "id",
+ "args": null,
+ "storageKey": null
+ },
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "login",
+ "args": null,
+ "storageKey": null
+ },
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "avatarUrl",
+ "args": null,
+ "storageKey": null
+ }
+ ]
+};
+// prettier-ignore
+(node/*: any*/).hash = 'e9e4cf88f2d8a809620a0f225d502896';
+module.exports = node;
diff --git a/lib/controllers/comment-decorations-controller.js b/lib/controllers/comment-decorations-controller.js
new file mode 100644
index 0000000000..ffda517e22
--- /dev/null
+++ b/lib/controllers/comment-decorations-controller.js
@@ -0,0 +1,252 @@
+import React, {Fragment} from 'react';
+import PropTypes from 'prop-types';
+import {CompositeDisposable} from 'event-kit';
+import {graphql, createFragmentContainer} from 'react-relay';
+import path from 'path';
+
+import EditorCommentDecorationsController from './editor-comment-decorations-controller';
+import ReviewsItem from '../items/reviews-item';
+import Gutter from '../atom/gutter';
+import Commands, {Command} from '../atom/commands';
+import {EndpointPropType, BranchSetPropType, RemoteSetPropType, RemotePropType} from '../prop-types';
+import {toNativePathSep} from '../helpers';
+
+export class BareCommentDecorationsController extends React.Component {
+ static propTypes = {
+ // Relay response
+ relay: PropTypes.object.isRequired,
+ pullRequests: PropTypes.arrayOf(PropTypes.shape({
+ number: PropTypes.number.isRequired,
+ headRefName: PropTypes.string.isRequired,
+ headRefOid: PropTypes.string.isRequired,
+ headRepository: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ owner: PropTypes.shape({
+ login: PropTypes.string.isRequired,
+ }).isRequired,
+ }),
+ repository: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ owner: PropTypes.shape({
+ login: PropTypes.string.isRequired,
+ }).isRequired,
+ }).isRequired,
+ })),
+
+ // Connection information
+ endpoint: EndpointPropType.isRequired,
+ owner: PropTypes.string.isRequired,
+ repo: PropTypes.string.isRequired,
+
+ // Atom environment
+ workspace: PropTypes.object.isRequired,
+ commands: PropTypes.object.isRequired,
+
+ // Models
+ repoData: PropTypes.shape({
+ branches: BranchSetPropType.isRequired,
+ remotes: RemoteSetPropType.isRequired,
+ currentRemote: RemotePropType.isRequired,
+ workingDirectoryPath: PropTypes.string.isRequired,
+ }).isRequired,
+ commentThreads: PropTypes.arrayOf(PropTypes.shape({
+ comments: PropTypes.arrayOf(PropTypes.object).isRequired,
+ thread: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ }).isRequired,
+ })).isRequired,
+ commentTranslations: PropTypes.shape({
+ get: PropTypes.func.isRequired,
+ }).isRequired,
+ };
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.subscriptions = new CompositeDisposable();
+ this.state = {openEditors: this.props.workspace.getTextEditors()};
+ }
+
+ componentDidMount() {
+ this.subscriptions.add(
+ this.props.workspace.observeTextEditors(this.updateOpenEditors),
+ this.props.workspace.onDidDestroyPaneItem(this.updateOpenEditors),
+ );
+ }
+
+ componentWillUnmount() {
+ this.subscriptions.dispose();
+ }
+
+ render() {
+ if (this.props.pullRequests.length === 0) {
+ return null;
+ }
+ const pullRequest = this.props.pullRequests[0];
+
+ // only show comment decorations if we're on a checked out pull request
+ // otherwise, we'd have no way of knowing which comments to show.
+ if (
+ !this.isCheckedOutPullRequest(
+ this.props.repoData.branches,
+ this.props.repoData.remotes,
+ pullRequest,
+ )
+ ) {
+ return null;
+ }
+
+ const threadDataByPath = new Map();
+ const workdirPath = this.props.repoData.workingDirectoryPath;
+
+ for (const {comments, thread} of this.props.commentThreads) {
+ // Skip comment threads that are entirely minimized.
+ if (comments.every(comment => 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 (
+
+
+ this.openReviewThread(this.props.threadId)} />
+
+
+ );
+ }
+
+ async openReviewThread(threadId) {
+ const uri = ReviewsItem.buildURI({
+ host: this.props.endpoint.getHost(),
+ owner: this.props.owner,
+ repo: this.props.repo,
+ number: this.props.number,
+ workdir: this.props.workdir,
+ });
+ const reviewsItem = await this.props.workspace.open(uri, {searchAllPanes: true});
+ reviewsItem.jumpToThread(threadId);
+ addEvent('open-review-thread', {package: 'github', from: this.props.parent});
+ }
+
+}
diff --git a/lib/controllers/editor-comment-decorations-controller.js b/lib/controllers/editor-comment-decorations-controller.js
new file mode 100644
index 0000000000..917f6d7666
--- /dev/null
+++ b/lib/controllers/editor-comment-decorations-controller.js
@@ -0,0 +1,131 @@
+import React, {Fragment} from 'react';
+import PropTypes from 'prop-types';
+import {Range} from 'atom';
+
+import {EndpointPropType} from '../prop-types';
+import Marker from '../atom/marker';
+import Decoration from '../atom/decoration';
+import CommentGutterDecorationController from '../controllers/comment-gutter-decoration-controller';
+
+export default class EditorCommentDecorationsController extends React.Component {
+ static propTypes = {
+ endpoint: EndpointPropType.isRequired,
+ owner: PropTypes.string.isRequired,
+ repo: PropTypes.string.isRequired,
+ number: PropTypes.number.isRequired,
+ workdir: PropTypes.string.isRequired,
+
+ workspace: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ threadsForPath: PropTypes.arrayOf(PropTypes.shape({
+ rootCommentID: PropTypes.string.isRequired,
+ position: PropTypes.number,
+ threadID: PropTypes.string.isRequired,
+ })).isRequired,
+ commentTranslationsForPath: PropTypes.shape({
+ diffToFilePosition: PropTypes.shape({
+ get: PropTypes.func.isRequired,
+ }).isRequired,
+ fileTranslations: PropTypes.shape({
+ get: PropTypes.func.isRequired,
+ }),
+ digest: PropTypes.string,
+ }),
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.rangesByRootID = new Map();
+ }
+
+ shouldComponentUpdate(nextProps) {
+ return translationDigestFrom(this.props) !== translationDigestFrom(nextProps);
+ }
+
+ render() {
+ if (!this.props.commentTranslationsForPath) {
+ return null;
+ }
+
+ return this.props.threadsForPath.map(thread => {
+ const range = this.getRangeForThread(thread);
+ if (!range) {
+ return null;
+ }
+
+ return (
+
+ this.markerDidChange(thread.rootCommentID, evt)}>
+
+
+
+
+
+
+ );
+ });
+ }
+
+ markerDidChange(rootCommentID, {newRange}) {
+ this.rangesByRootID.set(rootCommentID, Range.fromObject(newRange));
+ }
+
+ getRangeForThread(thread) {
+ const translations = this.props.commentTranslationsForPath;
+
+ if (thread.position === null) {
+ this.rangesByRootID.delete(thread.rootCommentID);
+ return null;
+ }
+
+ let adjustedPosition = translations.diffToFilePosition.get(thread.position);
+ if (!adjustedPosition) {
+ this.rangesByRootID.delete(thread.rootCommentID);
+ return null;
+ }
+
+ if (translations.fileTranslations) {
+ adjustedPosition = translations.fileTranslations.get(adjustedPosition).newPosition;
+ if (!adjustedPosition) {
+ this.rangesByRootID.delete(thread.rootCommentID);
+ return null;
+ }
+ }
+
+ const editorRow = adjustedPosition - 1;
+
+ let localRange = this.rangesByRootID.get(thread.rootCommentID);
+ if (!localRange) {
+ localRange = Range.fromObject([[editorRow, 0], [editorRow, Infinity]]);
+ this.rangesByRootID.set(thread.rootCommentID, localRange);
+ }
+ return localRange;
+ }
+}
+
+function translationDigestFrom(props) {
+ const translations = props.commentTranslationsForPath;
+ return translations ? translations.digest : null;
+}
diff --git a/lib/controllers/emoji-reactions-controller.js b/lib/controllers/emoji-reactions-controller.js
new file mode 100644
index 0000000000..9adbab1d40
--- /dev/null
+++ b/lib/controllers/emoji-reactions-controller.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {createFragmentContainer, graphql} from 'react-relay';
+
+import EmojiReactionsView from '../views/emoji-reactions-view';
+import addReactionMutation from '../mutations/add-reaction';
+import removeReactionMutation from '../mutations/remove-reaction';
+
+export class BareEmojiReactionsController extends React.Component {
+ static propTypes = {
+ relay: PropTypes.shape({
+ environment: PropTypes.object.isRequired,
+ }).isRequired,
+ reactable: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ }).isRequired,
+
+ // Atom environment
+ tooltips: PropTypes.object.isRequired,
+
+ // Action methods
+ reportMutationErrors: PropTypes.func.isRequired,
+ }
+
+ render() {
+ return (
+
+ );
+ }
+
+ addReaction = async content => {
+ try {
+ await addReactionMutation(this.props.relay.environment, this.props.reactable.id, content);
+ } catch (err) {
+ this.props.reportMutationErrors('Unable to add reaction emoji', err);
+ }
+ };
+
+ removeReaction = async content => {
+ try {
+ await removeReactionMutation(this.props.relay.environment, this.props.reactable.id, content);
+ } catch (err) {
+ this.props.reportMutationErrors('Unable to remove reaction emoji', err);
+ }
+ };
+}
+
+export default createFragmentContainer(BareEmojiReactionsController, {
+ reactable: graphql`
+ fragment emojiReactionsController_reactable on Reactable {
+ id
+ ...emojiReactionsView_reactable
+ }
+ `,
+});
diff --git a/lib/controllers/github-tab-controller.js b/lib/controllers/github-tab-controller.js
index 153b723d28..1f66e132f0 100644
--- a/lib/controllers/github-tab-controller.js
+++ b/lib/controllers/github-tab-controller.js
@@ -15,7 +15,7 @@ export default class GitHubTabController extends React.Component {
loginModel: GithubLoginModelPropType.isRequired,
rootHolder: RefHolderPropType.isRequired,
- workingDirectory: PropTypes.string.isRequired,
+ workingDirectory: PropTypes.string,
allRemotes: RemoteSetPropType.isRequired,
branches: BranchSetPropType.isRequired,
selectedRemoteName: PropTypes.string,
diff --git a/lib/controllers/issueish-detail-controller.js b/lib/controllers/issueish-detail-controller.js
index c704a8ebed..ea77f455ff 100644
--- a/lib/controllers/issueish-detail-controller.js
+++ b/lib/controllers/issueish-detail-controller.js
@@ -5,16 +5,17 @@ import PropTypes from 'prop-types';
import {
BranchSetPropType, RemoteSetPropType, ItemTypePropType, EndpointPropType, RefHolderPropType,
} from '../prop-types';
-import {GitError} from '../git-shell-out-strategy';
-import EnableableOperation from '../models/enableable-operation';
-import PullRequestDetailView, {checkoutStates} from '../views/pr-detail-view';
import IssueDetailView from '../views/issue-detail-view';
import CommitDetailItem from '../items/commit-detail-item';
-import {incrementCounter, addEvent} from '../reporter-proxy';
+import ReviewsItem from '../items/reviews-item';
+import {addEvent} from '../reporter-proxy';
+import PullRequestCheckoutController from './pr-checkout-controller';
+import PullRequestDetailView from '../views/pr-detail-view';
export class BareIssueishDetailController extends React.Component {
static propTypes = {
// Relay response
+ relay: PropTypes.object.isRequired,
repository: PropTypes.shape({
name: PropTypes.string.isRequired,
owner: PropTypes.shape({
@@ -23,7 +24,6 @@ export class BareIssueishDetailController extends React.Component {
pullRequest: PropTypes.any,
issue: PropTypes.any,
}),
- issueishNumber: PropTypes.number.isRequired,
// Local Repository model properties
localRepository: PropTypes.object.isRequired,
@@ -35,6 +35,16 @@ export class BareIssueishDetailController extends React.Component {
isLoading: PropTypes.bool.isRequired,
isPresent: PropTypes.bool.isRequired,
workdirPath: PropTypes.string,
+ issueishNumber: PropTypes.number.isRequired,
+
+ // 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,
@@ -48,50 +58,21 @@ export class BareIssueishDetailController extends React.Component {
config: PropTypes.object.isRequired,
// Action methods
- fetch: PropTypes.func.isRequired,
- checkout: PropTypes.func.isRequired,
- pull: PropTypes.func.isRequired,
- addRemote: PropTypes.func.isRequired,
onTitleChange: PropTypes.func.isRequired,
switchToIssueish: PropTypes.func.isRequired,
destroy: PropTypes.func.isRequired,
+ reportMutationErrors: PropTypes.func.isRequired,
// Item context
itemType: ItemTypePropType.isRequired,
refEditor: RefHolderPropType.isRequired,
- }
-
- constructor(props) {
- super(props);
- this.state = {
- checkoutInProgress: false,
- typename: null,
- };
-
- this.checkoutOp = new EnableableOperation(
- () => this.checkout().catch(e => {
- if (!(e instanceof GitError)) {
- throw e;
- }
- }),
- );
- this.checkoutOp.toggleState(this, 'checkoutInProgress');
- }
-
- // storing `typename` in state to avoid having to do ugly long chained lookups in several places.
- // note that whether we're rendering an Issue or a PullRequest,
- // relay returns both issue and pull request data.
- // So a pullRequest can have a __typename of `Issue` or `PullRequest`, which is // then set in state here.
- static getDerivedStateFromProps(nextProps, prevState) {
- const {repository} = nextProps;
- const typename = repository && repository.pullRequest &&
- repository.pullRequest.__typename ? repository.pullRequest.__typename : null;
- if (typename && prevState.typename !== typename) {
- return ({typename});
- } else {
- return null;
- }
+ // For opening files changed tab
+ initChangedFilePath: PropTypes.string,
+ initChangedFilePosition: PropTypes.number,
+ selectedTab: PropTypes.number.isRequired,
+ onTabSelected: PropTypes.func.isRequired,
+ onOpenFilesTab: PropTypes.func.isRequired,
}
componentDidMount() {
@@ -106,7 +87,7 @@ export class BareIssueishDetailController extends React.Component {
const {repository} = this.props;
if (repository && (repository.issue || repository.pullRequest)) {
let prefix, issueish;
- if (this.state.typename === 'PullRequest') {
+ if (this.getTypename() === 'PullRequest') {
prefix = 'PR:';
issueish = repository.pullRequest;
} else {
@@ -124,31 +105,63 @@ export class BareIssueishDetailController extends React.Component {
return Issue/PR #{this.props.issueishNumber} not found
; // TODO: no PRs
}
- this.checkoutOp = this.nextCheckoutOp();
- if (this.state.typename === 'PullRequest') {
+ if (this.getTypename() === 'PullRequest') {
return (
-
+ localRepository={this.props.localRepository}
+ isAbsent={this.props.isAbsent}
+ isLoading={this.props.isLoading}
+ isPresent={this.props.isPresent}
+ isMerging={this.props.isMerging}
+ isRebasing={this.props.isRebasing}
+ branches={this.props.branches}
+ remotes={this.props.remotes}>
+
+ {checkoutOp => (
+
+ )}
+
+
);
} else {
return (
@@ -156,139 +169,52 @@ export class BareIssueishDetailController extends React.Component {
repository={repository}
issue={repository.issue}
switchToIssueish={this.props.switchToIssueish}
+ tooltips={this.props.tooltips}
+ reportMutationErrors={this.props.reportMutationErrors}
/>
);
}
}
- nextCheckoutOp() {
- const {repository} = this.props;
- const {pullRequest} = repository;
-
- if (this.state.typename !== 'PullRequest') {
- return this.checkoutOp.disable(checkoutStates.HIDDEN, 'Cannot check out an issue');
- }
-
- if (this.props.isAbsent) {
- return this.checkoutOp.disable(checkoutStates.HIDDEN, 'No repository found');
- }
-
- if (this.props.isLoading) {
- return this.checkoutOp.disable(checkoutStates.DISABLED, 'Loading');
- }
-
- if (!this.props.isPresent) {
- return this.checkoutOp.disable(checkoutStates.DISABLED, 'No repository found');
- }
-
- if (this.props.isMerging) {
- return this.checkoutOp.disable(checkoutStates.DISABLED, 'Merge in progress');
- }
-
- if (this.props.isRebasing) {
- return this.checkoutOp.disable(checkoutStates.DISABLED, 'Rebase in progress');
- }
-
- if (this.state.checkoutInProgress) {
- return this.checkoutOp.disable(checkoutStates.DISABLED, 'Checking out...');
- }
-
- // determine if pullRequest.headRepository is null
- // this can happen if a repository has been deleted.
- if (!pullRequest.headRepository) {
- return this.checkoutOp.disable(checkoutStates.DISABLED, 'Pull request head repository does not exist');
- }
-
- // Determine if we already have this PR checked out.
-
- const headPush = this.props.branches.getHeadBranch().getPush();
- const headRemote = this.props.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 this.checkoutOp.disable(checkoutStates.CURRENT, 'Current');
+ openCommit = async ({sha}) => {
+ /* istanbul ignore if */
+ if (!this.props.workdirPath) {
+ return;
}
- return this.checkoutOp.enable();
+ const uri = CommitDetailItem.buildURI(this.props.workdirPath, sha);
+ await this.props.workspace.open(uri, {pending: true});
+ addEvent('open-commit-in-pane', {package: 'github', from: this.constructor.name});
}
- async checkout() {
- const {repository} = this.props;
- const {pullRequest} = repository;
- const {headRepository} = pullRequest;
-
- const fullHeadRef = `refs/heads/${pullRequest.headRefName}`;
-
- let sourceRemoteName, localRefName;
-
- // Discover or create a remote pointing to the repo containing the pull request's head ref.
- // If the local repository already has the head repository specified as a remote, that remote will be used, so
- // that any related configuration is picked up for the fetch. Otherwise, the head repository fetch URL is used
- // directly.
- const headRemotes = this.props.remotes.matchingGitHubRepository(headRepository.owner.login, headRepository.name);
- if (headRemotes.length > 0) {
- sourceRemoteName = headRemotes[0].getName();
- } else {
- const url = {
- https: headRepository.url + '.git',
- ssh: headRepository.sshUrl,
- }[this.props.remotes.mostUsedProtocol(['https', 'ssh'])];
-
- // This will throw if a remote with this name already exists (and points somewhere else, or we would have found
- // it above). ¯\_(ツ)_/¯
- const remote = await this.props.addRemote(headRepository.owner.login, url);
- sourceRemoteName = remote.getName();
- }
-
- // Identify an existing local ref that already corresponds to the pull request, if one exists. Otherwise, generate
- // a new local ref name.
- const pullTargets = this.props.branches.getPullTargets(sourceRemoteName, fullHeadRef);
- if (pullTargets.length > 0) {
- localRefName = pullTargets[0].getName();
-
- // Check out the existing local ref.
- await this.props.checkout(localRefName);
- try {
- await this.props.pull(fullHeadRef, {remoteName: sourceRemoteName, ffOnly: true});
- } finally {
- incrementCounter('checkout-pr');
- }
-
+ openReviews = async () => {
+ /* istanbul ignore if */
+ if (this.getTypename() !== 'PullRequest') {
return;
}
- await this.props.fetch(fullHeadRef, {remoteName: sourceRemoteName});
-
- // Check out the local ref and set it up to track the head ref.
- await this.props.checkout(`pr-${pullRequest.number}/${headRepository.owner.login}/${pullRequest.headRefName}`, {
- createNew: true,
- track: true,
- startPoint: `refs/remotes/${sourceRemoteName}/${pullRequest.headRefName}`,
+ const uri = ReviewsItem.buildURI({
+ host: this.props.endpoint.getHost(),
+ owner: this.props.repository.owner.login,
+ repo: this.props.repository.name,
+ number: this.props.issueishNumber,
+ workdir: this.props.workdirPath,
});
-
- incrementCounter('checkout-pr');
+ await this.props.workspace.open(uri);
+ addEvent('open-reviews-tab', {package: 'github', from: this.constructor.name});
}
- openCommit = async ({sha}) => {
+ getTypename() {
+ const {repository} = this.props;
/* istanbul ignore if */
- if (!this.props.workdirPath) {
- return;
+ if (!repository) {
+ return null;
}
-
- const uri = CommitDetailItem.buildURI(this.props.workdirPath, sha);
- await this.props.workspace.open(uri, {pending: true});
- addEvent('open-commit-in-pane', {package: 'github', from: this.constructor.name});
+ /* istanbul ignore if */
+ if (!repository.pullRequest) {
+ return null;
+ }
+ return repository.pullRequest.__typename;
}
}
@@ -297,16 +223,13 @@ export default createFragmentContainer(BareIssueishDetailController, {
fragment issueishDetailController_repository on Repository
@argumentDefinitions(
issueishNumber: {type: "Int!"}
- 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"}
) {
...issueDetailView_repository
+ ...prCheckoutController_repository
...prDetailView_repository
name
owner {
@@ -328,24 +251,18 @@ export default createFragmentContainer(BareIssueishDetailController, {
... on PullRequest {
title
number
- headRefName
- headRepository {
- name
- owner {
- login
- }
- url
- sshUrl
- }
+ ...prCheckoutController_pullRequest
...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
+ reviewCount: $reviewCount
+ reviewCursor: $reviewCursor
+ threadCount: $threadCount
+ threadCursor: $threadCursor
+ commentCount: $commentCount
+ commentCursor: $commentCursor
)
}
}
diff --git a/lib/controllers/issueish-list-controller.js b/lib/controllers/issueish-list-controller.js
index d4d96244cd..a004632a9f 100644
--- a/lib/controllers/issueish-list-controller.js
+++ b/lib/controllers/issueish-list-controller.js
@@ -1,9 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import {graphql, createFragmentContainer} from 'react-relay';
-
+import {EndpointPropType} from '../prop-types';
import IssueishListView from '../views/issueish-list-view';
import Issueish from '../models/issueish';
+import ReviewsItem from '../items/reviews-item';
+import {addEvent} from '../reporter-proxy';
const StatePropType = PropTypes.oneOf(['EXPECTED', 'PENDING', 'SUCCESS', 'ERROR', 'FAILURE']);
@@ -22,6 +24,10 @@ export class BareIssueishListController extends React.Component {
headRefName: PropTypes.string.isRequired,
repository: PropTypes.shape({
id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ owner: PropTypes.shape({
+ login: PropTypes.string.isRequired,
+ }).isRequired,
}).isRequired,
commits: PropTypes.shape({
nodes: PropTypes.arrayOf(PropTypes.shape({
@@ -48,6 +54,10 @@ export class BareIssueishListController extends React.Component {
onOpenMore: PropTypes.func,
emptyComponent: PropTypes.func,
+ workspace: PropTypes.object,
+ endpoint: EndpointPropType,
+ workingDirectory: PropTypes.string,
+ needReviewsButton: PropTypes.bool,
};
static defaultProps = {
@@ -80,6 +90,23 @@ export class BareIssueishListController extends React.Component {
return null;
}
+ openReviews = async () => {
+ /* istanbul ignore next */
+ if (this.props.results.length < 1) {
+ return;
+ }
+ const result = this.props.results[0];
+ const uri = ReviewsItem.buildURI({
+ host: this.props.endpoint.getHost(),
+ owner: result.repository.owner.login,
+ repo: result.repository.name,
+ number: result.number,
+ workdir: this.props.workingDirectory,
+ });
+ await this.props.workspace.open(uri);
+ addEvent('open-reviews-tab', {package: 'github', from: this.constructor.name});
+ }
+
render() {
return (
);
@@ -115,6 +142,10 @@ export default createFragmentContainer(BareIssueishListController, {
repository {
id
+ name
+ owner {
+ login
+ }
}
commits(last:1) {
diff --git a/lib/controllers/issueish-searches-controller.js b/lib/controllers/issueish-searches-controller.js
index 65b07b7f42..25c8dd48cc 100644
--- a/lib/controllers/issueish-searches-controller.js
+++ b/lib/controllers/issueish-searches-controller.js
@@ -2,7 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import {shell} from 'electron';
-import {autobind} from '../helpers';
import {
RemotePropType, RemoteSetPropType, BranchSetPropType, OperationStateObserverPropType, EndpointPropType,
} from '../prop-types';
@@ -32,7 +31,7 @@ export default class IssueishSearchesController extends React.Component {
// Repository model attributes
remoteOperationObserver: OperationStateObserverPropType.isRequired,
- workingDirectory: PropTypes.string.isRequired,
+ workingDirectory: PropTypes.string,
remote: RemotePropType.isRequired,
remotes: RemoteSetPropType.isRequired,
branches: BranchSetPropType.isRequired,
@@ -43,12 +42,7 @@ export default class IssueishSearchesController extends React.Component {
onCreatePr: PropTypes.func.isRequired,
}
- constructor(props) {
- super(props);
- autobind(this, 'onOpenIssueish', 'onOpenSearch');
-
- this.state = {};
- }
+ state = {};
static getDerivedStateFromProps(props) {
return {
@@ -71,7 +65,8 @@ export default class IssueishSearchesController extends React.Component {
branches={this.props.branches}
aheadCount={this.props.aheadCount}
pushInProgress={this.props.pushInProgress}
-
+ workspace={this.props.workspace}
+ workingDirectory={this.props.workingDirectory}
onOpenIssueish={this.onOpenIssueish}
onCreatePr={this.props.onCreatePr}
/>
@@ -92,22 +87,22 @@ export default class IssueishSearchesController extends React.Component {
);
}
- onOpenIssueish(issueish) {
+ onOpenIssueish = issueish => {
return this.props.workspace.open(
- IssueishDetailItem.buildURI(
- this.props.endpoint.getHost(),
- this.props.remote.getOwner(),
- this.props.remote.getRepo(),
- issueish.getNumber(),
- this.props.workingDirectory,
- ),
+ IssueishDetailItem.buildURI({
+ host: this.props.endpoint.getHost(),
+ owner: this.props.remote.getOwner(),
+ repo: this.props.remote.getRepo(),
+ number: issueish.getNumber(),
+ workdir: this.props.workingDirectory,
+ }),
{pending: true, searchAllPanes: true},
).then(() => {
addEvent('open-issueish-in-pane', {package: 'github', from: 'issueish-list'});
});
}
- onOpenSearch(search) {
+ onOpenSearch = search => {
const searchURL = search.getWebURL(this.props.remote);
return new Promise((resolve, reject) => {
diff --git a/lib/controllers/multi-file-patch-controller.js b/lib/controllers/multi-file-patch-controller.js
index 449747a3d0..16b52cc1d9 100644
--- a/lib/controllers/multi-file-patch-controller.js
+++ b/lib/controllers/multi-file-patch-controller.js
@@ -15,6 +15,12 @@ export default class MultiFilePatchController extends React.Component {
multiFilePatch: MultiFilePatchPropType.isRequired,
hasUndoHistory: PropTypes.bool,
+ reviewCommentsLoading: PropTypes.bool,
+ reviewCommentThreads: PropTypes.arrayOf(PropTypes.shape({
+ thread: PropTypes.object.isRequired,
+ comments: PropTypes.arrayOf(PropTypes.object).isRequired,
+ })),
+
workspace: PropTypes.object.isRequired,
commands: PropTypes.object.isRequired,
keymaps: PropTypes.object.isRequired,
@@ -25,7 +31,6 @@ export default class MultiFilePatchController extends React.Component {
discardLines: PropTypes.func,
undoLastDiscard: PropTypes.func,
surface: PropTypes.func,
-
switchToIssueish: PropTypes.func,
}
diff --git a/lib/controllers/pr-checkout-controller.js b/lib/controllers/pr-checkout-controller.js
new file mode 100644
index 0000000000..c9eb4436d0
--- /dev/null
+++ b/lib/controllers/pr-checkout-controller.js
@@ -0,0 +1,219 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {graphql, createFragmentContainer} from 'react-relay';
+
+import EnableableOperation from '../models/enableable-operation';
+import {GitError} from '../git-shell-out-strategy';
+import {RemoteSetPropType, BranchSetPropType} from '../prop-types';
+import {incrementCounter} from '../reporter-proxy';
+
+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 BarePullRequestCheckoutController extends React.Component {
+ static propTypes = {
+ // GraphQL response
+ repository: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ owner: PropTypes.shape({
+ login: PropTypes.string.isRequired,
+ }).isRequired,
+ }).isRequired,
+ pullRequest: PropTypes.shape({
+ number: PropTypes.number.isRequired,
+ headRefName: PropTypes.string.isRequired,
+ headRepository: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ url: PropTypes.string.isRequired,
+ sshUrl: PropTypes.string.isRequired,
+ owner: PropTypes.shape({
+ login: PropTypes.string.isRequired,
+ }),
+ }),
+ }).isRequired,
+
+ // Repository model and attributes
+ localRepository: PropTypes.object.isRequired,
+ isAbsent: PropTypes.bool.isRequired,
+ isLoading: PropTypes.bool.isRequired,
+ isPresent: PropTypes.bool.isRequired,
+ isMerging: PropTypes.bool.isRequired,
+ isRebasing: PropTypes.bool.isRequired,
+ branches: BranchSetPropType.isRequired,
+ remotes: RemoteSetPropType.isRequired,
+
+ children: PropTypes.func.isRequired,
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ checkoutInProgress: false,
+ };
+
+ this.checkoutOp = new EnableableOperation(
+ () => this.checkout().catch(e => {
+ if (!(e instanceof GitError)) {
+ throw e;
+ }
+ }),
+ );
+ this.checkoutOp.toggleState(this, 'checkoutInProgress');
+ }
+
+ render() {
+ return this.props.children(this.nextCheckoutOp());
+ }
+
+ nextCheckoutOp() {
+ const {repository, pullRequest} = this.props;
+
+ if (this.props.isAbsent) {
+ return this.checkoutOp.disable(checkoutStates.HIDDEN, 'No repository found');
+ }
+
+ if (this.props.isLoading) {
+ return this.checkoutOp.disable(checkoutStates.DISABLED, 'Loading');
+ }
+
+ if (!this.props.isPresent) {
+ return this.checkoutOp.disable(checkoutStates.DISABLED, 'No repository found');
+ }
+
+ if (this.props.isMerging) {
+ return this.checkoutOp.disable(checkoutStates.DISABLED, 'Merge in progress');
+ }
+
+ if (this.props.isRebasing) {
+ return this.checkoutOp.disable(checkoutStates.DISABLED, 'Rebase in progress');
+ }
+
+ if (this.state.checkoutInProgress) {
+ return this.checkoutOp.disable(checkoutStates.DISABLED, 'Checking out...');
+ }
+
+ // determine if pullRequest.headRepository is null
+ // this can happen if a repository has been deleted.
+ if (!pullRequest.headRepository) {
+ return this.checkoutOp.disable(checkoutStates.DISABLED, 'Pull request head repository does not exist');
+ }
+
+ // Determine if we already have this PR checked out.
+
+ const headPush = this.props.branches.getHeadBranch().getPush();
+ const headRemote = this.props.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 this.checkoutOp.disable(checkoutStates.CURRENT, 'Current');
+ }
+
+ return this.checkoutOp.enable();
+ }
+
+ async checkout() {
+ const {pullRequest} = this.props;
+ const {headRepository} = pullRequest;
+
+ const fullHeadRef = `refs/heads/${pullRequest.headRefName}`;
+
+ let sourceRemoteName, localRefName;
+
+ // Discover or create a remote pointing to the repo containing the pull request's head ref.
+ // If the local repository already has the head repository specified as a remote, that remote will be used, so
+ // that any related configuration is picked up for the fetch. Otherwise, the head repository fetch URL is used
+ // directly.
+ const headRemotes = this.props.remotes.matchingGitHubRepository(headRepository.owner.login, headRepository.name);
+ if (headRemotes.length > 0) {
+ sourceRemoteName = headRemotes[0].getName();
+ } else {
+ const url = {
+ https: headRepository.url + '.git',
+ ssh: headRepository.sshUrl,
+ }[this.props.remotes.mostUsedProtocol(['https', 'ssh'])];
+
+ // This will throw if a remote with this name already exists (and points somewhere else, or we would have found
+ // it above). ¯\_(ツ)_/¯
+ const remote = await this.props.localRepository.addRemote(headRepository.owner.login, url);
+ sourceRemoteName = remote.getName();
+ }
+
+ // Identify an existing local ref that already corresponds to the pull request, if one exists. Otherwise, generate
+ // a new local ref name.
+ const pullTargets = this.props.branches.getPullTargets(sourceRemoteName, fullHeadRef);
+ if (pullTargets.length > 0) {
+ localRefName = pullTargets[0].getName();
+
+ // Check out the existing local ref.
+ await this.props.localRepository.checkout(localRefName);
+ try {
+ await this.props.localRepository.pull(fullHeadRef, {remoteName: sourceRemoteName, ffOnly: true});
+ } finally {
+ incrementCounter('checkout-pr');
+ }
+
+ return;
+ }
+
+ await this.props.localRepository.fetch(fullHeadRef, {remoteName: sourceRemoteName});
+
+ // Check out the local ref and set it up to track the head ref.
+ await this.props.localRepository.checkout(
+ `pr-${pullRequest.number}/${headRepository.owner.login}/${pullRequest.headRefName}`,
+ {createNew: true, track: true, startPoint: `refs/remotes/${sourceRemoteName}/${pullRequest.headRefName}`,
+ });
+
+ incrementCounter('checkout-pr');
+ }
+}
+
+export default createFragmentContainer(BarePullRequestCheckoutController, {
+ repository: graphql`
+ fragment prCheckoutController_repository on Repository {
+ name
+ owner {
+ login
+ }
+ }
+ `,
+ pullRequest: graphql`
+ fragment prCheckoutController_pullRequest on PullRequest {
+ number
+ headRefName
+ headRepository {
+ name
+ url
+ sshUrl
+ owner {
+ login
+ }
+ }
+ }
+ `,
+});
diff --git a/lib/controllers/pr-reviews-controller.js b/lib/controllers/pr-reviews-controller.js
deleted file mode 100644
index fdaf09c434..0000000000
--- a/lib/controllers/pr-reviews-controller.js
+++ /dev/null
@@ -1,159 +0,0 @@
-import React, {Fragment} from 'react';
-import PropTypes from 'prop-types';
-import {RelayConnectionPropType} from '../prop-types';
-
-import PullRequestReviewCommentsContainer from '../containers/pr-review-comments-container';
-import PullRequestReviewCommentsView from '../views/pr-review-comments-view';
-import {PAGE_SIZE, PAGINATION_WAIT_TIME_MS} from '../helpers';
-
-export default class PullRequestReviewsController extends React.Component {
- static propTypes = {
- relay: PropTypes.shape({
- hasMore: PropTypes.func.isRequired,
- loadMore: PropTypes.func.isRequired,
- isLoading: PropTypes.func.isRequired,
- }).isRequired,
- pullRequest: PropTypes.shape({
- reviews: RelayConnectionPropType(
- PropTypes.object,
- ),
- }),
- getBufferRowForDiffPosition: PropTypes.func.isRequired,
- isPatchVisible: PropTypes.func.isRequired,
- }
-
- constructor(props) {
- super(props);
- this.state = {};
- this.reviewsById = new Map();
- }
-
- componentDidMount() {
- this._attemptToLoadMoreReviews();
- }
-
- _attemptToLoadMoreReviews = () => {
- if (!this.props.relay.hasMore()) {
- return;
- }
-
- if (this.props.relay.isLoading()) {
- setTimeout(this._loadMoreReviews, PAGINATION_WAIT_TIME_MS);
- } else {
- this._loadMoreReviews();
- }
- }
-
- accumulateReviews = error => {
- /* istanbul ignore if */
- if (error) {
- // eslint-disable-next-line no-console
- console.error(error);
- } else {
- this._attemptToLoadMoreReviews();
- }
- }
-
- _loadMoreReviews = () => {
- this.props.relay.loadMore(PAGE_SIZE, this.accumulateReviews);
- }
-
- render() {
- if (!this.props.pullRequest || !this.props.pullRequest.reviews) {
- return null;
- }
-
- const commentThreads = Object.keys(this.state).reverse().map(rootCommentId => {
- return {
- rootCommentId,
- comments: this.state[rootCommentId],
- };
- });
-
- /** Dealing with comment threading...
- *
- * Threads can have comments belonging to multiple reviews.
- * We need a nested pagination container to fetch comment pages.
- * Upon fetching new comments, the `collectComments` method is called with all comments fetched for that review.
- * Ultimately we want to group comments based on the root comment they are replies to.
- * `renderCommentFetchingContainers` only fetches data and doesn't render any user visible DOM elements.
- * `PullRequestReviewCommentsView` renders the aggregated comment thread data.
- * */
- return (
-
- {this.renderCommentFetchingContainers()}
-
-
- );
- }
-
- renderCommentFetchingContainers() {
- return this.props.pullRequest.reviews.edges.map(({node: review}) => {
- return (
-
- );
- });
- }
-
- collectComments = ({reviewId, submittedAt, comments, fetchingMoreComments}) => {
- this.reviewsById.set(reviewId, {submittedAt, comments, fetchingMoreComments});
- const stillFetchingReviews = this.props.relay.hasMore();
- if (!stillFetchingReviews) {
- const stillFetchingComments = [...this.reviewsById.values()].some(review => review.fetchingMoreComments);
- if (!stillFetchingComments) {
- this.groupCommentsByThread();
- }
- }
- }
-
- groupCommentsByThread() {
- // we have no guarantees that reviews will return in order so sort them by date.
- const sortedReviews = [...this.reviewsById.values()].sort(this.compareReviewsByDate);
-
- // react batches calls to setState and does not update state synchronously
- // therefore we need an intermediate state so we can do checks against keys
- // we have just added.
- const state = {};
- sortedReviews.forEach(({comments}) => {
- comments.edges.forEach(({node: comment}) => {
- if (!comment.replyTo) {
- state[comment.id] = [comment];
- } else {
- // Ran into this error when viewing files for https://github.com/numpy/numpy/pull/9998
- // for comment MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDE1MzA1NTUzMw,
- // who's replyTo comment does not exist.
- // Not sure how we'd get into this state -- tried replying to outdated,
- // hidden, deleted, and resolved comments but none of those conditions
- // got us here.
- // It may be that this only affects older pull requests, before something
- // changed with oudated comment behavior.
- // anyhow, do this check and move on with our lives.
- if (!state[comment.replyTo.id]) {
- state[comment.id] = [comment];
- } else {
- state[comment.replyTo.id].push(comment);
- }
- }
- });
- });
-
- this.setState(state);
- }
-
- // compare reviews by date ascending (in order to sort oldest to newest)
- compareReviewsByDate(reviewA, reviewB) {
- const dateA = new Date(reviewA.submittedAt);
- const dateB = new Date(reviewB.submittedAt);
- if (dateA > dateB) {
- return 1;
- } else if (dateB > dateA) {
- return -1;
- } else {
- return 0;
- }
- }
-}
diff --git a/lib/controllers/reaction-picker-controller.js b/lib/controllers/reaction-picker-controller.js
new file mode 100644
index 0000000000..e93f8392ea
--- /dev/null
+++ b/lib/controllers/reaction-picker-controller.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import ReactionPickerView from '../views/reaction-picker-view';
+import {RefHolderPropType} from '../prop-types';
+import {addEvent} from '../reporter-proxy';
+
+export default class ReactionPickerController extends React.Component {
+ static propTypes = {
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+
+ tooltipHolder: RefHolderPropType.isRequired,
+ }
+
+ render() {
+ return (
+
+ );
+ }
+
+ addReactionAndClose = async content => {
+ await this.props.addReaction(content);
+ addEvent('add-emoji-reaction', {package: 'github'});
+ this.props.tooltipHolder.map(tooltip => tooltip.dispose());
+ }
+
+ removeReactionAndClose = async content => {
+ await this.props.removeReaction(content);
+ addEvent('remove-emoji-reaction', {package: 'github'});
+ this.props.tooltipHolder.map(tooltip => tooltip.dispose());
+ }
+}
diff --git a/lib/controllers/remote-controller.js b/lib/controllers/remote-controller.js
index 8d896ccdfd..75097cd3d2 100644
--- a/lib/controllers/remote-controller.js
+++ b/lib/controllers/remote-controller.js
@@ -26,7 +26,7 @@ export default class RemoteController extends React.Component {
// Repository derived attributes
remoteOperationObserver: OperationStateObserverPropType.isRequired,
- workingDirectory: PropTypes.string.isRequired,
+ workingDirectory: PropTypes.string,
workspace: PropTypes.object.isRequired,
remote: RemotePropType.isRequired,
remotes: RemoteSetPropType.isRequired,
diff --git a/lib/controllers/reviews-controller.js b/lib/controllers/reviews-controller.js
new file mode 100644
index 0000000000..3c2255d4db
--- /dev/null
+++ b/lib/controllers/reviews-controller.js
@@ -0,0 +1,348 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {createFragmentContainer, graphql} from 'react-relay';
+
+import {RemoteSetPropType, BranchSetPropType, EndpointPropType, WorkdirContextPoolPropType} from '../prop-types';
+import ReviewsView from '../views/reviews-view';
+import PullRequestCheckoutController from '../controllers/pr-checkout-controller';
+import addReviewMutation from '../mutations/add-pr-review';
+import addReviewCommentMutation from '../mutations/add-pr-review-comment';
+import submitReviewMutation from '../mutations/submit-pr-review';
+import deleteReviewMutation from '../mutations/delete-pr-review';
+import resolveReviewThreadMutation from '../mutations/resolve-review-thread';
+import unresolveReviewThreadMutation from '../mutations/unresolve-review-thread';
+import IssueishDetailItem from '../items/issueish-detail-item';
+import {addEvent} from '../reporter-proxy';
+
+// Milliseconds to leave scrollToThreadID non-null before reverting.
+const FLASH_DELAY = 1500;
+
+export class BareReviewsController extends React.Component {
+ static propTypes = {
+ // Relay results
+ relay: PropTypes.shape({
+ environment: PropTypes.object.isRequired,
+ }).isRequired,
+ viewer: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ }).isRequired,
+ repository: PropTypes.object.isRequired,
+ pullRequest: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ }).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
+ workdirContextPool: WorkdirContextPoolPropType.isRequired,
+ localRepository: PropTypes.object.isRequired,
+ isAbsent: PropTypes.bool.isRequired,
+ isLoading: PropTypes.bool.isRequired,
+ isPresent: PropTypes.bool.isRequired,
+ isMerging: PropTypes.bool.isRequired,
+ isRebasing: PropTypes.bool.isRequired,
+ branches: BranchSetPropType.isRequired,
+ remotes: RemoteSetPropType.isRequired,
+ multiFilePatch: PropTypes.object.isRequired,
+ initThreadID: PropTypes.string,
+
+ // Connection properties
+ endpoint: EndpointPropType.isRequired,
+
+ // URL parameters
+ owner: PropTypes.string.isRequired,
+ repo: PropTypes.string.isRequired,
+ number: PropTypes.number.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
+ reportMutationErrors: PropTypes.func.isRequired,
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ contextLines: 4,
+ postingToThreadID: null,
+ scrollToThreadID: this.props.initThreadID,
+ summarySectionOpen: true,
+ commentSectionOpen: true,
+ threadIDsOpen: new Set(
+ this.props.initThreadID ? [this.props.initThreadID] : [],
+ ),
+ };
+ }
+
+ componentDidMount() {
+ const {scrollToThreadID} = this.state;
+ if (scrollToThreadID) {
+ setTimeout(() => this.setState({scrollToThreadID: null}), FLASH_DELAY);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {initThreadID} = this.props;
+ if (initThreadID && initThreadID !== prevProps.initThreadID) {
+ this.setState(prev => {
+ prev.threadIDsOpen.add(initThreadID);
+ return {commentSectionOpen: true, scrollToThreadID: initThreadID};
+ }, () => {
+ setTimeout(() => this.setState({scrollToThreadID: null}), FLASH_DELAY);
+ });
+ }
+ }
+
+ render() {
+ return (
+
+
+ {checkoutOp => (
+
+ )}
+
+
+ );
+ }
+
+ openFile = async (filePath, lineNumber) => {
+ await this.props.workspace.open(
+ filePath, {
+ initialLine: lineNumber - 1,
+ initialColumn: 0,
+ pending: true,
+ });
+ addEvent('reviews-dock-open-file', {package: 'github'});
+ }
+
+ openDiff = async (filePath, lineNumber) => {
+ const item = await this.getPRDetailItem();
+ item.openFilesTab({
+ changedFilePath: filePath,
+ changedFilePosition: lineNumber,
+ });
+ addEvent('reviews-dock-open-diff', {package: 'github', component: this.constructor.name});
+ }
+
+ openPR = async () => {
+ await this.getPRDetailItem();
+ addEvent('reviews-dock-open-pr', {package: 'github', component: this.constructor.name});
+ }
+
+ getPRDetailItem = () => {
+ return this.props.workspace.open(
+ IssueishDetailItem.buildURI({
+ host: this.props.endpoint.getHost(),
+ owner: this.props.owner,
+ repo: this.props.repo,
+ number: this.props.number,
+ workdir: this.props.workdir,
+ }), {
+ pending: true,
+ searchAllPanes: true,
+ },
+ );
+ }
+
+ moreContext = () => {
+ this.setState(prev => ({contextLines: prev.contextLines + 1}));
+ addEvent('reviews-dock-show-more-context', {package: 'github'});
+ }
+
+ lessContext = () => {
+ this.setState(prev => ({contextLines: Math.max(prev.contextLines - 1, 1)}));
+ addEvent('reviews-dock-show-less-context', {package: 'github'});
+ }
+
+ openIssueish = async (owner, repo, number) => {
+ const host = this.props.endpoint.getHost();
+
+ const homeRepository = await this.props.localRepository.hasGitHubRemote(host, owner, repo)
+ ? this.props.localRepository
+ : (await this.props.workdirContextPool.getMatchingContext(host, owner, repo)).getRepository();
+
+ const uri = IssueishDetailItem.buildURI({
+ host, owner, repo, number, workdir: homeRepository.getWorkingDirectoryPath(),
+ });
+ return this.props.workspace.open(uri, {pending: true, searchAllPanes: true});
+ }
+
+ showSummaries = () => new Promise(resolve => this.setState({summarySectionOpen: true}, resolve));
+
+ hideSummaries = () => new Promise(resolve => this.setState({summarySectionOpen: false}, resolve));
+
+ showComments = () => new Promise(resolve => this.setState({commentSectionOpen: true}, resolve));
+
+ hideComments = () => new Promise(resolve => this.setState({commentSectionOpen: false}, resolve));
+
+ showThreadID = commentID => new Promise(resolve => this.setState(state => {
+ state.threadIDsOpen.add(commentID);
+ return {};
+ }, resolve));
+
+ hideThreadID = commentID => new Promise(resolve => this.setState(state => {
+ state.threadIDsOpen.delete(commentID);
+ return {};
+ }, resolve));
+
+ resolveThread = async thread => {
+ if (thread.viewerCanResolve) {
+ // optimistically hide the thread to avoid jankiness;
+ // if the operation fails, the onError callback will revert it.
+ this.hideThreadID(thread.id);
+ try {
+ await resolveReviewThreadMutation(this.props.relay.environment, {
+ threadID: thread.id,
+ viewerID: this.props.viewer.id,
+ viewerLogin: this.props.viewer.login,
+ });
+ addEvent('resolve-comment-thread', {package: 'github'});
+ } catch (err) {
+ this.showThreadID(thread.id);
+ this.props.reportMutationErrors('Unable to resolve the comment thread', err);
+ }
+ }
+ }
+
+ unresolveThread = async thread => {
+ if (thread.viewerCanUnresolve) {
+ try {
+ await unresolveReviewThreadMutation(this.props.relay.environment, {
+ threadID: thread.id,
+ viewerID: this.props.viewer.id,
+ viewerLogin: this.props.viewer.login,
+ });
+ addEvent('unresolve-comment-thread', {package: 'github'});
+ } catch (err) {
+ this.props.reportMutationErrors('Unable to unresolve the comment thread', err);
+ }
+ }
+ }
+
+ addSingleComment = async (commentBody, threadID, replyToID, path, position, callbacks = {}) => {
+ let pendingReviewID = null;
+ try {
+ this.setState({postingToThreadID: threadID});
+
+ const reviewResult = await addReviewMutation(this.props.relay.environment, {
+ pullRequestID: this.props.pullRequest.id,
+ viewerID: this.props.viewer.id,
+ });
+ const reviewID = reviewResult.addPullRequestReview.reviewEdge.node.id;
+ pendingReviewID = reviewID;
+
+ const commentPromise = addReviewCommentMutation(this.props.relay.environment, {
+ body: commentBody,
+ inReplyTo: replyToID,
+ reviewID,
+ threadID,
+ viewerID: this.props.viewer.id,
+ path,
+ position,
+ });
+ if (callbacks.didSubmitComment) {
+ callbacks.didSubmitComment();
+ }
+ await commentPromise;
+ pendingReviewID = null;
+
+ await submitReviewMutation(this.props.relay.environment, {
+ event: 'COMMENT',
+ reviewID,
+ });
+ addEvent('add-single-comment', {package: 'github'});
+ } catch (error) {
+ if (callbacks.didFailComment) {
+ callbacks.didFailComment();
+ }
+
+ if (pendingReviewID !== null) {
+ try {
+ await deleteReviewMutation(this.props.relay.environment, {
+ reviewID: pendingReviewID,
+ pullRequestID: this.props.pullRequest.id,
+ });
+ } catch (e) {
+ /* istanbul ignore else */
+ if (error.errors && e.errors) {
+ error.errors.push(...e.errors);
+ } else {
+ // eslint-disable-next-line no-console
+ console.warn('Unable to delete pending review', e);
+ }
+ }
+ }
+
+ this.props.reportMutationErrors('Unable to submit your comment', error);
+ } finally {
+ this.setState({postingToThreadID: null});
+ }
+ }
+}
+
+export default createFragmentContainer(BareReviewsController, {
+ viewer: graphql`
+ fragment reviewsController_viewer on User {
+ id
+ login
+ avatarUrl
+ }
+ `,
+ repository: graphql`
+ fragment reviewsController_repository on Repository {
+ ...prCheckoutController_repository
+ }
+ `,
+ pullRequest: graphql`
+ fragment reviewsController_pullRequest on PullRequest {
+ id
+ ...prCheckoutController_pullRequest
+ }
+ `,
+});
diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js
index b911f11b3e..ae64fffa77 100644
--- a/lib/controllers/root-controller.js
+++ b/lib/controllers/root-controller.js
@@ -15,16 +15,18 @@ import OpenCommitDialog from '../views/open-commit-dialog';
import InitDialog from '../views/init-dialog';
import CredentialDialog from '../views/credential-dialog';
import Commands, {Command} from '../atom/commands';
-import GitTimingsView from '../views/git-timings-view';
import ChangedFileItem from '../items/changed-file-item';
import IssueishDetailItem from '../items/issueish-detail-item';
import CommitDetailItem from '../items/commit-detail-item';
import CommitPreviewItem from '../items/commit-preview-item';
import GitTabItem from '../items/git-tab-item';
import GitHubTabItem from '../items/github-tab-item';
+import ReviewsItem from '../items/reviews-item';
+import CommentDecorationsContainer from '../containers/comment-decorations-container';
import StatusBarTileController from './status-bar-tile-controller';
import RepositoryConflictController from './repository-conflict-controller';
import GitCacheView from '../views/git-cache-view';
+import GitTimingsView from '../views/git-timings-view';
import Conflict from '../models/conflicts/conflict';
import Switchboard from '../switchboard';
import {WorkdirContextPoolPropType} from '../prop-types';
@@ -121,6 +123,7 @@ export default class RootController extends React.Component {
{this.renderPaneItems()}
{this.renderDialogs()}
{this.renderConflictResolver()}
+ {this.renderCommentDecorations()}
);
}
@@ -276,6 +279,20 @@ export default class RootController extends React.Component {
);
}
+ renderCommentDecorations() {
+ if (!this.props.repository) {
+ return null;
+ }
+ return (
+
+ );
+ }
+
renderConflictResolver() {
if (!this.props.repository) {
return null;
@@ -404,7 +421,7 @@ export default class RootController extends React.Component {
)}
- {({itemHolder, params}) => (
+ {({itemHolder, params, deserialized}) => (
+ )}
+
+
+ {({itemHolder, params}) => (
+
)}
@@ -462,15 +503,26 @@ export default class RootController extends React.Component {
const devToolsName = 'electron-devtools-installer';
const devTools = require(devToolsName);
+ await Promise.all([
+ this.installExtension(devTools.REACT_DEVELOPER_TOOLS.id),
+ // relay developer tools extension id
+ this.installExtension('ncedobpgnmkhcmnnkcimnobpfepidadl'),
+ ]);
+
+ this.props.notificationManager.addSuccess('🌈 Reload your window to start using the React/Relay dev tools!');
+ }
+
+ async installExtension(id) {
+ const devToolsName = 'electron-devtools-installer';
+ const devTools = require(devToolsName);
+
const crossUnzipName = 'cross-unzip';
const unzip = require(crossUnzipName);
- const reactId = devTools.REACT_DEVELOPER_TOOLS.id;
-
const url =
'https://clients2.google.com/service/update2/crx?' +
- `response=redirect&x=id%3D${reactId}%26uc&prodversion=32`;
- const extensionFolder = path.resolve(remote.app.getPath('userData'), `extensions/${reactId}`);
+ `response=redirect&x=id%3D${id}%26uc&prodversion=32`;
+ const extensionFolder = path.resolve(remote.app.getPath('userData'), `extensions/${id}`);
const extensionFile = `${extensionFolder}.crx`;
await fs.ensureDir(path.dirname(extensionFile));
const response = await fetch(url, {method: 'GET'});
@@ -488,9 +540,7 @@ export default class RootController extends React.Component {
});
await fs.ensureDir(extensionFolder, 0o755);
- await devTools.default(devTools.REACT_DEVELOPER_TOOLS);
-
- this.props.notificationManager.addSuccess('🌈 Reload your window to start using the React dev tools!');
+ await devTools.default(id);
}
componentWillUnmount() {
@@ -588,7 +638,12 @@ export default class RootController extends React.Component {
}
acceptOpenIssueish({repoOwner, repoName, issueishNumber}) {
- const uri = IssueishDetailItem.buildURI('github.com', repoOwner, repoName, issueishNumber);
+ const uri = IssueishDetailItem.buildURI({
+ host: 'github.com',
+ owner: repoOwner,
+ repo: repoName,
+ number: issueishNumber,
+ });
this.setState({openIssueishDialogActive: false});
this.props.workspace.open(uri).then(() => {
addEvent('open-issueish-in-pane', {package: 'github', from: 'dialog'});
@@ -870,6 +925,18 @@ export default class RootController extends React.Component {
return await Promise.all(editorPromises);
}
+ reportMutationErrors = (friendlyMessage, err) => {
+ const opts = {dismissable: true};
+
+ if (err.errors) {
+ opts.detail = err.errors.map(e => e.message).join('\n');
+ } else if (err.stack) {
+ opts.stack = err.stack;
+ }
+
+ this.props.notificationManager.addError(friendlyMessage, opts);
+ }
+
/*
* Asynchronously count the conflict markers present in a file specified by full path.
*/
diff --git a/lib/github-package.js b/lib/github-package.js
index 092c8eb6fa..29824b41e7 100644
--- a/lib/github-package.js
+++ b/lib/github-package.js
@@ -322,9 +322,10 @@ export default class GithubPackage {
}, GitTimingsView.buildURI());
}
- createIssueishPaneItemStub({uri}) {
+ createIssueishPaneItemStub({uri, selectedTab}) {
return StubItem.create('issueish-detail-item', {
title: 'Issueish',
+ initSelectedTab: selectedTab,
}, uri);
}
@@ -394,6 +395,16 @@ export default class GithubPackage {
return item;
}
+ createReviewsStub({uri}) {
+ const item = StubItem.create('github-reviews', {
+ title: 'Reviews',
+ }, uri);
+ if (this.controller) {
+ this.rerender();
+ }
+ return item;
+ }
+
destroyGitTabItem() {
if (this.gitTabStubItem) {
this.gitTabStubItem.destroy();
diff --git a/lib/helpers.js b/lib/helpers.js
index 0b76f1bc71..8f3b653bfa 100644
--- a/lib/helpers.js
+++ b/lib/helpers.js
@@ -3,7 +3,6 @@ import fs from 'fs-extra';
import os from 'os';
import temp from 'temp';
-import MultiFilePatchController from './controllers/multi-file-patch-controller';
import RefHolder from './models/ref-holder';
export const LINE_ENDING_REGEX = /\r?\n/;
@@ -377,9 +376,14 @@ export function getCommitMessageEditors(repository, workspace) {
return workspace.getTextEditors().filter(editor => editor.getPath() === getCommitMessagePath(repository));
}
+let ChangedFileItem = null;
export function getFilePatchPaneItems({onlyStaged, empty} = {}, workspace) {
+ if (ChangedFileItem === null) {
+ ChangedFileItem = require('./items/changed-file-item').default;
+ }
+
return workspace.getPaneItems().filter(item => {
- const isFilePatchItem = item && item.getRealItem && item.getRealItem() instanceof MultiFilePatchController;
+ const isFilePatchItem = item && item.getRealItem && item.getRealItem() instanceof ChangedFileItem;
if (onlyStaged) {
return isFilePatchItem && item.stagingStatus === 'staged';
} else if (empty) {
@@ -481,3 +485,22 @@ export function equalSets(left, right) {
return true;
}
+
+// Constants
+
+export const NBSP_CHARACTER = '\u00a0';
+
+export function blankLabel() {
+ return NBSP_CHARACTER;
+}
+
+export const reactionTypeToEmoji = {
+ THUMBS_UP: '👍',
+ THUMBS_DOWN: '👎',
+ LAUGH: '😆',
+ HOORAY: '🎉',
+ CONFUSED: '😕',
+ HEART: '❤️',
+ ROCKET: '🚀',
+ EYES: '👀',
+};
diff --git a/lib/items/issueish-detail-item.js b/lib/items/issueish-detail-item.js
index a0c5c3a1f6..2d83f30963 100644
--- a/lib/items/issueish-detail-item.js
+++ b/lib/items/issueish-detail-item.js
@@ -11,6 +11,13 @@ import IssueishDetailContainer from '../containers/issueish-detail-container';
import RefHolder from '../models/ref-holder';
export default class IssueishDetailItem extends Component {
+ static tabs = {
+ OVERVIEW: 0,
+ BUILD_STATUS: 1,
+ COMMITS: 2,
+ FILES: 3,
+ }
+
static propTypes = {
// Issueish selection criteria
// Parsed from item URI
@@ -23,6 +30,9 @@ export default class IssueishDetailItem extends Component {
// Package models
workdirContextPool: WorkdirContextPoolPropType.isRequired,
loginModel: GithubLoginModelPropType.isRequired,
+ initSelectedTab: PropTypes.oneOf(
+ Object.keys(IssueishDetailItem.tabs).map(k => IssueishDetailItem.tabs[k]),
+ ),
// Atom environment
workspace: PropTypes.object.isRequired,
@@ -30,18 +40,26 @@ export default class IssueishDetailItem extends Component {
keymaps: PropTypes.object.isRequired,
tooltips: PropTypes.object.isRequired,
config: PropTypes.object.isRequired,
+
+ // Action methods
+ reportMutationErrors: PropTypes.func.isRequired,
+ }
+
+ static defaultProps = {
+ initSelectedTab: IssueishDetailItem.tabs.OVERVIEW,
}
static uriPattern = 'atom-github://issueish/{host}/{owner}/{repo}/{issueishNumber}?workdir={workingDirectory}'
- static buildURI(host, owner, repo, number, workdir = null) {
- const encodedWorkdir = workdir ? encodeURIComponent(workdir) : '';
+ static buildURI({host, owner, repo, number, workdir}) {
+ const encodeOptionalParam = param => (param ? encodeURIComponent(param) : '');
return 'atom-github://issueish/' +
encodeURIComponent(host) + '/' +
encodeURIComponent(owner) + '/' +
encodeURIComponent(repo) + '/' +
- encodeURIComponent(number) + '?workdir=' + encodedWorkdir;
+ encodeURIComponent(number) +
+ '?workdir=' + encodeOptionalParam(workdir);
}
constructor(props) {
@@ -62,6 +80,9 @@ export default class IssueishDetailItem extends Component {
repo: this.props.repo,
issueishNumber: this.props.issueishNumber,
repository,
+ initChangedFilePath: '',
+ initChangedFilePosition: 0,
+ selectedTab: this.props.initSelectedTab,
};
if (repository.isAbsent()) {
@@ -81,6 +102,11 @@ export default class IssueishDetailItem extends Component {
owner={this.state.owner}
repo={this.state.repo}
issueishNumber={this.state.issueishNumber}
+ initChangedFilePath={this.state.initChangedFilePath}
+ initChangedFilePosition={this.state.initChangedFilePosition}
+ selectedTab={this.state.selectedTab}
+ onTabSelected={this.onTabSelected}
+ onOpenFilesTab={this.onOpenFilesTab}
repository={this.state.repository}
workspace={this.props.workspace}
@@ -96,6 +122,7 @@ export default class IssueishDetailItem extends Component {
destroy={this.destroy}
itemType={this.constructor}
refEditor={this.refEditor}
+ reportMutationErrors={this.props.reportMutationErrors}
/>
);
}
@@ -108,14 +135,9 @@ export default class IssueishDetailItem extends Component {
issueishNumber: this.state.issueishNumber,
};
- const matchingRepositories = (await Promise.all(
- pool.withResidentContexts((workdir, context) => {
- const repository = context.getRepository();
- return repository.hasGitHubRemote(this.state.host, owner, repo)
- .then(hasRemote => (hasRemote ? repository : null));
- }),
- )).filter(Boolean);
- const nextRepository = matchingRepositories.length === 1 ? matchingRepositories[0] : Repository.absent();
+ const nextRepository = await this.state.repository.hasGitHubRemote(this.state.host, owner, repo)
+ ? this.state.repository
+ : (await pool.getMatchingContext(this.state.host, owner, repo)).getRepository();
await new Promise(resolve => {
this.setState((prevState, props) => {
@@ -175,7 +197,14 @@ export default class IssueishDetailItem extends Component {
serialize() {
return {
- uri: this.getURI(),
+ uri: IssueishDetailItem.buildURI({
+ host: this.props.host,
+ owner: this.props.owner,
+ repo: this.props.repo,
+ number: this.props.issueishNumber,
+ workdir: this.props.workingDirectory,
+ }),
+ selectedTab: this.state.selectedTab,
deserializer: 'IssueishDetailItem',
};
}
@@ -188,4 +217,24 @@ export default class IssueishDetailItem extends Component {
this.refEditor.map(editor => cb(editor));
return this.emitter.on('did-change-embedded-text-editor', cb);
}
+
+ openFilesTab({changedFilePath, changedFilePosition}) {
+ this.setState({
+ selectedTab: IssueishDetailItem.tabs.FILES,
+ initChangedFilePath: changedFilePath,
+ initChangedFilePosition: changedFilePosition,
+ }, () => {
+ this.emitter.emit('on-open-files-tab', {changedFilePath, changedFilePosition});
+ });
+ }
+
+ onTabSelected = index => new Promise(resolve => {
+ this.setState({
+ selectedTab: index,
+ initChangedFilePath: '',
+ initChangedFilePosition: 0,
+ }, resolve);
+ });
+
+ onOpenFilesTab = callback => this.emitter.on('on-open-files-tab', callback);
}
diff --git a/lib/items/reviews-item.js b/lib/items/reviews-item.js
new file mode 100644
index 0000000000..8edb7263c5
--- /dev/null
+++ b/lib/items/reviews-item.js
@@ -0,0 +1,116 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {Emitter} from 'event-kit';
+
+import {GithubLoginModelPropType, WorkdirContextPoolPropType} from '../prop-types';
+import Repository from '../models/repository';
+import {getEndpoint} from '../models/endpoint';
+import ReviewsContainer from '../containers/reviews-container';
+
+export default class ReviewsItem extends React.Component {
+ static propTypes = {
+ // Parsed from URI
+ host: PropTypes.string.isRequired,
+ owner: PropTypes.string.isRequired,
+ repo: PropTypes.string.isRequired,
+ number: PropTypes.number.isRequired,
+ workdir: PropTypes.string.isRequired,
+
+ // Package models
+ workdirContextPool: WorkdirContextPoolPropType.isRequired,
+ loginModel: GithubLoginModelPropType.isRequired,
+
+ // Atom environment
+ workspace: PropTypes.object.isRequired,
+ config: PropTypes.object.isRequired,
+ commands: PropTypes.object.isRequired,
+ tooltips: PropTypes.object.isRequired,
+
+ // Action methods
+ reportMutationErrors: PropTypes.func.isRequired,
+ }
+
+ static uriPattern = 'atom-github://reviews/{host}/{owner}/{repo}/{number}?workdir={workdir}'
+
+ static buildURI({host, owner, repo, number, workdir}) {
+ return 'atom-github://reviews/' +
+ encodeURIComponent(host) + '/' +
+ encodeURIComponent(owner) + '/' +
+ encodeURIComponent(repo) + '/' +
+ encodeURIComponent(number) +
+ '?workdir=' + encodeURIComponent(workdir || '');
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.emitter = new Emitter();
+ this.isDestroyed = false;
+
+ this.state = {
+ initThreadID: null,
+ };
+ }
+
+ render() {
+ const endpoint = getEndpoint(this.props.host);
+
+ const repository = this.props.workdir.length > 0
+ ? this.props.workdirContextPool.add(this.props.workdir).getRepository()
+ : Repository.absent();
+
+ return (
+
+ );
+ }
+
+ getTitle() {
+ return `Reviews #${this.props.number}`;
+ }
+
+ getDefaultLocation() {
+ return 'right';
+ }
+
+ getPreferredWidth() {
+ return 400;
+ }
+
+ destroy() {
+ /* istanbul ignore else */
+ if (!this.isDestroyed) {
+ this.emitter.emit('did-destroy');
+ this.isDestroyed = true;
+ }
+ }
+
+ onDidDestroy(callback) {
+ return this.emitter.on('did-destroy', callback);
+ }
+
+ serialize() {
+ return {
+ deserializer: 'ReviewsStub',
+ uri: ReviewsItem.buildURI({
+ host: this.props.host,
+ owner: this.props.owner,
+ repo: this.props.repo,
+ number: this.props.number,
+ workdir: this.props.workdir,
+ }),
+ };
+ }
+
+ async jumpToThread(id) {
+ if (this.state.initThreadID === id) {
+ await new Promise(resolve => this.setState({initThreadID: null}, resolve));
+ }
+
+ return new Promise(resolve => this.setState({initThreadID: id}, resolve));
+ }
+}
diff --git a/lib/models/model-observer.js b/lib/models/model-observer.js
index d730b771c5..f6089225b3 100644
--- a/lib/models/model-observer.js
+++ b/lib/models/model-observer.js
@@ -72,6 +72,10 @@ export default class ModelObserver {
return this.lastModelDataRefreshPromise;
}
+ hasPendingUpdate() {
+ return this.pending;
+ }
+
destroy() {
if (this.activeModelUpdateSubscription) { this.activeModelUpdateSubscription.dispose(); }
}
diff --git a/lib/models/patch/builder.js b/lib/models/patch/builder.js
index b328a4da35..dd3daab79d 100644
--- a/lib/models/patch/builder.js
+++ b/lib/models/patch/builder.js
@@ -15,6 +15,9 @@ export const DEFAULT_OPTIONS = {
// Existing patch buffer to render onto
patchBuffer: null,
+
+ // Store off what-the-diff file patch
+ preserveOriginal: false,
};
export function buildFilePatch(diffs, options) {
@@ -146,7 +149,8 @@ function singleDiffFilePatch(diff, patchBuffer, opts) {
const [hunks, patchMarker] = buildHunks(diff, patchBuffer);
const patch = new Patch({status: diff.status, hunks, marker: patchMarker});
- return new FilePatch(oldFile, newFile, patch);
+ const rawPatches = opts.preserveOriginal ? {content: diff} : null;
+ return new FilePatch(oldFile, newFile, patch, rawPatches);
}
}
@@ -212,7 +216,8 @@ function dualDiffFilePatch(diff1, diff2, patchBuffer, opts) {
const [hunks, patchMarker] = buildHunks(contentChangeDiff, patchBuffer);
const patch = new Patch({status, hunks, marker: patchMarker});
- return new FilePatch(oldFile, newFile, patch);
+ const rawPatches = opts.preserveOriginal ? {content: contentChangeDiff, mode: modeChangeDiff} : null;
+ return new FilePatch(oldFile, newFile, patch, rawPatches);
}
}
diff --git a/lib/models/patch/file-patch.js b/lib/models/patch/file-patch.js
index 87764c0d41..825df0bd27 100644
--- a/lib/models/patch/file-patch.js
+++ b/lib/models/patch/file-patch.js
@@ -13,10 +13,11 @@ export default class FilePatch {
return new this(oldFile, newFile, Patch.createHiddenPatch(marker, renderStatus, showFn));
}
- constructor(oldFile, newFile, patch) {
+ constructor(oldFile, newFile, patch, rawPatches) {
this.oldFile = oldFile;
this.newFile = newFile;
this.patch = patch;
+ this.rawPatches = rawPatches;
this.emitter = new Emitter();
}
@@ -37,6 +38,14 @@ export default class FilePatch {
return this.newFile;
}
+ getRawContentPatch() {
+ if (!this.rawPatches) {
+ throw new Error('FilePatch was not parsed with {perserveOriginal: true}');
+ }
+
+ return this.rawPatches.content;
+ }
+
getPatch() {
return this.patch;
}
diff --git a/lib/models/patch/multi-file-patch.js b/lib/models/patch/multi-file-patch.js
index e64570c7a6..dfc0a4b845 100644
--- a/lib/models/patch/multi-file-patch.js
+++ b/lib/models/patch/multi-file-patch.js
@@ -71,6 +71,10 @@ export default class MultiFilePatch {
return this.filePatches;
}
+ getPatchForPath(path) {
+ return this.filePatchesByPath.get(path);
+ }
+
getPathSet() {
return this.getFilePatches().reduce((pathSet, filePatch) => {
for (const file of [filePatch.getOldFile(), filePatch.getNewFile()]) {
@@ -200,6 +204,7 @@ export default class MultiFilePatch {
const diffRowOffsetIndex = this.diffRowOffsetIndices.get(filePatchPath);
return diffRowOffsetIndex.index.size === 0;
}
+
populateDiffRowOffsetIndices(filePatch) {
let diffRow = 1;
const index = new RBTree((a, b) => a.diffRow - b.diffRow);
@@ -387,11 +392,52 @@ export default class MultiFilePatch {
}
getBufferRowForDiffPosition = (fileName, diffRow) => {
- const {startBufferRow, index} = this.diffRowOffsetIndices.get(fileName);
- const {offset} = index.lowerBound({diffRow}).data();
+ const offsetIndex = this.diffRowOffsetIndices.get(fileName);
+ if (!offsetIndex) {
+ // eslint-disable-next-line no-console
+ console.error('Attempt to compute buffer row for invalid diff position: file not included', {
+ fileName,
+ diffRow,
+ validFileNames: Array.from(this.diffRowOffsetIndices.keys()),
+ });
+ return null;
+ }
+ const {startBufferRow, index} = offsetIndex;
+
+ const result = index.lowerBound({diffRow}).data();
+ if (!result) {
+ // eslint-disable-next-line no-console
+ console.error('Attempt to compute buffer row for invalid diff position: diff row out of range', {
+ fileName,
+ diffRow,
+ });
+ return null;
+ }
+ const {offset} = result;
+
return startBufferRow + diffRow - offset;
}
+ getPreviewPatchBuffer(fileName, diffRow, maxRowCount) {
+ const bufferRow = this.getBufferRowForDiffPosition(fileName, diffRow);
+ if (bufferRow === null) {
+ return new PatchBuffer();
+ }
+
+ const filePatch = this.getFilePatchAt(bufferRow);
+ const filePatchIndex = this.filePatches.indexOf(filePatch);
+ const hunk = this.getHunkAt(bufferRow);
+
+ const previewStartRow = Math.max(bufferRow - maxRowCount + 1, hunk.getRange().start.row);
+ const previewEndRow = bufferRow;
+
+ const before = this.getMarkersBefore(filePatchIndex);
+ const after = this.getMarkersAfter(filePatchIndex);
+ const exclude = new Set([...before, ...after]);
+
+ return this.patchBuffer.createSubBuffer([[previewStartRow, 0], [previewEndRow, Infinity]], {exclude}).patchBuffer;
+ }
+
/*
* Construct an apply-able patch String.
*/
diff --git a/lib/models/patch/patch-buffer.js b/lib/models/patch/patch-buffer.js
index e93a48f865..9ba9e2a794 100644
--- a/lib/models/patch/patch-buffer.js
+++ b/lib/models/patch/patch-buffer.js
@@ -59,7 +59,7 @@ export default class PatchBuffer {
return this.createInserterAt(this.getInsertionPoint());
}
- extractPatchBuffer(rangeLike, options = {}) {
+ createSubBuffer(rangeLike, options = {}) {
const opts = {
exclude: new Set(),
...options,
@@ -67,9 +67,9 @@ export default class PatchBuffer {
const range = Range.fromObject(rangeLike);
const baseOffset = range.start.negate();
- const movedMarkersByLayer = LAYER_NAMES.reduce((map, layerName) => {
+ const includedMarkersByLayer = LAYER_NAMES.reduce((map, layerName) => {
map[layerName] = this.layers[layerName]
- .findMarkers({containedInRange: range})
+ .findMarkers({intersectsRange: range})
.filter(m => !opts.exclude.has(m));
return map;
}, {});
@@ -79,20 +79,40 @@ export default class PatchBuffer {
subBuffer.getBuffer().setText(this.buffer.getTextInRange(range));
for (const layerName of LAYER_NAMES) {
- for (const oldMarker of movedMarkersByLayer[layerName]) {
- const startOffset = oldMarker.getRange().start.row === range.start.row ? baseOffset : [baseOffset.row, 0];
- const endOffset = oldMarker.getRange().end.row === range.start.row ? baseOffset : [baseOffset.row, 0];
+ for (const oldMarker of includedMarkersByLayer[layerName]) {
+ const oldRange = oldMarker.getRange();
+
+ const clippedStart = oldRange.start.isLessThanOrEqual(range.start) ? range.start : oldRange.start;
+ const clippedEnd = oldRange.end.isGreaterThanOrEqual(range.end) ? range.end : oldRange.end;
+
+ // Exclude non-empty markers that intersect *only* at the range start or end
+ if (clippedStart.isEqual(clippedEnd) && !oldRange.start.isEqual(oldRange.end)) {
+ continue;
+ }
+
+ const startOffset = clippedStart.row === range.start.row ? baseOffset : [baseOffset.row, 0];
+ const endOffset = clippedEnd.row === range.start.row ? baseOffset : [baseOffset.row, 0];
+
const newMarker = subBuffer.markRange(
layerName,
- oldMarker.getRange().translate(startOffset, endOffset),
+ [clippedStart.translate(startOffset), clippedEnd.translate(endOffset)],
oldMarker.getProperties(),
);
markerMap.set(oldMarker, newMarker);
- oldMarker.destroy();
}
}
- this.buffer.setTextInRange(range, '');
+ return {patchBuffer: subBuffer, markerMap};
+ }
+
+ extractPatchBuffer(rangeLike, options = {}) {
+ const {patchBuffer: subBuffer, markerMap} = this.createSubBuffer(rangeLike, options);
+
+ for (const oldMarker of markerMap.keys()) {
+ oldMarker.destroy();
+ }
+
+ this.buffer.setTextInRange(rangeLike, '');
return {patchBuffer: subBuffer, markerMap};
}
diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js
index 233477e19c..710bbc2cfe 100644
--- a/lib/models/repository-states/present.js
+++ b/lib/models/repository-states/present.js
@@ -162,7 +162,9 @@ export default class Present extends State {
// File change within the working directory
const relativePath = path.relative(this.workdir(), fullPath);
- keys.add(Keys.filePatch.oneWith(relativePath, {staged: false}));
+ for (const key of Keys.filePatch.eachWithFileOpts([relativePath], [{staged: false}])) {
+ keys.add(key);
+ }
keys.add(Keys.statusBundle);
}
@@ -300,13 +302,7 @@ export default class Present extends State {
commit(message, options) {
return this.invalidate(
- () => [
- ...Keys.headOperationKeys(),
- ...Keys.filePatch.eachWithOpts({staged: true}),
- Keys.headDescription,
- Keys.branches,
- Keys.stagedChanges,
- ],
+ Keys.headOperationKeys,
// eslint-disable-next-line no-shadow
() => this.executePipelineAction('COMMIT', async (message, options = {}) => {
const coAuthors = options.coAuthors;
@@ -394,6 +390,7 @@ export default class Present extends State {
Keys.statusBundle,
Keys.index.all,
...Keys.filePatch.eachWithOpts({staged: true}),
+ Keys.filePatch.allAgainstNonHead,
Keys.headDescription,
Keys.branches,
],
@@ -411,6 +408,7 @@ export default class Present extends State {
Keys.stagedChanges,
...paths.map(fileName => Keys.index.oneWith(fileName)),
...Keys.filePatch.eachWithFileOpts(paths, [{staged: true}]),
+ ...Keys.filePatch.eachNonHeadWithFiles(paths),
],
() => this.git().checkoutFiles(paths, revision),
);
@@ -587,6 +585,7 @@ export default class Present extends State {
() => [
Keys.statusBundle,
...paths.map(filePath => Keys.filePatch.oneWith(filePath, {staged: false})),
+ ...Keys.filePatch.eachNonHeadWithFiles(paths),
],
async () => {
const untrackedFiles = await this.git().getUntrackedFiles();
@@ -715,6 +714,12 @@ export default class Present extends State {
});
}
+ getDiffsForFilePath(filePath, baseCommit) {
+ return this.cache.getOrSet(Keys.filePatch.oneWith(filePath, {baseCommit}), () => {
+ return this.git().getDiffsForFilePath(filePath, {baseCommit});
+ });
+ }
+
getStagedChangesPatch(options) {
const opts = {
builder: {},
@@ -1088,15 +1093,25 @@ const Keys = {
stagedChanges: new CacheKey('staged-changes'),
filePatch: {
- _optKey: ({staged}) => {
- return staged ? 's' : 'u';
- },
+ _optKey: ({staged}) => (staged ? 's' : 'u'),
oneWith: (fileName, options) => { // <-- Keys.filePatch
const optKey = Keys.filePatch._optKey(options);
- return new CacheKey(`file-patch:${optKey}:${fileName}`, [
+ const baseCommit = options.baseCommit || 'head';
+
+ const extraGroups = [];
+ if (options.baseCommit) {
+ extraGroups.push(`file-patch:base-nonhead:path-${fileName}`);
+ extraGroups.push('file-patch:base-nonhead');
+ } else {
+ extraGroups.push('file-patch:base-head');
+ }
+
+ return new CacheKey(`file-patch:${optKey}:${baseCommit}:${fileName}`, [
'file-patch',
- `file-patch:${optKey}`,
+ `file-patch:opt-${optKey}`,
+ `file-patch:opt-${optKey}:path-${fileName}`,
+ ...extraGroups,
]);
},
@@ -1104,13 +1119,19 @@ const Keys = {
const keys = [];
for (let i = 0; i < fileNames.length; i++) {
for (let j = 0; j < opts.length; j++) {
- keys.push(Keys.filePatch.oneWith(fileNames[i], opts[j]));
+ keys.push(new GroupKey(`file-patch:opt-${Keys.filePatch._optKey(opts[j])}:path-${fileNames[i]}`));
}
}
return keys;
},
- eachWithOpts: (...opts) => opts.map(opt => new GroupKey(`file-patch:${Keys.filePatch._optKey(opt)}`)),
+ eachNonHeadWithFiles: fileNames => {
+ return fileNames.map(fileName => new GroupKey(`file-patch:base-nonhead:path-${fileName}`));
+ },
+
+ allAgainstNonHead: new GroupKey('file-patch:base-nonhead'),
+
+ eachWithOpts: (...opts) => opts.map(opt => new GroupKey(`file-patch:opt-${Keys.filePatch._optKey(opt)}`)),
all: new GroupKey('file-patch'),
},
@@ -1168,7 +1189,10 @@ const Keys = {
],
headOperationKeys: () => [
+ Keys.headDescription,
+ Keys.branches,
...Keys.filePatch.eachWithOpts({staged: true}),
+ Keys.filePatch.allAgainstNonHead,
Keys.stagedChanges,
Keys.lastCommit,
Keys.recentCommits,
diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js
index b364d85dde..9e36786062 100644
--- a/lib/models/repository-states/state.js
+++ b/lib/models/repository-states/state.js
@@ -286,6 +286,10 @@ export default class State {
return Promise.resolve(MultiFilePatch.createNull());
}
+ getDiffsForFilePath(filePath, options = {}) {
+ return Promise.resolve([]);
+ }
+
getStagedChangesPatch() {
return Promise.resolve(MultiFilePatch.createNull());
}
diff --git a/lib/models/repository.js b/lib/models/repository.js
index 2142961430..c597380f8a 100644
--- a/lib/models/repository.js
+++ b/lib/models/repository.js
@@ -247,6 +247,24 @@ export default class Repository {
: nullAuthor;
}
+ // todo (@annthurium, 3/2019): refactor GitHubTabController etc to use this method.
+ async getCurrentGitHubRemote() {
+ let currentRemote = null;
+
+ const remotes = await this.getRemotes();
+
+ const gitHubRemotes = remotes.filter(remote => remote.isGithubRepo());
+ const selectedRemoteName = await this.getConfig('atomGithub.currentRemote');
+ currentRemote = gitHubRemotes.withName(selectedRemoteName);
+
+ if (!currentRemote.isPresent() && gitHubRemotes.size() === 1) {
+ currentRemote = Array.from(gitHubRemotes)[0];
+ }
+ // todo: handle the case where multiple remotes are available and no chosen remote is set.
+ return currentRemote;
+ }
+
+
async hasGitHubRemote(host, owner, name) {
const remotes = await this.getRemotes();
return remotes.matchingGitHubRepository(owner, name).length > 0;
@@ -322,6 +340,7 @@ const delegates = [
'getStatusBundle',
'getStatusesForChangedFiles',
'getFilePatchForPath',
+ 'getDiffsForFilePath',
'getStagedChangesPatch',
'readFileFromIndex',
diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js
index 45bfd48328..0adad67557 100644
--- a/lib/models/workdir-context-pool.js
+++ b/lib/models/workdir-context-pool.js
@@ -30,6 +30,22 @@ export default class WorkdirContextPool {
return this.contexts.get(directory) || WorkdirContext.absent({pipelineManager});
}
+ /**
+ * Return a WorkdirContext whose Repository has at least one remote configured to push to the named GitHub repository.
+ * Returns a null context if zero or more than one contexts match.
+ */
+ async getMatchingContext(host, owner, repo) {
+ const matches = await Promise.all(
+ this.withResidentContexts(async (_workdir, context) => {
+ const match = await context.getRepository().hasGitHubRemote(host, owner, repo);
+ return match ? context : null;
+ }),
+ );
+ const filtered = matches.filter(Boolean);
+
+ return filtered.length === 1 ? filtered[0] : WorkdirContext.absent({...this.options});
+ }
+
add(directory, options = {}) {
if (this.contexts.has(directory)) {
return this.getContext(directory);
diff --git a/lib/mutations/__generated__/addPrReviewCommentMutation.graphql.js b/lib/mutations/__generated__/addPrReviewCommentMutation.graphql.js
new file mode 100644
index 0000000000..48a72fc002
--- /dev/null
+++ b/lib/mutations/__generated__/addPrReviewCommentMutation.graphql.js
@@ -0,0 +1,380 @@
+/**
+ * @flow
+ * @relayHash 565ec2aecd4101a6f349746557180729
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ConcreteRequest } from 'relay-runtime';
+type emojiReactionsController_reactable$ref = any;
+export type AddPullRequestReviewCommentInput = {|
+ pullRequestReviewId: string,
+ commitOID?: ?any,
+ body: string,
+ path?: ?string,
+ position?: ?number,
+ inReplyTo?: ?string,
+ clientMutationId?: ?string,
+|};
+export type addPrReviewCommentMutationVariables = {|
+ input: AddPullRequestReviewCommentInput
+|};
+export type addPrReviewCommentMutationResponse = {|
+ +addPullRequestReviewComment: ?{|
+ +commentEdge: ?{|
+ +node: ?{|
+ +id: string,
+ +author: ?{|
+ +avatarUrl: any,
+ +login: string,
+ |},
+ +bodyHTML: any,
+ +isMinimized: boolean,
+ +viewerCanReact: boolean,
+ +path: string,
+ +position: ?number,
+ +createdAt: any,
+ +url: any,
+ +$fragmentRefs: emojiReactionsController_reactable$ref,
+ |}
+ |}
+ |}
+|};
+export type addPrReviewCommentMutation = {|
+ variables: addPrReviewCommentMutationVariables,
+ response: addPrReviewCommentMutationResponse,
+|};
+*/
+
+
+/*
+mutation addPrReviewCommentMutation(
+ $input: AddPullRequestReviewCommentInput!
+) {
+ addPullRequestReviewComment(input: $input) {
+ commentEdge {
+ node {
+ id
+ author {
+ __typename
+ avatarUrl
+ login
+ ... on Node {
+ id
+ }
+ }
+ bodyHTML
+ isMinimized
+ viewerCanReact
+ path
+ position
+ createdAt
+ url
+ ...emojiReactionsController_reactable
+ }
+ }
+ }
+}
+
+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": "input",
+ "type": "AddPullRequestReviewCommentInput!",
+ "defaultValue": null
+ }
+],
+v1 = [
+ {
+ "kind": "Variable",
+ "name": "input",
+ "variableName": "input",
+ "type": "AddPullRequestReviewCommentInput!"
+ }
+],
+v2 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "id",
+ "args": null,
+ "storageKey": null
+},
+v3 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "avatarUrl",
+ "args": null,
+ "storageKey": null
+},
+v4 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "login",
+ "args": null,
+ "storageKey": null
+},
+v5 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "bodyHTML",
+ "args": null,
+ "storageKey": null
+},
+v6 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "isMinimized",
+ "args": null,
+ "storageKey": null
+},
+v7 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "viewerCanReact",
+ "args": null,
+ "storageKey": null
+},
+v8 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "path",
+ "args": null,
+ "storageKey": null
+},
+v9 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "position",
+ "args": null,
+ "storageKey": null
+},
+v10 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "createdAt",
+ "args": null,
+ "storageKey": null
+},
+v11 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "url",
+ "args": null,
+ "storageKey": null
+};
+return {
+ "kind": "Request",
+ "fragment": {
+ "kind": "Fragment",
+ "name": "addPrReviewCommentMutation",
+ "type": "Mutation",
+ "metadata": null,
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "addPullRequestReviewComment",
+ "storageKey": null,
+ "args": (v1/*: any*/),
+ "concreteType": "AddPullRequestReviewCommentPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "commentEdge",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReviewCommentEdge",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "node",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReviewComment",
+ "plural": false,
+ "selections": [
+ (v2/*: any*/),
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "author",
+ "storageKey": null,
+ "args": null,
+ "concreteType": null,
+ "plural": false,
+ "selections": [
+ (v3/*: any*/),
+ (v4/*: any*/)
+ ]
+ },
+ (v5/*: any*/),
+ (v6/*: any*/),
+ (v7/*: any*/),
+ (v8/*: any*/),
+ (v9/*: any*/),
+ (v10/*: any*/),
+ (v11/*: any*/),
+ {
+ "kind": "FragmentSpread",
+ "name": "emojiReactionsController_reactable",
+ "args": null
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "operation": {
+ "kind": "Operation",
+ "name": "addPrReviewCommentMutation",
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "addPullRequestReviewComment",
+ "storageKey": null,
+ "args": (v1/*: any*/),
+ "concreteType": "AddPullRequestReviewCommentPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "commentEdge",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReviewCommentEdge",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "node",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReviewComment",
+ "plural": false,
+ "selections": [
+ (v2/*: any*/),
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "author",
+ "storageKey": null,
+ "args": null,
+ "concreteType": null,
+ "plural": false,
+ "selections": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "__typename",
+ "args": null,
+ "storageKey": null
+ },
+ (v3/*: any*/),
+ (v4/*: any*/),
+ (v2/*: any*/)
+ ]
+ },
+ (v5/*: any*/),
+ (v6/*: any*/),
+ (v7/*: any*/),
+ (v8/*: any*/),
+ (v9/*: any*/),
+ (v10/*: any*/),
+ (v11/*: 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
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "params": {
+ "operationKind": "mutation",
+ "name": "addPrReviewCommentMutation",
+ "id": null,
+ "text": "mutation addPrReviewCommentMutation(\n $input: AddPullRequestReviewCommentInput!\n) {\n addPullRequestReviewComment(input: $input) {\n commentEdge {\n node {\n id\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n isMinimized\n viewerCanReact\n path\n position\n createdAt\n url\n ...emojiReactionsController_reactable\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 = 'ee13078483d102b11a0919624bd400f6';
+module.exports = node;
diff --git a/lib/mutations/__generated__/addPrReviewMutation.graphql.js b/lib/mutations/__generated__/addPrReviewMutation.graphql.js
new file mode 100644
index 0000000000..6f617762f9
--- /dev/null
+++ b/lib/mutations/__generated__/addPrReviewMutation.graphql.js
@@ -0,0 +1,349 @@
+/**
+ * @flow
+ * @relayHash 7307f4d80765c6cfa54e21823c6b8e91
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ConcreteRequest } from 'relay-runtime';
+type emojiReactionsController_reactable$ref = any;
+export type PullRequestReviewEvent = "APPROVE" | "COMMENT" | "DISMISS" | "REQUEST_CHANGES" | "%future added value";
+export type PullRequestReviewState = "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED" | "PENDING" | "%future added value";
+export type AddPullRequestReviewInput = {|
+ pullRequestId: string,
+ commitOID?: ?any,
+ body?: ?string,
+ event?: ?PullRequestReviewEvent,
+ comments?: ?$ReadOnlyArray,
+ clientMutationId?: ?string,
+|};
+export type DraftPullRequestReviewComment = {|
+ path: string,
+ position: number,
+ body: string,
+|};
+export type addPrReviewMutationVariables = {|
+ input: AddPullRequestReviewInput
+|};
+export type addPrReviewMutationResponse = {|
+ +addPullRequestReview: ?{|
+ +reviewEdge: ?{|
+ +node: ?{|
+ +id: string,
+ +bodyHTML: any,
+ +state: PullRequestReviewState,
+ +submittedAt: ?any,
+ +author: ?{|
+ +login: string,
+ +avatarUrl: any,
+ |},
+ +$fragmentRefs: emojiReactionsController_reactable$ref,
+ |}
+ |}
+ |}
+|};
+export type addPrReviewMutation = {|
+ variables: addPrReviewMutationVariables,
+ response: addPrReviewMutationResponse,
+|};
+*/
+
+
+/*
+mutation addPrReviewMutation(
+ $input: AddPullRequestReviewInput!
+) {
+ addPullRequestReview(input: $input) {
+ reviewEdge {
+ node {
+ id
+ bodyHTML
+ state
+ submittedAt
+ author {
+ __typename
+ login
+ avatarUrl
+ ... on Node {
+ id
+ }
+ }
+ ...emojiReactionsController_reactable
+ }
+ }
+ }
+}
+
+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": "input",
+ "type": "AddPullRequestReviewInput!",
+ "defaultValue": null
+ }
+],
+v1 = [
+ {
+ "kind": "Variable",
+ "name": "input",
+ "variableName": "input",
+ "type": "AddPullRequestReviewInput!"
+ }
+],
+v2 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "id",
+ "args": null,
+ "storageKey": null
+},
+v3 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "bodyHTML",
+ "args": null,
+ "storageKey": null
+},
+v4 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "state",
+ "args": null,
+ "storageKey": null
+},
+v5 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "submittedAt",
+ "args": null,
+ "storageKey": null
+},
+v6 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "login",
+ "args": null,
+ "storageKey": null
+},
+v7 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "avatarUrl",
+ "args": null,
+ "storageKey": null
+};
+return {
+ "kind": "Request",
+ "fragment": {
+ "kind": "Fragment",
+ "name": "addPrReviewMutation",
+ "type": "Mutation",
+ "metadata": null,
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "addPullRequestReview",
+ "storageKey": null,
+ "args": (v1/*: any*/),
+ "concreteType": "AddPullRequestReviewPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "reviewEdge",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReviewEdge",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "node",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReview",
+ "plural": false,
+ "selections": [
+ (v2/*: any*/),
+ (v3/*: any*/),
+ (v4/*: any*/),
+ (v5/*: any*/),
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "author",
+ "storageKey": null,
+ "args": null,
+ "concreteType": null,
+ "plural": false,
+ "selections": [
+ (v6/*: any*/),
+ (v7/*: any*/)
+ ]
+ },
+ {
+ "kind": "FragmentSpread",
+ "name": "emojiReactionsController_reactable",
+ "args": null
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "operation": {
+ "kind": "Operation",
+ "name": "addPrReviewMutation",
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "addPullRequestReview",
+ "storageKey": null,
+ "args": (v1/*: any*/),
+ "concreteType": "AddPullRequestReviewPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "reviewEdge",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReviewEdge",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "node",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReview",
+ "plural": false,
+ "selections": [
+ (v2/*: any*/),
+ (v3/*: any*/),
+ (v4/*: any*/),
+ (v5/*: any*/),
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "author",
+ "storageKey": null,
+ "args": null,
+ "concreteType": null,
+ "plural": false,
+ "selections": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "__typename",
+ "args": null,
+ "storageKey": null
+ },
+ (v6/*: any*/),
+ (v7/*: any*/),
+ (v2/*: 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
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "params": {
+ "operationKind": "mutation",
+ "name": "addPrReviewMutation",
+ "id": null,
+ "text": "mutation addPrReviewMutation(\n $input: AddPullRequestReviewInput!\n) {\n addPullRequestReview(input: $input) {\n reviewEdge {\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 }\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 = '765a0aef6c6805d1733e99f53db0e82b';
+module.exports = node;
diff --git a/lib/mutations/__generated__/addReactionMutation.graphql.js b/lib/mutations/__generated__/addReactionMutation.graphql.js
new file mode 100644
index 0000000000..76b02071ff
--- /dev/null
+++ b/lib/mutations/__generated__/addReactionMutation.graphql.js
@@ -0,0 +1,210 @@
+/**
+ * @flow
+ * @relayHash c1ef444cd32e176865e02cfd1b9dd93c
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ConcreteRequest } from 'relay-runtime';
+export type ReactionContent = "CONFUSED" | "EYES" | "HEART" | "HOORAY" | "LAUGH" | "ROCKET" | "THUMBS_DOWN" | "THUMBS_UP" | "%future added value";
+export type AddReactionInput = {|
+ subjectId: string,
+ content: ReactionContent,
+ clientMutationId?: ?string,
+|};
+export type addReactionMutationVariables = {|
+ input: AddReactionInput
+|};
+export type addReactionMutationResponse = {|
+ +addReaction: ?{|
+ +subject: ?{|
+ +reactionGroups: ?$ReadOnlyArray<{|
+ +content: ReactionContent,
+ +viewerHasReacted: boolean,
+ +users: {|
+ +totalCount: number
+ |},
+ |}>
+ |}
+ |}
+|};
+export type addReactionMutation = {|
+ variables: addReactionMutationVariables,
+ response: addReactionMutationResponse,
+|};
+*/
+
+
+/*
+mutation addReactionMutation(
+ $input: AddReactionInput!
+) {
+ addReaction(input: $input) {
+ subject {
+ __typename
+ reactionGroups {
+ content
+ viewerHasReacted
+ users {
+ totalCount
+ }
+ }
+ id
+ }
+ }
+}
+*/
+
+const node/*: ConcreteRequest*/ = (function(){
+var v0 = [
+ {
+ "kind": "LocalArgument",
+ "name": "input",
+ "type": "AddReactionInput!",
+ "defaultValue": null
+ }
+],
+v1 = [
+ {
+ "kind": "Variable",
+ "name": "input",
+ "variableName": "input",
+ "type": "AddReactionInput!"
+ }
+],
+v2 = {
+ "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
+ }
+ ]
+ }
+ ]
+};
+return {
+ "kind": "Request",
+ "fragment": {
+ "kind": "Fragment",
+ "name": "addReactionMutation",
+ "type": "Mutation",
+ "metadata": null,
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "addReaction",
+ "storageKey": null,
+ "args": (v1/*: any*/),
+ "concreteType": "AddReactionPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "subject",
+ "storageKey": null,
+ "args": null,
+ "concreteType": null,
+ "plural": false,
+ "selections": [
+ (v2/*: any*/)
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "operation": {
+ "kind": "Operation",
+ "name": "addReactionMutation",
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "addReaction",
+ "storageKey": null,
+ "args": (v1/*: any*/),
+ "concreteType": "AddReactionPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "subject",
+ "storageKey": null,
+ "args": null,
+ "concreteType": null,
+ "plural": false,
+ "selections": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "__typename",
+ "args": null,
+ "storageKey": null
+ },
+ (v2/*: any*/),
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "id",
+ "args": null,
+ "storageKey": null
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "params": {
+ "operationKind": "mutation",
+ "name": "addReactionMutation",
+ "id": null,
+ "text": "mutation addReactionMutation(\n $input: AddReactionInput!\n) {\n addReaction(input: $input) {\n subject {\n __typename\n reactionGroups {\n content\n viewerHasReacted\n users {\n totalCount\n }\n }\n id\n }\n }\n}\n",
+ "metadata": {}
+ }
+};
+})();
+// prettier-ignore
+(node/*: any*/).hash = 'fc238aed25f2d7e854162002cb00b57f';
+module.exports = node;
diff --git a/lib/mutations/__generated__/deletePrReviewMutation.graphql.js b/lib/mutations/__generated__/deletePrReviewMutation.graphql.js
new file mode 100644
index 0000000000..e6d8447944
--- /dev/null
+++ b/lib/mutations/__generated__/deletePrReviewMutation.graphql.js
@@ -0,0 +1,119 @@
+/**
+ * @flow
+ * @relayHash e3bcd91e1d22de3764f28c655972c381
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ConcreteRequest } from 'relay-runtime';
+export type DeletePullRequestReviewInput = {|
+ pullRequestReviewId: string,
+ clientMutationId?: ?string,
+|};
+export type deletePrReviewMutationVariables = {|
+ input: DeletePullRequestReviewInput
+|};
+export type deletePrReviewMutationResponse = {|
+ +deletePullRequestReview: ?{|
+ +pullRequestReview: ?{|
+ +id: string
+ |}
+ |}
+|};
+export type deletePrReviewMutation = {|
+ variables: deletePrReviewMutationVariables,
+ response: deletePrReviewMutationResponse,
+|};
+*/
+
+
+/*
+mutation deletePrReviewMutation(
+ $input: DeletePullRequestReviewInput!
+) {
+ deletePullRequestReview(input: $input) {
+ pullRequestReview {
+ id
+ }
+ }
+}
+*/
+
+const node/*: ConcreteRequest*/ = (function(){
+var v0 = [
+ {
+ "kind": "LocalArgument",
+ "name": "input",
+ "type": "DeletePullRequestReviewInput!",
+ "defaultValue": null
+ }
+],
+v1 = [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "deletePullRequestReview",
+ "storageKey": null,
+ "args": [
+ {
+ "kind": "Variable",
+ "name": "input",
+ "variableName": "input",
+ "type": "DeletePullRequestReviewInput!"
+ }
+ ],
+ "concreteType": "DeletePullRequestReviewPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "pullRequestReview",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReview",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "id",
+ "args": null,
+ "storageKey": null
+ }
+ ]
+ }
+ ]
+ }
+];
+return {
+ "kind": "Request",
+ "fragment": {
+ "kind": "Fragment",
+ "name": "deletePrReviewMutation",
+ "type": "Mutation",
+ "metadata": null,
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": (v1/*: any*/)
+ },
+ "operation": {
+ "kind": "Operation",
+ "name": "deletePrReviewMutation",
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": (v1/*: any*/)
+ },
+ "params": {
+ "operationKind": "mutation",
+ "name": "deletePrReviewMutation",
+ "id": null,
+ "text": "mutation deletePrReviewMutation(\n $input: DeletePullRequestReviewInput!\n) {\n deletePullRequestReview(input: $input) {\n pullRequestReview {\n id\n }\n }\n}\n",
+ "metadata": {}
+ }
+};
+})();
+// prettier-ignore
+(node/*: any*/).hash = '768b81334e225cb5d15c0508d2bd4b1f';
+module.exports = node;
diff --git a/lib/mutations/__generated__/removeReactionMutation.graphql.js b/lib/mutations/__generated__/removeReactionMutation.graphql.js
new file mode 100644
index 0000000000..41a031486a
--- /dev/null
+++ b/lib/mutations/__generated__/removeReactionMutation.graphql.js
@@ -0,0 +1,210 @@
+/**
+ * @flow
+ * @relayHash 1ce71722258b9dff73023ba70b4540b1
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ConcreteRequest } from 'relay-runtime';
+export type ReactionContent = "CONFUSED" | "EYES" | "HEART" | "HOORAY" | "LAUGH" | "ROCKET" | "THUMBS_DOWN" | "THUMBS_UP" | "%future added value";
+export type RemoveReactionInput = {|
+ subjectId: string,
+ content: ReactionContent,
+ clientMutationId?: ?string,
+|};
+export type removeReactionMutationVariables = {|
+ input: RemoveReactionInput
+|};
+export type removeReactionMutationResponse = {|
+ +removeReaction: ?{|
+ +subject: ?{|
+ +reactionGroups: ?$ReadOnlyArray<{|
+ +content: ReactionContent,
+ +viewerHasReacted: boolean,
+ +users: {|
+ +totalCount: number
+ |},
+ |}>
+ |}
+ |}
+|};
+export type removeReactionMutation = {|
+ variables: removeReactionMutationVariables,
+ response: removeReactionMutationResponse,
+|};
+*/
+
+
+/*
+mutation removeReactionMutation(
+ $input: RemoveReactionInput!
+) {
+ removeReaction(input: $input) {
+ subject {
+ __typename
+ reactionGroups {
+ content
+ viewerHasReacted
+ users {
+ totalCount
+ }
+ }
+ id
+ }
+ }
+}
+*/
+
+const node/*: ConcreteRequest*/ = (function(){
+var v0 = [
+ {
+ "kind": "LocalArgument",
+ "name": "input",
+ "type": "RemoveReactionInput!",
+ "defaultValue": null
+ }
+],
+v1 = [
+ {
+ "kind": "Variable",
+ "name": "input",
+ "variableName": "input",
+ "type": "RemoveReactionInput!"
+ }
+],
+v2 = {
+ "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
+ }
+ ]
+ }
+ ]
+};
+return {
+ "kind": "Request",
+ "fragment": {
+ "kind": "Fragment",
+ "name": "removeReactionMutation",
+ "type": "Mutation",
+ "metadata": null,
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "removeReaction",
+ "storageKey": null,
+ "args": (v1/*: any*/),
+ "concreteType": "RemoveReactionPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "subject",
+ "storageKey": null,
+ "args": null,
+ "concreteType": null,
+ "plural": false,
+ "selections": [
+ (v2/*: any*/)
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "operation": {
+ "kind": "Operation",
+ "name": "removeReactionMutation",
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "removeReaction",
+ "storageKey": null,
+ "args": (v1/*: any*/),
+ "concreteType": "RemoveReactionPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "subject",
+ "storageKey": null,
+ "args": null,
+ "concreteType": null,
+ "plural": false,
+ "selections": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "__typename",
+ "args": null,
+ "storageKey": null
+ },
+ (v2/*: any*/),
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "id",
+ "args": null,
+ "storageKey": null
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "params": {
+ "operationKind": "mutation",
+ "name": "removeReactionMutation",
+ "id": null,
+ "text": "mutation removeReactionMutation(\n $input: RemoveReactionInput!\n) {\n removeReaction(input: $input) {\n subject {\n __typename\n reactionGroups {\n content\n viewerHasReacted\n users {\n totalCount\n }\n }\n id\n }\n }\n}\n",
+ "metadata": {}
+ }
+};
+})();
+// prettier-ignore
+(node/*: any*/).hash = 'f20b76a0ff63579992f4631894495523';
+module.exports = node;
diff --git a/lib/mutations/__generated__/resolveReviewThreadMutation.graphql.js b/lib/mutations/__generated__/resolveReviewThreadMutation.graphql.js
new file mode 100644
index 0000000000..a980acba9c
--- /dev/null
+++ b/lib/mutations/__generated__/resolveReviewThreadMutation.graphql.js
@@ -0,0 +1,174 @@
+/**
+ * @flow
+ * @relayHash d51c8bf97ef11e513beda627761908bf
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ConcreteRequest } from 'relay-runtime';
+export type ResolveReviewThreadInput = {|
+ threadId: string,
+ clientMutationId?: ?string,
+|};
+export type resolveReviewThreadMutationVariables = {|
+ input: ResolveReviewThreadInput
+|};
+export type resolveReviewThreadMutationResponse = {|
+ +resolveReviewThread: ?{|
+ +thread: ?{|
+ +id: string,
+ +isResolved: boolean,
+ +viewerCanResolve: boolean,
+ +viewerCanUnresolve: boolean,
+ +resolvedBy: ?{|
+ +id: string,
+ +login: string,
+ |},
+ |}
+ |}
+|};
+export type resolveReviewThreadMutation = {|
+ variables: resolveReviewThreadMutationVariables,
+ response: resolveReviewThreadMutationResponse,
+|};
+*/
+
+
+/*
+mutation resolveReviewThreadMutation(
+ $input: ResolveReviewThreadInput!
+) {
+ resolveReviewThread(input: $input) {
+ thread {
+ id
+ isResolved
+ viewerCanResolve
+ viewerCanUnresolve
+ resolvedBy {
+ id
+ login
+ }
+ }
+ }
+}
+*/
+
+const node/*: ConcreteRequest*/ = (function(){
+var v0 = [
+ {
+ "kind": "LocalArgument",
+ "name": "input",
+ "type": "ResolveReviewThreadInput!",
+ "defaultValue": null
+ }
+],
+v1 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "id",
+ "args": null,
+ "storageKey": null
+},
+v2 = [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "resolveReviewThread",
+ "storageKey": null,
+ "args": [
+ {
+ "kind": "Variable",
+ "name": "input",
+ "variableName": "input",
+ "type": "ResolveReviewThreadInput!"
+ }
+ ],
+ "concreteType": "ResolveReviewThreadPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "thread",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReviewThread",
+ "plural": false,
+ "selections": [
+ (v1/*: any*/),
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "isResolved",
+ "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": "LinkedField",
+ "alias": null,
+ "name": "resolvedBy",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "User",
+ "plural": false,
+ "selections": [
+ (v1/*: any*/),
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "login",
+ "args": null,
+ "storageKey": null
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+];
+return {
+ "kind": "Request",
+ "fragment": {
+ "kind": "Fragment",
+ "name": "resolveReviewThreadMutation",
+ "type": "Mutation",
+ "metadata": null,
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": (v2/*: any*/)
+ },
+ "operation": {
+ "kind": "Operation",
+ "name": "resolveReviewThreadMutation",
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": (v2/*: any*/)
+ },
+ "params": {
+ "operationKind": "mutation",
+ "name": "resolveReviewThreadMutation",
+ "id": null,
+ "text": "mutation resolveReviewThreadMutation(\n $input: ResolveReviewThreadInput!\n) {\n resolveReviewThread(input: $input) {\n thread {\n id\n isResolved\n viewerCanResolve\n viewerCanUnresolve\n resolvedBy {\n id\n login\n }\n }\n }\n}\n",
+ "metadata": {}
+ }
+};
+})();
+// prettier-ignore
+(node/*: any*/).hash = '6947ef6710d494dc52fba1a5b532cd76';
+module.exports = node;
diff --git a/lib/mutations/__generated__/submitPrReviewMutation.graphql.js b/lib/mutations/__generated__/submitPrReviewMutation.graphql.js
new file mode 100644
index 0000000000..ed9f40d7a0
--- /dev/null
+++ b/lib/mutations/__generated__/submitPrReviewMutation.graphql.js
@@ -0,0 +1,122 @@
+/**
+ * @flow
+ * @relayHash 6f3f4343454b0d161166315c082e4b6e
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ConcreteRequest } from 'relay-runtime';
+export type PullRequestReviewEvent = "APPROVE" | "COMMENT" | "DISMISS" | "REQUEST_CHANGES" | "%future added value";
+export type SubmitPullRequestReviewInput = {|
+ pullRequestReviewId: string,
+ event: PullRequestReviewEvent,
+ body?: ?string,
+ clientMutationId?: ?string,
+|};
+export type submitPrReviewMutationVariables = {|
+ input: SubmitPullRequestReviewInput
+|};
+export type submitPrReviewMutationResponse = {|
+ +submitPullRequestReview: ?{|
+ +pullRequestReview: ?{|
+ +id: string
+ |}
+ |}
+|};
+export type submitPrReviewMutation = {|
+ variables: submitPrReviewMutationVariables,
+ response: submitPrReviewMutationResponse,
+|};
+*/
+
+
+/*
+mutation submitPrReviewMutation(
+ $input: SubmitPullRequestReviewInput!
+) {
+ submitPullRequestReview(input: $input) {
+ pullRequestReview {
+ id
+ }
+ }
+}
+*/
+
+const node/*: ConcreteRequest*/ = (function(){
+var v0 = [
+ {
+ "kind": "LocalArgument",
+ "name": "input",
+ "type": "SubmitPullRequestReviewInput!",
+ "defaultValue": null
+ }
+],
+v1 = [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "submitPullRequestReview",
+ "storageKey": null,
+ "args": [
+ {
+ "kind": "Variable",
+ "name": "input",
+ "variableName": "input",
+ "type": "SubmitPullRequestReviewInput!"
+ }
+ ],
+ "concreteType": "SubmitPullRequestReviewPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "pullRequestReview",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReview",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "id",
+ "args": null,
+ "storageKey": null
+ }
+ ]
+ }
+ ]
+ }
+];
+return {
+ "kind": "Request",
+ "fragment": {
+ "kind": "Fragment",
+ "name": "submitPrReviewMutation",
+ "type": "Mutation",
+ "metadata": null,
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": (v1/*: any*/)
+ },
+ "operation": {
+ "kind": "Operation",
+ "name": "submitPrReviewMutation",
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": (v1/*: any*/)
+ },
+ "params": {
+ "operationKind": "mutation",
+ "name": "submitPrReviewMutation",
+ "id": null,
+ "text": "mutation submitPrReviewMutation(\n $input: SubmitPullRequestReviewInput!\n) {\n submitPullRequestReview(input: $input) {\n pullRequestReview {\n id\n }\n }\n}\n",
+ "metadata": {}
+ }
+};
+})();
+// prettier-ignore
+(node/*: any*/).hash = 'c52752b3b2cde11e6c86d574ffa967a0';
+module.exports = node;
diff --git a/lib/mutations/__generated__/unresolveReviewThreadMutation.graphql.js b/lib/mutations/__generated__/unresolveReviewThreadMutation.graphql.js
new file mode 100644
index 0000000000..0ecc5132b2
--- /dev/null
+++ b/lib/mutations/__generated__/unresolveReviewThreadMutation.graphql.js
@@ -0,0 +1,174 @@
+/**
+ * @flow
+ * @relayHash b69c6f7d17913840e7811e0c27dc7a06
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ConcreteRequest } from 'relay-runtime';
+export type UnresolveReviewThreadInput = {|
+ threadId: string,
+ clientMutationId?: ?string,
+|};
+export type unresolveReviewThreadMutationVariables = {|
+ input: UnresolveReviewThreadInput
+|};
+export type unresolveReviewThreadMutationResponse = {|
+ +unresolveReviewThread: ?{|
+ +thread: ?{|
+ +id: string,
+ +isResolved: boolean,
+ +viewerCanResolve: boolean,
+ +viewerCanUnresolve: boolean,
+ +resolvedBy: ?{|
+ +id: string,
+ +login: string,
+ |},
+ |}
+ |}
+|};
+export type unresolveReviewThreadMutation = {|
+ variables: unresolveReviewThreadMutationVariables,
+ response: unresolveReviewThreadMutationResponse,
+|};
+*/
+
+
+/*
+mutation unresolveReviewThreadMutation(
+ $input: UnresolveReviewThreadInput!
+) {
+ unresolveReviewThread(input: $input) {
+ thread {
+ id
+ isResolved
+ viewerCanResolve
+ viewerCanUnresolve
+ resolvedBy {
+ id
+ login
+ }
+ }
+ }
+}
+*/
+
+const node/*: ConcreteRequest*/ = (function(){
+var v0 = [
+ {
+ "kind": "LocalArgument",
+ "name": "input",
+ "type": "UnresolveReviewThreadInput!",
+ "defaultValue": null
+ }
+],
+v1 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "id",
+ "args": null,
+ "storageKey": null
+},
+v2 = [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "unresolveReviewThread",
+ "storageKey": null,
+ "args": [
+ {
+ "kind": "Variable",
+ "name": "input",
+ "variableName": "input",
+ "type": "UnresolveReviewThreadInput!"
+ }
+ ],
+ "concreteType": "UnresolveReviewThreadPayload",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "thread",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestReviewThread",
+ "plural": false,
+ "selections": [
+ (v1/*: any*/),
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "isResolved",
+ "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": "LinkedField",
+ "alias": null,
+ "name": "resolvedBy",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "User",
+ "plural": false,
+ "selections": [
+ (v1/*: any*/),
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "login",
+ "args": null,
+ "storageKey": null
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+];
+return {
+ "kind": "Request",
+ "fragment": {
+ "kind": "Fragment",
+ "name": "unresolveReviewThreadMutation",
+ "type": "Mutation",
+ "metadata": null,
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": (v2/*: any*/)
+ },
+ "operation": {
+ "kind": "Operation",
+ "name": "unresolveReviewThreadMutation",
+ "argumentDefinitions": (v0/*: any*/),
+ "selections": (v2/*: any*/)
+ },
+ "params": {
+ "operationKind": "mutation",
+ "name": "unresolveReviewThreadMutation",
+ "id": null,
+ "text": "mutation unresolveReviewThreadMutation(\n $input: UnresolveReviewThreadInput!\n) {\n unresolveReviewThread(input: $input) {\n thread {\n id\n isResolved\n viewerCanResolve\n viewerCanUnresolve\n resolvedBy {\n id\n login\n }\n }\n }\n}\n",
+ "metadata": {}
+ }
+};
+})();
+// prettier-ignore
+(node/*: any*/).hash = '8b1105e1a3db0455c522c7e5dc69b436';
+module.exports = node;
diff --git a/lib/mutations/add-pr-review-comment.js b/lib/mutations/add-pr-review-comment.js
new file mode 100644
index 0000000000..8a1f63d3f6
--- /dev/null
+++ b/lib/mutations/add-pr-review-comment.js
@@ -0,0 +1,96 @@
+/* istanbul ignore file */
+
+import {commitMutation, graphql} from 'react-relay';
+import {ConnectionHandler} from 'relay-runtime';
+import moment from 'moment';
+
+const mutation = graphql`
+ mutation addPrReviewCommentMutation($input: AddPullRequestReviewCommentInput!) {
+ addPullRequestReviewComment(input: $input) {
+ commentEdge {
+ node {
+ id
+ author {
+ avatarUrl
+ login
+ }
+ bodyHTML
+ isMinimized
+ viewerCanReact
+ path
+ position
+ createdAt
+ url
+ ...emojiReactionsController_reactable
+ }
+ }
+ }
+ }
+`;
+
+let placeholderID = 0;
+
+export default (environment, {body, inReplyTo, reviewID, threadID, viewerID, path, position}) => {
+ const variables = {
+ input: {
+ body,
+ inReplyTo,
+ pullRequestReviewId: reviewID,
+ },
+ };
+
+ const configs = [{
+ type: 'RANGE_ADD',
+ parentID: threadID,
+ connectionInfo: [{key: 'ReviewCommentsAccumulator_comments', rangeBehavior: 'append'}],
+ edgeName: 'commentEdge',
+ }];
+
+ function optimisticUpdater(store) {
+ const reviewThread = store.get(threadID);
+ if (!reviewThread) {
+ return;
+ }
+
+ const id = `add-pr-review-comment:comment:${placeholderID++}`;
+ const comment = store.create(id, 'PullRequestReviewComment');
+ comment.setValue(id, 'id');
+ comment.setValue(`${body} `, 'bodyHTML');
+ comment.setValue(false, 'isMinimized');
+ comment.setValue(false, 'viewerCanMinimize');
+ comment.setValue(false, 'viewerCanReact');
+ comment.setValue(moment().toISOString(), 'createdAt');
+ comment.setValue('https://github.com', 'url');
+ comment.setValue(path, 'path');
+ comment.setValue(position, 'position');
+ comment.setLinkedRecords([], 'reactionGroups');
+
+ let author;
+ if (viewerID) {
+ author = store.get(viewerID);
+ } else {
+ author = store.create(`add-pr-review-comment:author:${placeholderID++}`, 'User');
+ author.setValue('...', 'login');
+ author.setValue('atom://github/img/avatar.svg', 'avatarUrl');
+ }
+ comment.setLinkedRecord(author, 'author');
+
+ const comments = ConnectionHandler.getConnection(reviewThread, 'ReviewCommentsAccumulator_comments');
+ const edge = ConnectionHandler.createEdge(store, comments, comment, 'PullRequestReviewCommentEdge');
+ ConnectionHandler.insertEdgeAfter(comments, edge);
+ }
+
+ return new Promise((resolve, reject) => {
+ commitMutation(
+ environment,
+ {
+ mutation,
+ variables,
+ configs,
+ optimisticUpdater,
+ onCompleted: resolve,
+ onError: reject,
+ },
+ );
+ });
+};
diff --git a/lib/mutations/add-pr-review.js b/lib/mutations/add-pr-review.js
new file mode 100644
index 0000000000..41631f4c6a
--- /dev/null
+++ b/lib/mutations/add-pr-review.js
@@ -0,0 +1,88 @@
+/* istanbul ignore file */
+
+import {commitMutation, graphql} from 'react-relay';
+import {ConnectionHandler} from 'relay-runtime';
+
+const mutation = graphql`
+ mutation addPrReviewMutation($input: AddPullRequestReviewInput!) {
+ addPullRequestReview(input: $input) {
+ reviewEdge {
+ node {
+ id
+ bodyHTML
+ state
+ submittedAt
+ author {
+ login
+ avatarUrl
+ }
+ ...emojiReactionsController_reactable
+ }
+ }
+ }
+ }
+`;
+
+let placeholderID = 0;
+
+export default (environment, {body, event, pullRequestID, viewerID}) => {
+ const variables = {
+ input: {pullRequestId: pullRequestID},
+ };
+
+ if (body) {
+ variables.input.body = body;
+ }
+ if (event) {
+ variables.input.event = event;
+ }
+
+ const configs = [{
+ type: 'RANGE_ADD',
+ parentID: pullRequestID,
+ connectionInfo: [{key: 'ReviewSummariesAccumulator_reviews', rangeBehavior: 'append'}],
+ edgeName: 'reviewEdge',
+ }];
+
+ function optimisticUpdater(store) {
+ const pullRequest = store.get(pullRequestID);
+ if (!pullRequest) {
+ return;
+ }
+
+ const id = `add-pr-review:review:${placeholderID++}`;
+ const review = store.create(id, 'PullRequestReview');
+ review.setValue(id, 'id');
+ review.setValue('PENDING', 'state');
+ review.setValue(body || '...', 'bodyHTML');
+ review.setLinkedRecords([], 'reactionGroups');
+
+ let author;
+ if (viewerID) {
+ author = store.get(viewerID);
+ } else {
+ author = store.create(`add-pr-review-comment:author:${placeholderID++}`, 'User');
+ author.setValue('...', 'login');
+ author.setValue('atom://github/img/avatar.svg', 'avatarUrl');
+ }
+ review.setLinkedRecord(author, 'author');
+
+ const reviews = ConnectionHandler.getConnection(pullRequest, 'ReviewSummariesAccumulator_reviews');
+ const edge = ConnectionHandler.createEdge(store, reviews, review, 'PullRequestReviewEdge');
+ ConnectionHandler.insertEdgeAfter(reviews, edge);
+ }
+
+ return new Promise((resolve, reject) => {
+ commitMutation(
+ environment,
+ {
+ mutation,
+ variables,
+ configs,
+ optimisticUpdater,
+ onCompleted: resolve,
+ onError: reject,
+ },
+ );
+ });
+};
diff --git a/lib/mutations/add-reaction.js b/lib/mutations/add-reaction.js
new file mode 100644
index 0000000000..667db51842
--- /dev/null
+++ b/lib/mutations/add-reaction.js
@@ -0,0 +1,66 @@
+/* istanbul ignore file */
+
+import {commitMutation, graphql} from 'react-relay';
+
+const mutation = graphql`
+ mutation addReactionMutation($input: AddReactionInput!) {
+ addReaction(input: $input) {
+ subject {
+ reactionGroups {
+ content
+ viewerHasReacted
+ users {
+ totalCount
+ }
+ }
+ }
+ }
+ }
+`;
+
+let placeholderID = 0;
+
+export default (environment, subjectId, content) => {
+ const variables = {
+ input: {
+ content,
+ subjectId,
+ },
+ };
+
+ function optimisticUpdater(store) {
+ const subject = store.get(subjectId);
+ const reactionGroups = subject.getLinkedRecords('reactionGroups') || [];
+ const reactionGroup = reactionGroups.find(group => group.getValue('content') === content);
+ if (!reactionGroup) {
+ const group = store.create(`add-reaction:reaction-group:${placeholderID++}`, 'ReactionGroup');
+ group.setValue(true, 'viewerHasReacted');
+ group.setValue(content, 'content');
+
+ const conn = store.create(`add-reaction:reacting-user-conn:${placeholderID++}`, 'ReactingUserConnection');
+ conn.setValue(1, 'totalCount');
+ group.setLinkedRecord(conn, 'users');
+
+ subject.setLinkedRecords([...reactionGroups, group], 'reactionGroups');
+
+ return;
+ }
+
+ reactionGroup.setValue(true, 'viewerHasReacted');
+ const conn = reactionGroup.getLinkedRecord('users');
+ conn.setValue(conn.getValue('totalCount') + 1, 'totalCount');
+ }
+
+ return new Promise((resolve, reject) => {
+ commitMutation(
+ environment,
+ {
+ mutation,
+ variables,
+ optimisticUpdater,
+ onCompleted: resolve,
+ onError: reject,
+ },
+ );
+ });
+};
diff --git a/lib/mutations/delete-pr-review.js b/lib/mutations/delete-pr-review.js
new file mode 100644
index 0000000000..29c7766b56
--- /dev/null
+++ b/lib/mutations/delete-pr-review.js
@@ -0,0 +1,46 @@
+/* istanbul ignore file */
+
+import {commitMutation, graphql} from 'react-relay';
+
+const mutation = graphql`
+ mutation deletePrReviewMutation($input: DeletePullRequestReviewInput!) {
+ deletePullRequestReview(input: $input) {
+ pullRequestReview {
+ id
+ }
+ }
+ }
+`;
+
+export default (environment, {reviewID, pullRequestID}) => {
+ const variables = {
+ input: {pullRequestReviewId: reviewID},
+ };
+
+ const configs = [
+ {
+ type: 'NODE_DELETE',
+ deletedIDFieldName: 'id',
+ },
+ {
+ type: 'RANGE_DELETE',
+ parentID: pullRequestID,
+ connectionKeys: [{key: 'ReviewSummariesAccumulator_reviews'}],
+ pathToConnection: ['pullRequest', 'reviews'],
+ deletedIDFieldName: 'id',
+ },
+ ];
+
+ return new Promise((resolve, reject) => {
+ commitMutation(
+ environment,
+ {
+ mutation,
+ variables,
+ configs,
+ onCompleted: resolve,
+ onError: reject,
+ },
+ );
+ });
+};
diff --git a/lib/mutations/remove-reaction.js b/lib/mutations/remove-reaction.js
new file mode 100644
index 0000000000..0980283445
--- /dev/null
+++ b/lib/mutations/remove-reaction.js
@@ -0,0 +1,54 @@
+/* istanbul ignore file */
+
+import {commitMutation, graphql} from 'react-relay';
+
+const mutation = graphql`
+ mutation removeReactionMutation($input: RemoveReactionInput!) {
+ removeReaction(input: $input) {
+ subject {
+ reactionGroups {
+ content
+ viewerHasReacted
+ users {
+ totalCount
+ }
+ }
+ }
+ }
+ }
+`;
+
+export default (environment, subjectId, content) => {
+ const variables = {
+ input: {
+ content,
+ subjectId,
+ },
+ };
+
+ function optimisticUpdater(store) {
+ const subject = store.get(subjectId);
+ const reactionGroups = subject.getLinkedRecords('reactionGroups') || [];
+ const reactionGroup = reactionGroups.find(group => group.getValue('content') === content);
+ if (!reactionGroup) {
+ return;
+ }
+
+ reactionGroup.setValue(false, 'viewerHasReacted');
+ const conn = reactionGroup.getLinkedRecord('users');
+ conn.setValue(conn.getValue('totalCount') - 1, 'totalCount');
+ }
+
+ return new Promise((resolve, reject) => {
+ commitMutation(
+ environment,
+ {
+ mutation,
+ variables,
+ optimisticUpdater,
+ onCompleted: resolve,
+ onError: reject,
+ },
+ );
+ });
+};
diff --git a/lib/mutations/resolve-review-thread.js b/lib/mutations/resolve-review-thread.js
new file mode 100644
index 0000000000..7d974a4aab
--- /dev/null
+++ b/lib/mutations/resolve-review-thread.js
@@ -0,0 +1,59 @@
+/* istanbul ignore file */
+
+import {
+ commitMutation,
+ graphql,
+} from 'react-relay';
+
+const mutation = graphql`
+ mutation resolveReviewThreadMutation($input: ResolveReviewThreadInput!) {
+ resolveReviewThread(input: $input) {
+ thread {
+ id
+ isResolved
+ viewerCanResolve
+ viewerCanUnresolve
+ resolvedBy {
+ id
+ login
+ }
+ }
+ }
+ }
+`;
+
+export default (environment, {threadID, viewerID, viewerLogin}) => {
+ const variables = {
+ input: {
+ threadId: threadID,
+ },
+ };
+
+ const optimisticResponse = {
+ resolveReviewThread: {
+ thread: {
+ id: threadID,
+ isResolved: true,
+ viewerCanResolve: false,
+ viewerCanUnresolve: true,
+ resolvedBy: {
+ id: viewerID,
+ login: viewerLogin || 'you',
+ },
+ },
+ },
+ };
+
+ return new Promise((resolve, reject) => {
+ commitMutation(
+ environment,
+ {
+ mutation,
+ variables,
+ optimisticResponse,
+ onCompleted: resolve,
+ onError: reject,
+ },
+ );
+ });
+};
diff --git a/lib/mutations/submit-pr-review.js b/lib/mutations/submit-pr-review.js
new file mode 100644
index 0000000000..cf5e6e7a98
--- /dev/null
+++ b/lib/mutations/submit-pr-review.js
@@ -0,0 +1,34 @@
+/* istanbul ignore file */
+
+import {commitMutation, graphql} from 'react-relay';
+
+const mutation = graphql`
+ mutation submitPrReviewMutation($input: SubmitPullRequestReviewInput!) {
+ submitPullRequestReview(input: $input) {
+ pullRequestReview {
+ id
+ }
+ }
+ }
+`;
+
+export default (environment, {reviewID, event}) => {
+ const variables = {
+ input: {
+ event,
+ pullRequestReviewId: reviewID,
+ },
+ };
+
+ return new Promise((resolve, reject) => {
+ commitMutation(
+ environment,
+ {
+ mutation,
+ variables,
+ onCompleted: resolve,
+ onError: reject,
+ },
+ );
+ });
+};
diff --git a/lib/mutations/unresolve-review-thread.js b/lib/mutations/unresolve-review-thread.js
new file mode 100644
index 0000000000..8e9ab1db75
--- /dev/null
+++ b/lib/mutations/unresolve-review-thread.js
@@ -0,0 +1,56 @@
+/* istanbul ignore file */
+
+import {commitMutation, graphql} from 'react-relay';
+
+const mutation = graphql`
+ mutation unresolveReviewThreadMutation($input: UnresolveReviewThreadInput!) {
+ unresolveReviewThread(input: $input) {
+ thread {
+ id
+ isResolved
+ viewerCanResolve
+ viewerCanUnresolve
+ resolvedBy {
+ id
+ login
+ }
+ }
+ }
+ }
+`;
+
+export default (environment, {threadID, viewerID, viewerLogin}) => {
+ const variables = {
+ input: {
+ threadId: threadID,
+ },
+ };
+
+ const optimisticResponse = {
+ unresolveReviewThread: {
+ thread: {
+ id: threadID,
+ isResolved: false,
+ viewerCanResolve: true,
+ viewerCanUnresolve: false,
+ resolvedBy: {
+ id: viewerID,
+ login: viewerLogin || 'you',
+ },
+ },
+ },
+ };
+
+ return new Promise((resolve, reject) => {
+ commitMutation(
+ environment,
+ {
+ mutation,
+ variables,
+ optimisticResponse,
+ onCompleted: resolve,
+ onError: reject,
+ },
+ );
+ });
+};
diff --git a/lib/relay-network-layer-manager.js b/lib/relay-network-layer-manager.js
index a79d8190f2..44a5ed2241 100644
--- a/lib/relay-network-layer-manager.js
+++ b/lib/relay-network-layer-manager.js
@@ -2,6 +2,9 @@ import util from 'util';
import {Environment, Network, RecordSource, Store} from 'relay-runtime';
import moment from 'moment';
+const LODASH_ISEQUAL = 'lodash.isequal';
+let isEqual = null;
+
const relayEnvironmentPerURL = new Map();
const tokenPerURL = new Map();
const fetchPerURL = new Map();
@@ -20,15 +23,17 @@ function logRatelimitApi(headers) {
export function expectRelayQuery(operationPattern, response) {
let resolve, reject;
+ const handler = typeof response === 'function' ? response : () => ({data: response});
+
const promise = new Promise((resolve0, reject0) => {
- resolve = () => resolve0({data: response});
+ resolve = resolve0;
reject = reject0;
});
const existing = responsesByQuery.get(operationPattern.name) || [];
existing.push({
promise,
- response,
+ handler,
variables: operationPattern.variables || {},
trace: operationPattern.trace,
});
@@ -48,17 +53,13 @@ function createFetchQuery(url) {
return function specFetchQuery(operation, variables, _cacheConfig, _uploadables) {
const expectations = responsesByQuery.get(operation.name) || [];
const match = expectations.find(expectation => {
- if (Object.keys(expectation.variables).length !== Object.keys(variables).length) {
- return false;
- }
-
- for (const key in expectation.variables) {
- if (expectation.variables[key] !== variables[key]) {
- return false;
- }
+ if (isEqual === null) {
+ // Lazily require lodash.isequal so we can keep it as a dev dependency.
+ // Require indirectly to trick electron-link into not following this.
+ isEqual = require(LODASH_ISEQUAL);
}
- return true;
+ return isEqual(expectation.variables, variables);
});
if (!match) {
@@ -73,18 +74,24 @@ function createFetchQuery(url) {
throw e;
}
+ const responsePromise = match.promise.then(() => {
+ return match.handler(operation);
+ });
+
if (match.trace) {
- match.promise.then(result => {
+ // eslint-disable-next-line no-console
+ console.log(`[Relay] query "${operation.name}":\n${operation.text}`);
+ responsePromise.then(result => {
+ // eslint-disable-next-line no-console
+ console.log(`[Relay] response "${operation.name}":`, result);
+ }, err => {
// eslint-disable-next-line no-console
- console.log(
- `GraphQL query ${operation.name} was:\n` +
- util.inspect(variables) + '\n' +
- util.inspect(result, {depth: null}),
- );
+ console.error(`[Relay] error "${operation.name}":\n${err.stack || err}`);
+ throw err;
});
}
- return match.promise;
+ return responsePromise;
};
}
diff --git a/lib/views/__generated__/emojiReactionsView_reactable.graphql.js b/lib/views/__generated__/emojiReactionsView_reactable.graphql.js
new file mode 100644
index 0000000000..2c12ac3640
--- /dev/null
+++ b/lib/views/__generated__/emojiReactionsView_reactable.graphql.js
@@ -0,0 +1,97 @@
+/**
+ * @flow
+ */
+
+/* eslint-disable */
+
+'use strict';
+
+/*::
+import type { ReaderFragment } from 'relay-runtime';
+export type ReactionContent = "CONFUSED" | "EYES" | "HEART" | "HOORAY" | "LAUGH" | "ROCKET" | "THUMBS_DOWN" | "THUMBS_UP" | "%future added value";
+import type { FragmentReference } from "relay-runtime";
+declare export opaque type emojiReactionsView_reactable$ref: FragmentReference;
+export type emojiReactionsView_reactable = {|
+ +id: string,
+ +reactionGroups: ?$ReadOnlyArray<{|
+ +content: ReactionContent,
+ +viewerHasReacted: boolean,
+ +users: {|
+ +totalCount: number
+ |},
+ |}>,
+ +viewerCanReact: boolean,
+ +$refType: emojiReactionsView_reactable$ref,
+|};
+*/
+
+
+const node/*: ReaderFragment*/ = {
+ "kind": "Fragment",
+ "name": "emojiReactionsView_reactable",
+ "type": "Reactable",
+ "metadata": null,
+ "argumentDefinitions": [],
+ "selections": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "id",
+ "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
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "viewerCanReact",
+ "args": null,
+ "storageKey": null
+ }
+ ]
+};
+// prettier-ignore
+(node/*: any*/).hash = 'fde156007f42d841401632fce79875d5';
+module.exports = node;
diff --git a/lib/views/__generated__/issueDetailViewRefetchQuery.graphql.js b/lib/views/__generated__/issueDetailViewRefetchQuery.graphql.js
index 861efb7789..3d4954689f 100644
--- a/lib/views/__generated__/issueDetailViewRefetchQuery.graphql.js
+++ b/lib/views/__generated__/issueDetailViewRefetchQuery.graphql.js
@@ -1,6 +1,6 @@
/**
* @flow
- * @relayHash 52b4d4eae123e9624bea3c36531049e8
+ * @relayHash f8c997ba55500bb1826e00574873187f
*/
/* eslint-disable */
@@ -62,10 +62,9 @@ fragment issueDetailView_repository_3D8CP9 on Repository {
}
fragment issueDetailView_issue_3D8CP9 on Issue {
+ id
__typename
- ... on Node {
- id
- }
+ url
state
number
title
@@ -74,28 +73,13 @@ fragment issueDetailView_issue_3D8CP9 on Issue {
__typename
login
avatarUrl
- ... on User {
- url
- }
- ... on Bot {
- url
- }
+ url
... on Node {
id
}
}
...issueTimelineController_issue_3D8CP9
- ... on UniformResourceLocatable {
- url
- }
- ... on Reactable {
- reactionGroups {
- content
- users {
- totalCount
- }
- }
- }
+ ...emojiReactionsView_reactable
}
fragment issueTimelineController_issue_3D8CP9 on Issue {
@@ -120,6 +104,18 @@ fragment issueTimelineController_issue_3D8CP9 on Issue {
}
}
+fragment emojiReactionsView_reactable on Reactable {
+ id
+ reactionGroups {
+ content
+ viewerHasReacted
+ users {
+ totalCount
+ }
+ }
+ viewerCanReact
+}
+
fragment commitsView_nodes on Commit {
id
author {
@@ -340,42 +336,39 @@ v8 = {
v9 = {
"kind": "ScalarField",
"alias": null,
- "name": "number",
+ "name": "title",
"args": null,
"storageKey": null
},
v10 = {
"kind": "ScalarField",
"alias": null,
- "name": "title",
+ "name": "url",
"args": null,
"storageKey": null
},
v11 = {
"kind": "ScalarField",
"alias": null,
- "name": "bodyHTML",
+ "name": "number",
"args": null,
"storageKey": null
},
v12 = {
"kind": "ScalarField",
"alias": null,
- "name": "avatarUrl",
+ "name": "bodyHTML",
"args": null,
"storageKey": null
},
v13 = {
"kind": "ScalarField",
"alias": null,
- "name": "url",
+ "name": "avatarUrl",
"args": null,
"storageKey": null
},
v14 = [
- (v13/*: any*/)
-],
-v15 = [
{
"kind": "Variable",
"name": "after",
@@ -389,7 +382,7 @@ v15 = [
"type": "Int"
}
],
-v16 = {
+v15 = {
"kind": "LinkedField",
"alias": null,
"name": "user",
@@ -486,7 +479,8 @@ return {
"kind": "InlineFragment",
"type": "Issue",
"selections": [
- (v4/*: any*/),
+ (v9/*: any*/),
+ (v10/*: any*/),
{
"kind": "ScalarField",
"alias": null,
@@ -494,9 +488,9 @@ return {
"args": null,
"storageKey": null
},
- (v9/*: any*/),
- (v10/*: any*/),
(v11/*: any*/),
+ (v4/*: any*/),
+ (v12/*: any*/),
{
"kind": "LinkedField",
"alias": null,
@@ -508,27 +502,17 @@ return {
"selections": [
(v4/*: any*/),
(v7/*: any*/),
- (v12/*: any*/),
- (v5/*: any*/),
- {
- "kind": "InlineFragment",
- "type": "Bot",
- "selections": (v14/*: any*/)
- },
- {
- "kind": "InlineFragment",
- "type": "User",
- "selections": (v14/*: any*/)
- }
+ (v13/*: any*/),
+ (v10/*: any*/),
+ (v5/*: any*/)
]
},
- (v13/*: any*/),
{
"kind": "LinkedField",
"alias": null,
"name": "timeline",
"storageKey": null,
- "args": (v15/*: any*/),
+ "args": (v14/*: any*/),
"concreteType": "IssueTimelineConnection",
"plural": false,
"selections": [
@@ -613,7 +597,7 @@ return {
"selections": [
(v4/*: any*/),
(v7/*: any*/),
- (v12/*: any*/),
+ (v13/*: any*/),
(v5/*: any*/)
]
},
@@ -653,9 +637,9 @@ return {
"kind": "InlineFragment",
"type": "PullRequest",
"selections": [
+ (v11/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
- (v13/*: any*/),
{
"kind": "ScalarField",
"alias": "prState",
@@ -669,9 +653,9 @@ return {
"kind": "InlineFragment",
"type": "Issue",
"selections": [
+ (v11/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
- (v13/*: any*/),
{
"kind": "ScalarField",
"alias": "issueState",
@@ -699,12 +683,12 @@ return {
"plural": false,
"selections": [
(v4/*: any*/),
- (v12/*: any*/),
+ (v13/*: any*/),
(v7/*: any*/),
(v5/*: any*/)
]
},
- (v11/*: any*/),
+ (v12/*: any*/),
{
"kind": "ScalarField",
"alias": null,
@@ -712,7 +696,7 @@ return {
"args": null,
"storageKey": null
},
- (v13/*: any*/)
+ (v10/*: any*/)
]
},
{
@@ -729,8 +713,8 @@ return {
"plural": false,
"selections": [
(v6/*: any*/),
- (v16/*: any*/),
- (v12/*: any*/)
+ (v15/*: any*/),
+ (v13/*: any*/)
]
},
{
@@ -743,8 +727,8 @@ return {
"plural": false,
"selections": [
(v6/*: any*/),
- (v12/*: any*/),
- (v16/*: any*/)
+ (v13/*: any*/),
+ (v15/*: any*/)
]
},
{
@@ -794,7 +778,7 @@ return {
"kind": "LinkedHandle",
"alias": null,
"name": "timeline",
- "args": (v15/*: any*/),
+ "args": (v14/*: any*/),
"handle": "connection",
"key": "IssueTimelineController_timeline",
"filters": null
@@ -815,6 +799,13 @@ return {
"args": null,
"storageKey": null
},
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "viewerHasReacted",
+ "args": null,
+ "storageKey": null
+ },
{
"kind": "LinkedField",
"alias": null,
@@ -834,6 +825,13 @@ return {
]
}
]
+ },
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "viewerCanReact",
+ "args": null,
+ "storageKey": null
}
]
}
@@ -845,7 +843,7 @@ return {
"operationKind": "query",
"name": "issueDetailViewRefetchQuery",
"id": null,
- "text": "query issueDetailViewRefetchQuery(\n $repoId: ID!\n $issueishId: ID!\n $timelineCount: Int!\n $timelineCursor: String\n) {\n repository: node(id: $repoId) {\n __typename\n ...issueDetailView_repository_3D8CP9\n id\n }\n issue: node(id: $issueishId) {\n __typename\n ...issueDetailView_issue_3D8CP9\n id\n }\n}\n\nfragment issueDetailView_repository_3D8CP9 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 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 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 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 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",
+ "text": "query issueDetailViewRefetchQuery(\n $repoId: ID!\n $issueishId: ID!\n $timelineCount: Int!\n $timelineCursor: String\n) {\n repository: node(id: $repoId) {\n __typename\n ...issueDetailView_repository_3D8CP9\n id\n }\n issue: node(id: $issueishId) {\n __typename\n ...issueDetailView_issue_3D8CP9\n id\n }\n}\n\nfragment issueDetailView_repository_3D8CP9 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 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 emojiReactionsView_reactable on Reactable {\n id\n reactionGroups {\n content\n viewerHasReacted\n users {\n totalCount\n }\n }\n viewerCanReact\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 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 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",
"metadata": {}
}
};
diff --git a/lib/views/__generated__/issueDetailView_issue.graphql.js b/lib/views/__generated__/issueDetailView_issue.graphql.js
index 8eabadcabd..fcf5130591 100644
--- a/lib/views/__generated__/issueDetailView_issue.graphql.js
+++ b/lib/views/__generated__/issueDetailView_issue.graphql.js
@@ -8,13 +8,14 @@
/*::
import type { ReaderFragment } from 'relay-runtime';
+type emojiReactionsView_reactable$ref = any;
type issueTimelineController_issue$ref = any;
export type IssueState = "CLOSED" | "OPEN" | "%future added value";
-export type ReactionContent = "CONFUSED" | "HEART" | "HOORAY" | "LAUGH" | "THUMBS_DOWN" | "THUMBS_UP" | "%future added value";
import type { FragmentReference } from "relay-runtime";
declare export opaque type issueDetailView_issue$ref: FragmentReference;
export type issueDetailView_issue = {|
- +id?: string,
+ +id: string,
+ +url: any,
+state: IssueState,
+number: number,
+title: string,
@@ -22,17 +23,10 @@ export type issueDetailView_issue = {|
+author: ?{|
+login: string,
+avatarUrl: any,
- +url?: any,
+ +url: any,
|},
- +url?: any,
- +reactionGroups?: ?$ReadOnlyArray<{|
- +content: ReactionContent,
- +users: {|
- +totalCount: number
- |},
- |}>,
+__typename: "Issue",
- +$fragmentRefs: issueTimelineController_issue$ref,
+ +$fragmentRefs: issueTimelineController_issue$ref & emojiReactionsView_reactable$ref,
+$refType: issueDetailView_issue$ref,
|};
*/
@@ -45,10 +39,7 @@ var v0 = {
"name": "url",
"args": null,
"storageKey": null
-},
-v1 = [
- (v0/*: any*/)
-];
+};
return {
"kind": "Fragment",
"name": "issueDetailView_issue",
@@ -72,17 +63,18 @@ return {
{
"kind": "ScalarField",
"alias": null,
- "name": "__typename",
+ "name": "id",
"args": null,
"storageKey": null
},
{
"kind": "ScalarField",
"alias": null,
- "name": "id",
+ "name": "__typename",
"args": null,
"storageKey": null
},
+ (v0/*: any*/),
{
"kind": "ScalarField",
"alias": null,
@@ -134,16 +126,7 @@ return {
"args": null,
"storageKey": null
},
- {
- "kind": "InlineFragment",
- "type": "Bot",
- "selections": (v1/*: any*/)
- },
- {
- "kind": "InlineFragment",
- "type": "User",
- "selections": (v1/*: any*/)
- }
+ (v0/*: any*/)
]
},
{
@@ -164,46 +147,14 @@ return {
}
]
},
- (v0/*: 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": "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": "FragmentSpread",
+ "name": "emojiReactionsView_reactable",
+ "args": null
}
]
};
})();
// prettier-ignore
-(node/*: any*/).hash = 'e1cf4b71a99cbade6149738c70451892';
+(node/*: any*/).hash = 'f7adc2e75c1d55df78481fd359bf7180';
module.exports = node;
diff --git a/lib/views/__generated__/prDetailViewRefetchQuery.graphql.js b/lib/views/__generated__/prDetailViewRefetchQuery.graphql.js
index b417eaf6cd..a96e94f414 100644
--- a/lib/views/__generated__/prDetailViewRefetchQuery.graphql.js
+++ b/lib/views/__generated__/prDetailViewRefetchQuery.graphql.js
@@ -1,6 +1,6 @@
/**
* @flow
- * @relayHash 57a14954e98d84258646e48cc063e544
+ * @relayHash 14bf97211976e9ea810b6420032269c5
*/
/* eslint-disable */
@@ -18,10 +18,6 @@ export type prDetailViewRefetchQueryVariables = {|
timelineCursor?: ?string,
commitCount: number,
commitCursor?: ?string,
- reviewCount: number,
- reviewCursor?: ?string,
- commentCount: number,
- commentCursor?: ?string,
|};
export type prDetailViewRefetchQueryResponse = {|
+repository: ?{|
@@ -46,10 +42,6 @@ query prDetailViewRefetchQuery(
$timelineCursor: String
$commitCount: Int!
$commitCursor: String
- $reviewCount: Int!
- $reviewCursor: String
- $commentCount: Int!
- $commentCursor: String
) {
repository: node(id: $repoId) {
__typename
@@ -58,7 +50,7 @@ query prDetailViewRefetchQuery(
}
pullRequest: node(id: $issueishId) {
__typename
- ...prDetailView_pullRequest_2qM2KL
+ ...prDetailView_pullRequest_4cAEh0
id
}
}
@@ -73,86 +65,34 @@ fragment prDetailView_repository_3D8CP9 on Repository {
}
}
-fragment prDetailView_pullRequest_2qM2KL on PullRequest {
+fragment prDetailView_pullRequest_4cAEh0 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 {
@@ -225,6 +165,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 {
@@ -449,41 +406,6 @@ fragment prCommitView_item on Commit {
sha: oid
url
}
-
-fragment prReviewCommentsContainer_review_1VbUmL on PullRequestReview {
- id
- submittedAt
- comments(first: $commentCount, after: $commentCursor) {
- pageInfo {
- hasNextPage
- endCursor
- }
- edges {
- cursor
- node {
- id
- author {
- __typename
- avatarUrl
- login
- ... on Node {
- id
- }
- }
- bodyHTML
- isMinimized
- path
- position
- replyTo {
- id
- }
- createdAt
- url
- __typename
- }
- }
- }
-}
*/
const node/*: ConcreteRequest*/ = (function(){
@@ -523,30 +445,6 @@ var v0 = [
"name": "commitCursor",
"type": "String",
"defaultValue": null
- },
- {
- "kind": "LocalArgument",
- "name": "reviewCount",
- "type": "Int!",
- "defaultValue": null
- },
- {
- "kind": "LocalArgument",
- "name": "reviewCursor",
- "type": "String",
- "defaultValue": null
- },
- {
- "kind": "LocalArgument",
- "name": "commentCount",
- "type": "Int!",
- "defaultValue": null
- },
- {
- "kind": "LocalArgument",
- "name": "commentCursor",
- "type": "String",
- "defaultValue": null
}
],
v1 = [
@@ -623,7 +521,7 @@ v10 = {
v11 = {
"kind": "ScalarField",
"alias": null,
- "name": "number",
+ "name": "url",
"args": null,
"storageKey": null
},
@@ -637,131 +535,48 @@ v12 = {
v13 = {
"kind": "ScalarField",
"alias": null,
- "name": "url",
- "args": null,
- "storageKey": null
-},
-v14 = [
- {
- "kind": "Variable",
- "name": "after",
- "variableName": "reviewCursor",
- "type": "String"
- },
- {
- "kind": "Variable",
- "name": "first",
- "variableName": "reviewCount",
- "type": "Int"
- }
-],
-v15 = {
- "kind": "ScalarField",
- "alias": null,
- "name": "hasNextPage",
- "args": null,
- "storageKey": null
-},
-v16 = {
- "kind": "ScalarField",
- "alias": null,
- "name": "endCursor",
+ "name": "state",
"args": null,
"storageKey": null
},
-v17 = {
- "kind": "LinkedField",
- "alias": null,
- "name": "pageInfo",
- "storageKey": null,
- "args": null,
- "concreteType": "PageInfo",
- "plural": false,
- "selections": [
- (v15/*: any*/),
- (v16/*: any*/)
- ]
-},
-v18 = {
+v14 = {
"kind": "ScalarField",
"alias": null,
- "name": "cursor",
+ "name": "number",
"args": null,
"storageKey": null
},
-v19 = {
+v15 = {
"kind": "ScalarField",
"alias": null,
- "name": "state",
+ "name": "title",
"args": null,
"storageKey": null
},
-v20 = {
+v16 = {
"kind": "ScalarField",
"alias": null,
- "name": "avatarUrl",
+ "name": "bodyHTML",
"args": null,
"storageKey": null
},
-v21 = [
- {
- "kind": "Variable",
- "name": "after",
- "variableName": "commentCursor",
- "type": "String"
- },
+v17 = [
{
- "kind": "Variable",
- "name": "first",
- "variableName": "commentCount",
- "type": "Int"
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "totalCount",
+ "args": null,
+ "storageKey": null
}
],
-v22 = [
- (v5/*: any*/),
- (v20/*: any*/),
- (v8/*: any*/),
- (v6/*: any*/)
-],
-v23 = {
- "kind": "LinkedField",
- "alias": null,
- "name": "author",
- "storageKey": null,
- "args": null,
- "concreteType": null,
- "plural": false,
- "selections": (v22/*: any*/)
-},
-v24 = {
- "kind": "ScalarField",
- "alias": null,
- "name": "bodyHTML",
- "args": null,
- "storageKey": null
-},
-v25 = {
- "kind": "ScalarField",
- "alias": null,
- "name": "path",
- "args": null,
- "storageKey": null
-},
-v26 = {
- "kind": "ScalarField",
- "alias": null,
- "name": "position",
- "args": null,
- "storageKey": null
-},
-v27 = {
+v18 = {
"kind": "ScalarField",
"alias": null,
- "name": "createdAt",
+ "name": "avatarUrl",
"args": null,
"storageKey": null
},
-v28 = [
+v19 = [
{
"kind": "Variable",
"name": "after",
@@ -775,7 +590,7 @@ v28 = [
"type": "Int"
}
],
-v29 = {
+v20 = {
"kind": "LinkedField",
"alias": null,
"name": "pageInfo",
@@ -784,37 +599,37 @@ v29 = {
"concreteType": "PageInfo",
"plural": false,
"selections": [
- (v16/*: any*/),
- (v15/*: any*/)
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "endCursor",
+ "args": null,
+ "storageKey": null
+ },
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "hasNextPage",
+ "args": null,
+ "storageKey": null
+ }
]
},
-v30 = {
+v21 = {
"kind": "ScalarField",
- "alias": "sha",
- "name": "oid",
+ "alias": null,
+ "name": "cursor",
"args": null,
"storageKey": null
},
-v31 = [
- {
- "kind": "ScalarField",
- "alias": null,
- "name": "totalCount",
- "args": null,
- "storageKey": null
- }
-],
-v32 = {
+v22 = {
"kind": "ScalarField",
- "alias": null,
- "name": "title",
+ "alias": "sha",
+ "name": "oid",
"args": null,
"storageKey": null
},
-v33 = [
- (v13/*: any*/)
-],
-v34 = [
+v23 = [
{
"kind": "Variable",
"name": "after",
@@ -828,13 +643,13 @@ v34 = [
"type": "Int"
}
],
-v35 = [
+v24 = [
(v5/*: any*/),
(v8/*: any*/),
- (v20/*: any*/),
+ (v18/*: any*/),
(v6/*: any*/)
],
-v36 = [
+v25 = [
{
"kind": "ScalarField",
"alias": null,
@@ -844,7 +659,7 @@ v36 = [
},
(v6/*: any*/)
],
-v37 = {
+v26 = {
"kind": "LinkedField",
"alias": null,
"name": "commit",
@@ -852,9 +667,22 @@ v37 = {
"args": null,
"concreteType": "Commit",
"plural": false,
- "selections": (v36/*: any*/)
+ "selections": (v25/*: any*/)
+},
+v27 = {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "createdAt",
+ "args": null,
+ "storageKey": null
},
-v38 = {
+v28 = [
+ (v5/*: any*/),
+ (v18/*: any*/),
+ (v8/*: any*/),
+ (v6/*: any*/)
+],
+v29 = {
"kind": "LinkedField",
"alias": null,
"name": "actor",
@@ -862,9 +690,9 @@ v38 = {
"args": null,
"concreteType": null,
"plural": false,
- "selections": (v22/*: any*/)
+ "selections": (v28/*: any*/)
},
-v39 = {
+v30 = {
"kind": "LinkedField",
"alias": null,
"name": "user",
@@ -918,18 +746,6 @@ return {
"kind": "FragmentSpread",
"name": "prDetailView_pullRequest",
"args": [
- {
- "kind": "Variable",
- "name": "commentCount",
- "variableName": "commentCount",
- "type": null
- },
- {
- "kind": "Variable",
- "name": "commentCursor",
- "variableName": "commentCursor",
- "type": null
- },
{
"kind": "Variable",
"name": "commitCount",
@@ -942,18 +758,6 @@ return {
"variableName": "commitCursor",
"type": null
},
- {
- "kind": "Variable",
- "name": "reviewCount",
- "variableName": "reviewCount",
- "type": null
- },
- {
- "kind": "Variable",
- "name": "reviewCursor",
- "variableName": "reviewCursor",
- "type": null
- },
(v2/*: any*/),
(v3/*: any*/)
]
@@ -1003,8 +807,14 @@ return {
"kind": "InlineFragment",
"type": "PullRequest",
"selections": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "headRefName",
+ "args": null,
+ "storageKey": null
+ },
(v11/*: any*/),
- (v5/*: any*/),
(v12/*: any*/),
{
"kind": "ScalarField",
@@ -1014,172 +824,53 @@ return {
"storageKey": null
},
(v13/*: any*/),
+ (v14/*: any*/),
+ (v15/*: any*/),
+ (v16/*: any*/),
{
- "kind": "LinkedField",
+ "kind": "ScalarField",
"alias": null,
- "name": "reviews",
+ "name": "baseRefName",
+ "args": null,
+ "storageKey": null
+ },
+ (v5/*: any*/),
+ {
+ "kind": "LinkedField",
+ "alias": "countedCommits",
+ "name": "commits",
"storageKey": null,
- "args": (v14/*: any*/),
- "concreteType": "PullRequestReviewConnection",
+ "args": null,
+ "concreteType": "PullRequestCommitConnection",
"plural": false,
- "selections": [
- (v17/*: any*/),
- {
- "kind": "LinkedField",
- "alias": null,
- "name": "edges",
- "storageKey": null,
- "args": null,
- "concreteType": "PullRequestReviewEdge",
- "plural": true,
- "selections": [
- (v18/*: any*/),
- {
- "kind": "LinkedField",
- "alias": null,
- "name": "node",
- "storageKey": null,
- "args": null,
- "concreteType": "PullRequestReview",
- "plural": false,
- "selections": [
- (v6/*: any*/),
- {
- "kind": "ScalarField",
- "alias": null,
- "name": "body",
- "args": null,
- "storageKey": null
- },
- (v19/*: 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": (v9/*: any*/)
- },
- {
- "kind": "LinkedField",
- "alias": null,
- "name": "author",
- "storageKey": null,
- "args": null,
- "concreteType": null,
- "plural": false,
- "selections": [
- (v5/*: any*/),
- (v20/*: any*/),
- (v6/*: any*/)
- ]
- },
- {
- "kind": "LinkedField",
- "alias": null,
- "name": "comments",
- "storageKey": null,
- "args": (v21/*: any*/),
- "concreteType": "PullRequestReviewCommentConnection",
- "plural": false,
- "selections": [
- (v17/*: any*/),
- {
- "kind": "LinkedField",
- "alias": null,
- "name": "edges",
- "storageKey": null,
- "args": null,
- "concreteType": "PullRequestReviewCommentEdge",
- "plural": true,
- "selections": [
- (v18/*: any*/),
- {
- "kind": "LinkedField",
- "alias": null,
- "name": "node",
- "storageKey": null,
- "args": null,
- "concreteType": "PullRequestReviewComment",
- "plural": false,
- "selections": [
- (v6/*: any*/),
- (v23/*: any*/),
- (v24/*: any*/),
- {
- "kind": "ScalarField",
- "alias": null,
- "name": "isMinimized",
- "args": null,
- "storageKey": null
- },
- (v25/*: any*/),
- (v26/*: any*/),
- {
- "kind": "LinkedField",
- "alias": null,
- "name": "replyTo",
- "storageKey": null,
- "args": null,
- "concreteType": "PullRequestReviewComment",
- "plural": false,
- "selections": [
- (v6/*: any*/)
- ]
- },
- (v27/*: any*/),
- (v13/*: any*/),
- (v5/*: any*/)
- ]
- }
- ]
- }
- ]
- },
- {
- "kind": "LinkedHandle",
- "alias": null,
- "name": "comments",
- "args": (v21/*: any*/),
- "handle": "connection",
- "key": "PrReviewCommentsContainer_comments",
- "filters": null
- },
- (v5/*: any*/)
- ]
- }
- ]
- }
- ]
+ "selections": (v17/*: any*/)
},
{
- "kind": "LinkedHandle",
+ "kind": "LinkedField",
"alias": null,
- "name": "reviews",
- "args": (v14/*: any*/),
- "handle": "connection",
- "key": "PrReviewsContainer_reviews",
- "filters": null
+ "name": "author",
+ "storageKey": null,
+ "args": null,
+ "concreteType": null,
+ "plural": false,
+ "selections": [
+ (v5/*: any*/),
+ (v8/*: any*/),
+ (v18/*: any*/),
+ (v11/*: any*/),
+ (v6/*: any*/)
+ ]
},
{
"kind": "LinkedField",
"alias": null,
"name": "commits",
"storageKey": null,
- "args": (v28/*: any*/),
+ "args": (v19/*: any*/),
"concreteType": "PullRequestCommitConnection",
"plural": false,
"selections": [
- (v29/*: any*/),
+ (v20/*: any*/),
{
"kind": "LinkedField",
"alias": null,
@@ -1189,7 +880,7 @@ return {
"concreteType": "PullRequestCommitEdge",
"plural": true,
"selections": [
- (v18/*: any*/),
+ (v21/*: any*/),
{
"kind": "LinkedField",
"alias": null,
@@ -1218,7 +909,7 @@ return {
"concreteType": "GitActor",
"plural": false,
"selections": [
- (v20/*: any*/),
+ (v18/*: any*/),
(v7/*: any*/),
{
"kind": "ScalarField",
@@ -1250,8 +941,8 @@ return {
"args": null,
"storageKey": null
},
- (v30/*: any*/),
- (v13/*: any*/)
+ (v22/*: any*/),
+ (v11/*: any*/)
]
},
(v6/*: any*/),
@@ -1266,21 +957,11 @@ return {
"kind": "LinkedHandle",
"alias": null,
"name": "commits",
- "args": (v28/*: any*/),
+ "args": (v19/*: any*/),
"handle": "connection",
"key": "prCommitsView_commits",
"filters": null
},
- {
- "kind": "LinkedField",
- "alias": "countedCommits",
- "name": "commits",
- "storageKey": null,
- "args": null,
- "concreteType": "PullRequestCommitConnection",
- "plural": false,
- "selections": (v31/*: any*/)
- },
{
"kind": "LinkedField",
"alias": "recentCommits",
@@ -1333,7 +1014,7 @@ return {
"concreteType": "Status",
"plural": false,
"selections": [
- (v19/*: any*/),
+ (v13/*: any*/),
{
"kind": "LinkedField",
"alias": null,
@@ -1344,7 +1025,7 @@ return {
"plural": true,
"selections": [
(v6/*: any*/),
- (v19/*: any*/),
+ (v13/*: any*/),
{
"kind": "ScalarField",
"alias": null,
@@ -1381,48 +1062,6 @@ return {
}
]
},
- (v19/*: any*/),
- (v32/*: any*/),
- (v24/*: any*/),
- {
- "kind": "ScalarField",
- "alias": null,
- "name": "baseRefName",
- "args": null,
- "storageKey": null
- },
- {
- "kind": "ScalarField",
- "alias": null,
- "name": "headRefName",
- "args": null,
- "storageKey": null
- },
- {
- "kind": "LinkedField",
- "alias": null,
- "name": "author",
- "storageKey": null,
- "args": null,
- "concreteType": null,
- "plural": false,
- "selections": [
- (v5/*: any*/),
- (v8/*: any*/),
- (v20/*: any*/),
- (v6/*: any*/),
- {
- "kind": "InlineFragment",
- "type": "Bot",
- "selections": (v33/*: any*/)
- },
- {
- "kind": "InlineFragment",
- "type": "User",
- "selections": (v33/*: any*/)
- }
- ]
- },
{
"kind": "LinkedField",
"alias": null,
@@ -1451,11 +1090,11 @@ return {
"alias": null,
"name": "timeline",
"storageKey": null,
- "args": (v34/*: any*/),
+ "args": (v23/*: any*/),
"concreteType": "PullRequestTimelineConnection",
"plural": false,
"selections": [
- (v29/*: any*/),
+ (v20/*: any*/),
{
"kind": "LinkedField",
"alias": null,
@@ -1465,7 +1104,7 @@ return {
"concreteType": "PullRequestTimelineItemEdge",
"plural": true,
"selections": [
- (v18/*: any*/),
+ (v21/*: any*/),
{
"kind": "LinkedField",
"alias": null,
@@ -1497,7 +1136,7 @@ return {
"args": null,
"concreteType": null,
"plural": false,
- "selections": (v35/*: any*/)
+ "selections": (v24/*: any*/)
},
{
"kind": "LinkedField",
@@ -1535,9 +1174,9 @@ return {
"kind": "InlineFragment",
"type": "PullRequest",
"selections": [
+ (v14/*: any*/),
+ (v15/*: any*/),
(v11/*: any*/),
- (v32/*: any*/),
- (v13/*: any*/),
{
"kind": "ScalarField",
"alias": "prState",
@@ -1551,9 +1190,9 @@ return {
"kind": "InlineFragment",
"type": "Issue",
"selections": [
+ (v14/*: any*/),
+ (v15/*: any*/),
(v11/*: any*/),
- (v32/*: any*/),
- (v13/*: any*/),
{
"kind": "ScalarField",
"alias": "issueState",
@@ -1571,7 +1210,7 @@ return {
"kind": "InlineFragment",
"type": "CommitCommentThread",
"selections": [
- (v37/*: any*/),
+ (v26/*: any*/),
{
"kind": "LinkedField",
"alias": null,
@@ -1615,13 +1254,25 @@ return {
"args": null,
"concreteType": null,
"plural": false,
- "selections": (v35/*: any*/)
+ "selections": (v24/*: any*/)
},
- (v37/*: any*/),
- (v24/*: any*/),
+ (v26/*: any*/),
+ (v16/*: any*/),
(v27/*: any*/),
- (v25/*: any*/),
- (v26/*: any*/)
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "path",
+ "args": null,
+ "storageKey": null
+ },
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "position",
+ "args": null,
+ "storageKey": null
+ }
]
}
]
@@ -1634,7 +1285,7 @@ return {
"kind": "InlineFragment",
"type": "HeadRefForcePushedEvent",
"selections": [
- (v38/*: any*/),
+ (v29/*: any*/),
{
"kind": "LinkedField",
"alias": null,
@@ -1643,7 +1294,7 @@ return {
"args": null,
"concreteType": "Commit",
"plural": false,
- "selections": (v36/*: any*/)
+ "selections": (v25/*: any*/)
},
{
"kind": "LinkedField",
@@ -1653,7 +1304,7 @@ return {
"args": null,
"concreteType": "Commit",
"plural": false,
- "selections": (v36/*: any*/)
+ "selections": (v25/*: any*/)
},
(v27/*: any*/)
]
@@ -1662,8 +1313,8 @@ return {
"kind": "InlineFragment",
"type": "MergedEvent",
"selections": [
- (v38/*: any*/),
- (v37/*: any*/),
+ (v29/*: any*/),
+ (v26/*: any*/),
{
"kind": "ScalarField",
"alias": null,
@@ -1678,10 +1329,19 @@ return {
"kind": "InlineFragment",
"type": "IssueComment",
"selections": [
- (v23/*: any*/),
- (v24/*: any*/),
+ {
+ "kind": "LinkedField",
+ "alias": null,
+ "name": "author",
+ "storageKey": null,
+ "args": null,
+ "concreteType": null,
+ "plural": false,
+ "selections": (v28/*: any*/)
+ },
+ (v16/*: any*/),
(v27/*: any*/),
- (v13/*: any*/)
+ (v11/*: any*/)
]
},
{
@@ -1698,8 +1358,8 @@ return {
"plural": false,
"selections": [
(v7/*: any*/),
- (v39/*: any*/),
- (v20/*: any*/)
+ (v30/*: any*/),
+ (v18/*: any*/)
]
},
{
@@ -1712,8 +1372,8 @@ return {
"plural": false,
"selections": [
(v7/*: any*/),
- (v20/*: any*/),
- (v39/*: any*/)
+ (v18/*: any*/),
+ (v30/*: any*/)
]
},
{
@@ -1723,7 +1383,7 @@ return {
"args": null,
"storageKey": null
},
- (v30/*: any*/),
+ (v22/*: any*/),
{
"kind": "ScalarField",
"alias": null,
@@ -1757,7 +1417,7 @@ return {
"kind": "LinkedHandle",
"alias": null,
"name": "timeline",
- "args": (v34/*: any*/),
+ "args": (v23/*: any*/),
"handle": "connection",
"key": "prTimelineContainer_timeline",
"filters": null
@@ -1778,6 +1438,13 @@ return {
"args": null,
"storageKey": null
},
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "viewerHasReacted",
+ "args": null,
+ "storageKey": null
+ },
{
"kind": "LinkedField",
"alias": null,
@@ -1786,9 +1453,16 @@ return {
"args": null,
"concreteType": "ReactingUserConnection",
"plural": false,
- "selections": (v31/*: any*/)
+ "selections": (v17/*: any*/)
}
]
+ },
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "viewerCanReact",
+ "args": null,
+ "storageKey": null
}
]
}
@@ -1800,11 +1474,11 @@ return {
"operationKind": "query",
"name": "prDetailViewRefetchQuery",
"id": null,
- "text": "query prDetailViewRefetchQuery(\n $repoId: ID!\n $issueishId: ID!\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: node(id: $repoId) {\n __typename\n ...prDetailView_repository_3D8CP9\n id\n }\n pullRequest: node(id: $issueishId) {\n __typename\n ...prDetailView_pullRequest_2qM2KL\n id\n }\n}\n\nfragment prDetailView_repository_3D8CP9 on Repository {\n id\n name\n owner {\n __typename\n login\n id\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",
+ "text": "query prDetailViewRefetchQuery(\n $repoId: ID!\n $issueishId: ID!\n $timelineCount: Int!\n $timelineCursor: String\n $commitCount: Int!\n $commitCursor: String\n) {\n repository: node(id: $repoId) {\n __typename\n ...prDetailView_repository_3D8CP9\n id\n }\n pullRequest: node(id: $issueishId) {\n __typename\n ...prDetailView_pullRequest_4cAEh0\n id\n }\n}\n\nfragment prDetailView_repository_3D8CP9 on Repository {\n id\n name\n owner {\n __typename\n login\n id\n }\n}\n\nfragment prDetailView_pullRequest_4cAEh0 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",
"metadata": {}
}
};
})();
// prettier-ignore
-(node/*: any*/).hash = 'bbddc1ac39ae868ae2b12317af98efa4';
+(node/*: any*/).hash = '04dad90234c09010553beb02cf90cbb1';
module.exports = node;
diff --git a/lib/views/__generated__/prDetailView_pullRequest.graphql.js b/lib/views/__generated__/prDetailView_pullRequest.graphql.js
index ff7516fd2a..aeee3ab29a 100644
--- a/lib/views/__generated__/prDetailView_pullRequest.graphql.js
+++ b/lib/views/__generated__/prDetailView_pullRequest.graphql.js
@@ -8,66 +8,47 @@
/*::
import type { ReaderFragment } from 'relay-runtime';
+type emojiReactionsController_reactable$ref = any;
type prCommitsView_pullRequest$ref = any;
-type prReviewsContainer_pullRequest$ref = any;
type prStatusesView_pullRequest$ref = any;
type prTimelineController_pullRequest$ref = any;
export type PullRequestState = "CLOSED" | "MERGED" | "OPEN" | "%future added value";
-export type ReactionContent = "CONFUSED" | "HEART" | "HOORAY" | "LAUGH" | "THUMBS_DOWN" | "THUMBS_UP" | "%future added value";
import type { FragmentReference } from "relay-runtime";
declare export opaque type prDetailView_pullRequest$ref: FragmentReference;
export type prDetailView_pullRequest = {|
- +id?: string,
+ +id: string,
+ +url: any,
+isCrossRepository: boolean,
+changedFiles: number,
- +countedCommits: {|
- +totalCount: number
- |},
+state: PullRequestState,
+number: number,
+title: string,
+bodyHTML: any,
+baseRefName: string,
+headRefName: string,
+ +countedCommits: {|
+ +totalCount: number
+ |},
+author: ?{|
+login: string,
+avatarUrl: any,
- +url?: any,
+ +url: any,
|},
- +url?: any,
- +reactionGroups?: ?$ReadOnlyArray<{|
- +content: ReactionContent,
- +users: {|
- +totalCount: number
- |},
- |}>,
+__typename: "PullRequest",
- +$fragmentRefs: prReviewsContainer_pullRequest$ref & prCommitsView_pullRequest$ref & prStatusesView_pullRequest$ref & prTimelineController_pullRequest$ref,
+ +$fragmentRefs: prCommitsView_pullRequest$ref & prStatusesView_pullRequest$ref & prTimelineController_pullRequest$ref & emojiReactionsController_reactable$ref,
+$refType: prDetailView_pullRequest$ref,
|};
*/
const node/*: ReaderFragment*/ = (function(){
-var v0 = [
- {
- "kind": "ScalarField",
- "alias": null,
- "name": "totalCount",
- "args": null,
- "storageKey": null
- }
-],
-v1 = {
+var v0 = {
"kind": "ScalarField",
"alias": null,
"name": "url",
"args": null,
"storageKey": null
-},
-v2 = [
- (v1/*: any*/)
-];
+};
return {
"kind": "Fragment",
"name": "prDetailView_pullRequest",
@@ -97,47 +78,24 @@ return {
"name": "commitCursor",
"type": "String",
"defaultValue": null
- },
- {
- "kind": "LocalArgument",
- "name": "reviewCount",
- "type": "Int!",
- "defaultValue": null
- },
- {
- "kind": "LocalArgument",
- "name": "reviewCursor",
- "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": "number",
+ "name": "bodyHTML",
"args": null,
"storageKey": null
},
{
"kind": "ScalarField",
"alias": null,
- "name": "__typename",
+ "name": "id",
"args": null,
"storageKey": null
},
+ (v0/*: any*/),
{
"kind": "ScalarField",
"alias": null,
@@ -152,69 +110,6 @@ return {
"args": null,
"storageKey": null
},
- {
- "kind": "FragmentSpread",
- "name": "prReviewsContainer_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": "FragmentSpread",
- "name": "prCommitsView_pullRequest",
- "args": [
- {
- "kind": "Variable",
- "name": "commitCount",
- "variableName": "commitCount",
- "type": null
- },
- {
- "kind": "Variable",
- "name": "commitCursor",
- "variableName": "commitCursor",
- "type": null
- }
- ]
- },
- {
- "kind": "LinkedField",
- "alias": "countedCommits",
- "name": "commits",
- "storageKey": null,
- "args": null,
- "concreteType": "PullRequestCommitConnection",
- "plural": false,
- "selections": (v0/*: any*/)
- },
- {
- "kind": "FragmentSpread",
- "name": "prStatusesView_pullRequest",
- "args": null
- },
{
"kind": "ScalarField",
"alias": null,
@@ -225,7 +120,7 @@ return {
{
"kind": "ScalarField",
"alias": null,
- "name": "id",
+ "name": "number",
"args": null,
"storageKey": null
},
@@ -239,7 +134,7 @@ return {
{
"kind": "ScalarField",
"alias": null,
- "name": "bodyHTML",
+ "name": "__typename",
"args": null,
"storageKey": null
},
@@ -257,6 +152,24 @@ return {
"args": null,
"storageKey": null
},
+ {
+ "kind": "LinkedField",
+ "alias": "countedCommits",
+ "name": "commits",
+ "storageKey": null,
+ "args": null,
+ "concreteType": "PullRequestCommitConnection",
+ "plural": false,
+ "selections": [
+ {
+ "kind": "ScalarField",
+ "alias": null,
+ "name": "totalCount",
+ "args": null,
+ "storageKey": null
+ }
+ ]
+ },
{
"kind": "LinkedField",
"alias": null,
@@ -280,18 +193,32 @@ return {
"args": null,
"storageKey": null
},
+ (v0/*: any*/)
+ ]
+ },
+ {
+ "kind": "FragmentSpread",
+ "name": "prCommitsView_pullRequest",
+ "args": [
{
- "kind": "InlineFragment",
- "type": "Bot",
- "selections": (v2/*: any*/)
+ "kind": "Variable",
+ "name": "commitCount",
+ "variableName": "commitCount",
+ "type": null
},
{
- "kind": "InlineFragment",
- "type": "User",
- "selections": (v2/*: any*/)
+ "kind": "Variable",
+ "name": "commitCursor",
+ "variableName": "commitCursor",
+ "type": null
}
]
},
+ {
+ "kind": "FragmentSpread",
+ "name": "prStatusesView_pullRequest",
+ "args": null
+ },
{
"kind": "FragmentSpread",
"name": "prTimelineController_pullRequest",
@@ -310,38 +237,14 @@ return {
}
]
},
- (v1/*: 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": "LinkedField",
- "alias": null,
- "name": "users",
- "storageKey": null,
- "args": null,
- "concreteType": "ReactingUserConnection",
- "plural": false,
- "selections": (v0/*: any*/)
- }
- ]
+ "kind": "FragmentSpread",
+ "name": "emojiReactionsController_reactable",
+ "args": null
}
]
};
})();
// prettier-ignore
-(node/*: any*/).hash = 'e3868f3180aae27226fe92ba6f444412';
+(node/*: any*/).hash = '3fc00d1cf0c2c3906259c3dbd88952e6';
module.exports = node;
diff --git a/lib/views/accordion.js b/lib/views/accordion.js
index dc35729400..7ea969fe32 100644
--- a/lib/views/accordion.js
+++ b/lib/views/accordion.js
@@ -13,6 +13,7 @@ export default class Accordion extends React.Component {
loadingComponent: PropTypes.func,
emptyComponent: PropTypes.func,
moreComponent: PropTypes.func,
+ reviewsButton: PropTypes.func,
onClickItem: PropTypes.func,
children: PropTypes.func.isRequired,
};
@@ -22,6 +23,7 @@ export default class Accordion extends React.Component {
emptyComponent: () => null,
moreComponent: () => null,
onClickItem: () => {},
+ reviewsButton: () => null,
};
constructor(props) {
@@ -57,6 +59,7 @@ export default class Accordion extends React.Component {
{this.props.rightTitle}
)}
+ {this.props.reviewsButton()}
);
}
diff --git a/lib/views/checkout-button.js b/lib/views/checkout-button.js
new file mode 100644
index 0000000000..56a5a83aea
--- /dev/null
+++ b/lib/views/checkout-button.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+import {EnableableOperationPropType} from '../prop-types';
+import {checkoutStates} from '../controllers/pr-checkout-controller';
+
+export default class CheckoutButton extends React.Component {
+ static propTypes = {
+ checkoutOp: EnableableOperationPropType.isRequired,
+ classNamePrefix: PropTypes.string.isRequired,
+ classNames: PropTypes.array,
+ }
+
+ render() {
+ const {checkoutOp} = this.props;
+ const extraClasses = this.props.classNames || [];
+ 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',
+ });
+
+ extraClasses.push(this.props.classNamePrefix + reason.when({
+ disabled: 'disabled',
+ busy: 'busy',
+ current: 'current',
+ }));
+ }
+
+ const classNames = cx('btn', 'btn-primary', 'checkoutButton', ...extraClasses);
+ return (
+ checkoutOp.run()}>
+ {buttonText}
+
+ );
+ }
+
+}
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 (
+
{reactionTypeToEmoji[group.content]} {group.users.totalCount}
-
- : null);
- })}
+
+ );
+ })}
+
);
}
}
+
+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 (
+
+ See reviews
+
+ );
+ }
+
+ 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 (
- checkoutOp.run()}>
- {buttonText}
-
- );
- }
-
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} 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(
+
+ {reactionTypeToEmoji[content]}
+ ,
+ );
+ }
+
+ 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{' '}
+
+
+ See reviews
+
+ 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 (
+
+ );
+ }
+
+ 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 (
+
+ );
+ }
+
+ 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}
+
+
+
+
+
+ Jump To File
+
+
+ Open Diff
+
+ {this.props.checkoutOp.isEnabled() &&
+
+ }
+
+
+ {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.submitReply(replyHolder, thread, lastComment)}>
+ Comment
+
+ {this.renderResolveButton(thread)}
+
+
+ );
+ }
+
+ renderResolveButton = thread => {
+ if (thread.isResolved) {
+ return (
+ this.props.unresolveThread(thread)}>
+ Unresolve conversation
+
+ );
+ } else {
+ return (
+ this.props.resolveThread(thread)}>
+ Resolve conversation
+
+ );
+ }
+ }
+
+ renderComment = comment => {
+ if (comment.isMinimized) {
+ return (
+
+
+ This comment was hidden
+
+ );
+ }
+
+ const commentClass = cx('github-Review-comment', {'github-Review-comment--pending': comment.state === 'PENDING'});
+ return (
+
+ );
+ }
+
+ 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() {