Skip to content

Commit a606d09

Browse files
authored
Require variables for legacy GraphQL queries (#8889)
fd5f8d8 allowed legacy queries to replace their variables, but left the fallback variables optional. PullRequestComments supplied only a query, so retrying discarded owner, name, number, and the pagination cursor and failed with invalid-variable errors. Require variables whenever a legacy query is supplied. Pass the review-comment variables explicitly on every page, while preserving separate argument maps for queries with different inputs. Cover replacement and legacy pagination at the repository boundary. Handle missing repository data before reading review threads, so a missing response produces the intended diagnostic.
1 parent 9f1a80b commit a606d09

4 files changed

Lines changed: 108 additions & 8 deletions

File tree

src/github/githubRepository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ export class GitHubRepository extends Disposable {
328328
}
329329
}
330330

331-
query = async <T>(query: QueryOptions, ignoreSamlErrors: boolean = false, legacyFallback?: { query: DocumentNode, variables?: OperationVariables }): Promise<ApolloQueryResult<T>> => {
331+
query = async <T>(query: QueryOptions, ignoreSamlErrors: boolean = false, legacyFallback?: { query: DocumentNode, variables: OperationVariables }): Promise<ApolloQueryResult<T>> => {
332332
const gql = this.authMatchesServer && this.hub && this.hub.graphql;
333333
if (!gql) {
334334
const logValue = (query.query.definitions[0] as { name: { value: string } | undefined }).name?.value;

src/github/pullRequestModel.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,16 +1497,20 @@ export class PullRequestModel extends IssueModel<PullRequest> implements IPullRe
14971497
const reviewThreads: ReviewThread[] = [];
14981498
try {
14991499
do {
1500+
const variables = {
1501+
owner: remote.owner,
1502+
name: remote.repositoryName,
1503+
number: this.number,
1504+
after,
1505+
};
15001506
const { data } = await query<PullRequestCommentsResponse>({
15011507
query: schema.PullRequestComments,
1502-
variables: {
1503-
owner: remote.owner,
1504-
name: remote.repositoryName,
1505-
number: this.number,
1506-
after
1507-
},
1508-
}, false, { query: schema.LegacyPullRequestComments });
1508+
variables,
1509+
}, false, { query: schema.LegacyPullRequestComments, variables });
15091510

1511+
if (!data?.repository) {
1512+
throw new Error('Review comments response did not include a repository.');
1513+
}
15101514
reviewThreads.push(...data.repository.pullRequest.reviewThreads.nodes);
15111515

15121516
hasNextPage = data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage;

src/test/github/githubRepository.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { default as assert } from 'assert';
7+
import { NetworkStatus } from 'apollo-boost';
78
import { SinonSandbox, createSandbox } from 'sinon';
89
import { CredentialStore } from '../../github/credentials';
910
import { MockCommandRegistry } from '../mocks/mockCommandRegistry';
@@ -18,6 +19,7 @@ import { GitHubServerType } from '../../common/authentication';
1819
import { CheckState, PullRequestCheckStatus } from '../../github/interface';
1920
import { PullRequestBuilder as GraphQLPullRequestBuilder } from '../builders/graphql/pullRequestBuilder';
2021
import Logger from '../../common/logger';
22+
import { LoggingApolloClient, LoggingOctokit } from '../../github/loggingOctokit';
2123

2224
describe('GitHubRepository', function () {
2325
let sinon: SinonSandbox;
@@ -38,6 +40,36 @@ describe('GitHubRepository', function () {
3840
sinon.restore();
3941
});
4042

43+
describe('query', function () {
44+
it('replaces variables for a legacy query with different arguments', async function () {
45+
const url = 'https://github.com/some/repo';
46+
const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom);
47+
const repo = new GitHubRepository(1, remote, Uri.file('/workspaces/repo'), credentialStore, telemetry, true);
48+
const graphql = sinon.createStubInstance(LoggingApolloClient);
49+
sinon.stub(credentialStore, 'isAuthenticated').returns(true);
50+
sinon.stub(repo, 'hub').get(() => ({ graphql, octokit: sinon.createStubInstance(LoggingOctokit) }));
51+
const variables = { owner: 'some', name: 'repo', first: 100, after: 'cursor' };
52+
const response = { data: {}, loading: false, stale: false, networkStatus: NetworkStatus.ready };
53+
graphql.query.onFirstCall().rejects(new Error('Unsupported query'));
54+
graphql.query.onSecondCall().resolves(response);
55+
56+
try {
57+
const result = await repo.query({
58+
query: repo.schema.GetSuggestedActors,
59+
variables: { ...variables, capabilities: ['CAN_BE_ASSIGNED'] },
60+
}, false, { query: repo.schema.GetAssignableUsers, variables });
61+
62+
assert.strictEqual(result, response);
63+
assert.strictEqual(graphql.query.callCount, 2);
64+
const [fallback] = graphql.query.secondCall.args;
65+
assert.strictEqual(fallback.query, repo.schema.GetAssignableUsers);
66+
assert.deepStrictEqual(fallback.variables, variables);
67+
} finally {
68+
repo.dispose();
69+
}
70+
});
71+
});
72+
4173
describe('isGitHubDotCom', function () {
4274
it('detects when the remote is pointing to github.com', function () {
4375
const url = 'https://github.com/some/repo';

src/test/github/pullRequestModel.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import { NetworkStatus } from 'apollo-client';
1919
import { MockExtensionContext } from '../mocks/mockExtensionContext';
2020
import { GitHubServerType } from '../../common/authentication';
2121
import { mergeQuerySchemaWithShared } from '../../github/common';
22+
import { GitHubRepository } from '../../github/githubRepository';
23+
import { LoggingApolloClient, LoggingOctokit } from '../../github/loggingOctokit';
24+
import Logger from '../../common/logger';
2225
const queries = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any;
2326

2427
const telemetry = new MockTelemetry();
@@ -96,6 +99,67 @@ describe('PullRequestModel', function () {
9699
});
97100

98101
describe('reviewThreadCache', function () {
102+
function page(id: string, endCursor: string | null) {
103+
return {
104+
data: {
105+
repository: {
106+
pullRequest: {
107+
reviewThreads: {
108+
nodes: [{ ...reviewThreadResponse, id }],
109+
pageInfo: { hasNextPage: endCursor !== null, endCursor },
110+
},
111+
},
112+
},
113+
},
114+
loading: false,
115+
stale: false,
116+
networkStatus: NetworkStatus.ready,
117+
};
118+
}
119+
120+
it('passes review comment variables to every legacy page', async function () {
121+
const repository = new GitHubRepository(1, remote, repo.rootUri, credentials, telemetry, true);
122+
const graphql = sinon.createStubInstance(LoggingApolloClient);
123+
sinon.stub(credentials, 'isAuthenticated').returns(true);
124+
sinon.stub(repository, 'hub').get(() => ({ graphql, octokit: sinon.createStubInstance(LoggingOctokit) }));
125+
sinon.stub(repository, 'ensure').resolves(repository);
126+
graphql.query.onCall(0).rejects(new Error('Unsupported query'));
127+
graphql.query.onCall(1).resolves(page('1', 'first'));
128+
graphql.query.onCall(2).rejects(new Error('Unsupported query'));
129+
graphql.query.onCall(3).resolves(page('2', null));
130+
131+
try {
132+
const pr = new PullRequestBuilder().build();
133+
const model = new PullRequestModel(credentials, telemetry, repository, remote, convertRESTPullRequestToRawPullRequest(pr, repository));
134+
const threads = await model.getReviewThreads();
135+
136+
assert.deepStrictEqual(threads.map(thread => thread.id), ['1', '2']);
137+
assert.strictEqual(graphql.query.callCount, 4);
138+
for (const [call, after] of [[graphql.query.secondCall, null], [graphql.query.lastCall, 'first']] as const) {
139+
const [fallback] = call.args;
140+
assert.strictEqual(fallback.query, repository.schema.LegacyPullRequestComments);
141+
assert.deepStrictEqual(fallback.variables, {
142+
owner: remote.owner, name: remote.repositoryName, number: pr.number, after,
143+
});
144+
}
145+
} finally {
146+
repository.dispose();
147+
}
148+
});
149+
150+
it('reports missing review data without retrying', async function () {
151+
const pr = new PullRequestBuilder().build();
152+
const model = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo));
153+
const query = sinon.stub(repo, 'query').resolves({
154+
data: null, loading: false, stale: false, networkStatus: NetworkStatus.error,
155+
});
156+
const error = sinon.stub(Logger, 'error');
157+
158+
assert.deepStrictEqual(await model.getReviewThreads(), []);
159+
assert.strictEqual(query.callCount, 1);
160+
assert.strictEqual(error.lastCall.args[0], 'Failed to get pull request review comments: Error: Review comments response did not include a repository.');
161+
});
162+
99163
it('should update the cache when then cache is initialized', async function () {
100164
const pr = new PullRequestBuilder().build();
101165
const model = new PullRequestModel(

0 commit comments

Comments
 (0)