Skip to content

Commit a54b497

Browse files
authored
GitHub - refactor branch protection (microsoft#181880)
* GitHub - rewrite to use GraphQL instead of REST * Add paging
1 parent 0c85b95 commit a54b497

File tree

4 files changed

+161
-154
lines changed

4 files changed

+161
-154
lines changed

extensions/github/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@
176176
"watch": "gulp watch-extension:github"
177177
},
178178
"dependencies": {
179+
"@octokit/graphql": "5.0.5",
180+
"@octokit/graphql-schema": "14.4.0",
179181
"@octokit/rest": "19.0.4",
180182
"tunnel": "^0.0.6"
181183
},

extensions/github/src/auth.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { AuthenticationSession, authentication, window } from 'vscode';
77
import { Agent, globalAgent } from 'https';
8+
import { graphql } from '@octokit/graphql/dist-types/types';
89
import { Octokit } from '@octokit/rest';
910
import { httpsOverHttp } from 'tunnel';
1011
import { URL } from 'url';
@@ -53,3 +54,29 @@ export function getOctokit(): Promise<Octokit> {
5354

5455
return _octokit;
5556
}
57+
58+
let _octokitGraphql: Promise<graphql> | undefined;
59+
60+
export function getOctokitGraphql(): Promise<graphql> {
61+
if (!_octokitGraphql) {
62+
_octokitGraphql = getSession()
63+
.then(async session => {
64+
const token = session.accessToken;
65+
const { graphql } = await import('@octokit/graphql');
66+
67+
return graphql.defaults({
68+
headers: {
69+
authorization: `token ${token}`
70+
},
71+
request: {
72+
agent: getAgent()
73+
}
74+
});
75+
}).then(null, async err => {
76+
_octokitGraphql = undefined;
77+
throw err;
78+
});
79+
}
80+
81+
return _octokitGraphql;
82+
}

extensions/github/src/branchProtection.ts

Lines changed: 86 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,48 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { EventEmitter, LogOutputChannel, Memento, Uri, workspace } from 'vscode';
7-
import { getOctokit } from './auth';
7+
import { Repository as GitHubRepository, RepositoryRuleset } from '@octokit/graphql-schema';
8+
import { getOctokitGraphql } from './auth';
89
import { API, BranchProtection, BranchProtectionProvider, BranchProtectionRule, Repository } from './typings/git';
910
import { DisposableStore, getRepositoryFromUrl } from './util';
1011

11-
interface RepositoryRuleset {
12-
readonly id: number;
13-
readonly conditions: {
14-
ref_name: {
15-
exclude: string[];
16-
include: string[];
17-
};
18-
};
19-
readonly enforcement: 'active' | 'disabled' | 'evaluate';
20-
readonly rules: RepositoryRule[];
21-
readonly target: 'branch' | 'tag';
22-
}
23-
24-
interface RepositoryRule {
25-
readonly type: string;
26-
}
12+
const REPOSITORY_QUERY = `
13+
query repositoryPermissions($owner: String!, $repo: String!) {
14+
repository(owner: $owner, name: $repo) {
15+
defaultBranchRef {
16+
name
17+
},
18+
viewerPermission
19+
}
20+
}
21+
`;
22+
23+
const REPOSITORY_RULESETS_QUERY = `
24+
query repositoryRulesets($owner: String!, $repo: String!, $cursor: String, $limit: Int = 100) {
25+
repository(owner: $owner, name: $repo) {
26+
rulesets(includeParents: true, first: $limit, after: $cursor) {
27+
nodes {
28+
name
29+
enforcement
30+
rules(type: PULL_REQUEST) {
31+
totalCount
32+
}
33+
conditions {
34+
refName {
35+
include
36+
exclude
37+
}
38+
}
39+
target
40+
},
41+
pageInfo {
42+
endCursor,
43+
hasNextPage
44+
}
45+
}
46+
}
47+
}
48+
`;
2749

2850
export class GithubBranchProtectionProviderManager {
2951

@@ -92,130 +114,41 @@ export class GithubBranchProtectionProvider implements BranchProtectionProvider
92114
// Restore branch protection from global state
93115
this.branchProtection = this.globalState.get<BranchProtection[]>(this.globalStateKey, []);
94116

95-
repository.status()
96-
.then(() => this.initializeBranchProtection());
117+
repository.status().then(() => this.updateRepositoryBranchProtection());
97118
}
98119

99120
provideBranchProtection(): BranchProtection[] {
100121
return this.branchProtection;
101122
}
102123

103-
private async initializeBranchProtection(): Promise<void> {
104-
try {
105-
// Branch protection (HEAD)
106-
await this.updateHEADBranchProtection();
124+
private async getRepositoryDetails(owner: string, repo: string): Promise<GitHubRepository> {
125+
const graphql = await getOctokitGraphql();
126+
const { repository } = await graphql<{ repository: GitHubRepository }>(REPOSITORY_QUERY, { owner, repo });
107127

108-
// Branch protection (remotes)
109-
await this.updateRepositoryBranchProtection();
110-
} catch (err) {
111-
// noop
112-
this.logger.warn(`Failed to initialize branch protection: ${this.formatErrorMessage(err)}`);
113-
}
128+
return repository;
114129
}
115130

116-
private async hasPushPermission(repository: { owner: string; repo: string }): Promise<boolean> {
117-
try {
118-
const octokit = await getOctokit();
119-
const response = await octokit.repos.get({ ...repository });
131+
private async getRepositoryRulesets(owner: string, repo: string): Promise<RepositoryRuleset[]> {
132+
const rulesets: RepositoryRuleset[] = [];
120133

121-
return response.data.permissions?.push === true;
122-
} catch (err) {
123-
this.logger.warn(`Failed to get repository permissions for repository (${repository.owner}/${repository.repo}): ${this.formatErrorMessage(err)}`);
124-
throw err;
125-
}
126-
}
127-
128-
private async getBranchRules(repository: { owner: string; repo: string }, branch: string): Promise<RepositoryRule[]> {
129-
try {
130-
const octokit = await getOctokit();
131-
const response = await octokit.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', {
132-
...repository,
133-
branch,
134-
headers: {
135-
'X-GitHub-Api-Version': '2022-11-28'
136-
}
137-
});
138-
return response.data as RepositoryRule[];
139-
} catch (err) {
140-
this.logger.warn(`Failed to get branch rules for repository (${repository.owner}/${repository.repo}), branch (${branch}): ${this.formatErrorMessage(err)}`);
141-
throw err;
142-
}
143-
}
144-
145-
private async getRepositoryRulesets(repository: { owner: string; repo: string }): Promise<RepositoryRuleset[]> {
134+
let cursor: string | undefined = undefined;
135+
const graphql = await getOctokitGraphql();
146136

147-
try {
148-
const rulesets: RepositoryRuleset[] = [];
149-
const octokit = await getOctokit();
150-
for await (const response of octokit.paginate.iterator('GET /repos/{owner}/{repo}/rulesets', { ...repository, includes_parents: true })) {
151-
if (response.status !== 200) {
152-
continue;
153-
}
137+
while (true) {
138+
const { repository } = await graphql<{ repository: GitHubRepository }>(REPOSITORY_RULESETS_QUERY, { owner, repo, cursor });
154139

155-
for (const ruleset of response.data as RepositoryRuleset[]) {
156-
if (ruleset.target !== 'branch' || ruleset.enforcement !== 'active') {
157-
continue;
158-
}
140+
rulesets.push(...(repository.rulesets?.nodes ?? [])
141+
// Active branch ruleset that contains the pull request required rule
142+
.filter(node => node && node.target === 'BRANCH' && node.enforcement === 'ACTIVE' && (node.rules?.totalCount ?? 0) > 0) as RepositoryRuleset[]);
159143

160-
const response = await octokit.request('GET /repos/{owner}/{repo}/rulesets/{id}', {
161-
...repository,
162-
id: ruleset.id,
163-
headers: {
164-
'X-GitHub-Api-Version': '2022-11-28'
165-
}
166-
});
167-
168-
const rulesetWithDetails = response.data as RepositoryRuleset;
169-
if (rulesetWithDetails?.rules.find(r => r.type === 'pull_request')) {
170-
rulesets.push(rulesetWithDetails);
171-
}
172-
}
144+
if (repository.rulesets?.pageInfo.hasNextPage) {
145+
cursor = repository.rulesets.pageInfo.endCursor as string | undefined;
146+
} else {
147+
break;
173148
}
174-
175-
return rulesets;
176149
}
177-
catch (err) {
178-
this.logger.warn(`Failed to get repository rulesets for repository (${repository.owner}/${repository.repo}): ${this.formatErrorMessage(err)}`);
179-
throw err;
180-
}
181-
}
182-
183-
private async updateHEADBranchProtection(): Promise<void> {
184-
try {
185-
const HEAD = this.repository.state.HEAD;
186-
187-
if (!HEAD?.name || !HEAD?.upstream?.remote) {
188-
return;
189-
}
190-
191-
const remoteName = HEAD.upstream.remote;
192-
const remote = this.repository.state.remotes.find(r => r.name === remoteName);
193-
194-
if (!remote) {
195-
return;
196-
}
197-
198-
const repository = getRepositoryFromUrl(remote.pushUrl ?? remote.fetchUrl ?? '');
199-
200-
if (!repository) {
201-
return;
202-
}
203150

204-
if (!(await this.hasPushPermission(repository))) {
205-
return;
206-
}
207-
208-
const rules = await this.getBranchRules(repository, HEAD.name);
209-
if (!rules.find(r => r.type === 'pull_request')) {
210-
return;
211-
}
212-
213-
this.branchProtection = [{ remote: remote.name, rules: [{ include: [HEAD.name] }] }];
214-
this._onDidChangeBranchProtection.fire(this.repository.rootUri);
215-
} catch (err) {
216-
this.logger.warn(`Failed to update HEAD branch protection: ${this.formatErrorMessage(err)}`);
217-
throw err;
218-
}
151+
return rulesets;
219152
}
220153

221154
private async updateRepositoryBranchProtection(): Promise<void> {
@@ -229,38 +162,26 @@ export class GithubBranchProtectionProvider implements BranchProtectionProvider
229162
continue;
230163
}
231164

232-
if (!(await this.hasPushPermission(repository))) {
165+
// Repository details
166+
const repositoryDetails = await this.getRepositoryDetails(repository.owner, repository.repo);
167+
168+
// Check repository write permission
169+
if (repositoryDetails.viewerPermission !== 'ADMIN' && repositoryDetails.viewerPermission !== 'MAINTAIN' && repositoryDetails.viewerPermission !== 'WRITE') {
233170
continue;
234171
}
235172

236-
// Repository details
237-
const octokit = await getOctokit();
238-
const response = await octokit.repos.get({ ...repository });
239-
240-
// Repository rulesets
241-
const rulesets = await this.getRepositoryRulesets(repository);
242-
243-
const parseRef = (ref: string): string => {
244-
if (ref.startsWith('refs/heads/')) {
245-
return ref.substring(11);
246-
} else if (ref === '~DEFAULT_BRANCH') {
247-
return response.data.default_branch;
248-
} else if (ref === '~ALL') {
249-
return '**/*';
250-
}
251-
252-
return ref;
253-
};
173+
// Get repository rulesets
174+
const branchProtectionRules: BranchProtectionRule[] = [];
175+
const repositoryRulesets = await this.getRepositoryRulesets(repository.owner, repository.repo);
254176

255-
const rules: BranchProtectionRule[] = [];
256-
for (const ruleset of rulesets) {
257-
rules.push({
258-
include: ruleset.conditions.ref_name.include.map(r => parseRef(r)),
259-
exclude: ruleset.conditions.ref_name.exclude.map(r => parseRef(r))
177+
for (const ruleset of repositoryRulesets) {
178+
branchProtectionRules.push({
179+
include: (ruleset.conditions.refName?.include ?? []).map(r => this.parseRulesetRefName(repositoryDetails, r)),
180+
exclude: (ruleset.conditions.refName?.exclude ?? []).map(r => this.parseRulesetRefName(repositoryDetails, r))
260181
});
261182
}
262183

263-
branchProtection.push({ remote: remote.name, rules });
184+
branchProtection.push({ remote: remote.name, rules: branchProtectionRules });
264185
}
265186

266187
this.branchProtection = branchProtection;
@@ -269,12 +190,23 @@ export class GithubBranchProtectionProvider implements BranchProtectionProvider
269190
// Save branch protection to global state
270191
await this.globalState.update(this.globalStateKey, branchProtection);
271192
} catch (err) {
272-
this.logger.warn(`Failed to update repository branch protection: ${this.formatErrorMessage(err)}`);
273-
throw err;
193+
// noop
194+
this.logger.warn(`Failed to update repository branch protection: ${err.message}`);
274195
}
275196
}
276197

277-
private formatErrorMessage(err: any): string {
278-
return `${err.message ?? ''}${err.status ? ` (${err.status})` : ''}`;
198+
private parseRulesetRefName(repository: GitHubRepository, refName: string): string {
199+
if (refName.startsWith('refs/heads/')) {
200+
return refName.substring(11);
201+
}
202+
203+
switch (refName) {
204+
case '~ALL':
205+
return '**/*';
206+
case '~DEFAULT_BRANCH':
207+
return repository.defaultBranchRef!.name;
208+
default:
209+
return refName;
210+
}
279211
}
280212
}

0 commit comments

Comments
 (0)