@forge-glance/sdk 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/GitHubProvider.d.ts +14 -3
- package/dist/GitHubProvider.js +95 -2
- package/dist/GitLabProvider.d.ts +20 -3
- package/dist/GitLabProvider.js +185 -10
- package/dist/GitProvider.d.ts +64 -1
- package/dist/index.d.ts +14 -14
- package/dist/index.js +280 -12
- package/dist/providers.js +280 -12
- package/dist/types.d.ts +56 -1
- package/package.json +1 -1
- package/src/GitHubProvider.ts +424 -154
- package/src/GitLabProvider.ts +426 -88
- package/src/GitProvider.ts +113 -21
- package/src/index.ts +22 -15
- package/src/types.ts +59 -1
package/src/GitHubProvider.ts
CHANGED
|
@@ -12,23 +12,25 @@
|
|
|
12
12
|
* provides the instance URL and we append "/api/v3".
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type { GitProvider } from
|
|
15
|
+
import type { GitProvider } from './GitProvider.ts';
|
|
16
16
|
import type {
|
|
17
17
|
BranchProtectionRule,
|
|
18
18
|
CreatePullRequestInput,
|
|
19
19
|
DiffStats,
|
|
20
20
|
Discussion,
|
|
21
|
+
MergePullRequestInput,
|
|
21
22
|
MRDetail,
|
|
22
23
|
Note,
|
|
23
24
|
NoteAuthor,
|
|
24
25
|
NotePosition,
|
|
25
26
|
Pipeline,
|
|
26
27
|
PipelineJob,
|
|
28
|
+
ProviderCapabilities,
|
|
27
29
|
PullRequest,
|
|
28
30
|
UpdatePullRequestInput,
|
|
29
|
-
UserRef
|
|
30
|
-
} from
|
|
31
|
-
import { type ForgeLogger, noopLogger } from
|
|
31
|
+
UserRef
|
|
32
|
+
} from './types.ts';
|
|
33
|
+
import { type ForgeLogger, noopLogger } from './logger.ts';
|
|
32
34
|
|
|
33
35
|
// ---------------------------------------------------------------------------
|
|
34
36
|
// GitHub REST API response shapes (only fields we consume)
|
|
@@ -122,7 +124,7 @@ function toUserRef(u: GHUser): UserRef {
|
|
|
122
124
|
id: `github:user:${u.id}`,
|
|
123
125
|
username: u.login,
|
|
124
126
|
name: u.name ?? u.login,
|
|
125
|
-
avatarUrl: u.avatar_url
|
|
127
|
+
avatarUrl: u.avatar_url
|
|
126
128
|
};
|
|
127
129
|
}
|
|
128
130
|
|
|
@@ -131,9 +133,9 @@ function toUserRef(u: GHUser): UserRef {
|
|
|
131
133
|
* GitHub only has "open" and "closed"; we check `merged_at` to distinguish merges.
|
|
132
134
|
*/
|
|
133
135
|
function normalizePRState(pr: GHPullRequest): string {
|
|
134
|
-
if (pr.merged_at) return
|
|
135
|
-
if (pr.state ===
|
|
136
|
-
return
|
|
136
|
+
if (pr.merged_at) return 'merged';
|
|
137
|
+
if (pr.state === 'open') return 'opened';
|
|
138
|
+
return 'closed';
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
/**
|
|
@@ -142,32 +144,32 @@ function normalizePRState(pr: GHPullRequest): string {
|
|
|
142
144
|
*/
|
|
143
145
|
function toPipeline(
|
|
144
146
|
checkRuns: GHCheckRun[],
|
|
145
|
-
prHtmlUrl: string
|
|
147
|
+
prHtmlUrl: string
|
|
146
148
|
): Pipeline | null {
|
|
147
149
|
if (checkRuns.length === 0) return null;
|
|
148
150
|
|
|
149
|
-
const jobs: PipelineJob[] = checkRuns.map(
|
|
151
|
+
const jobs: PipelineJob[] = checkRuns.map(cr => ({
|
|
150
152
|
id: `github:check:${cr.id}`,
|
|
151
153
|
name: cr.name,
|
|
152
|
-
stage:
|
|
154
|
+
stage: 'checks', // GitHub doesn't have stages; use a flat stage name
|
|
153
155
|
status: normalizeCheckStatus(cr),
|
|
154
156
|
allowFailure: false,
|
|
155
|
-
webUrl: cr.html_url
|
|
157
|
+
webUrl: cr.html_url
|
|
156
158
|
}));
|
|
157
159
|
|
|
158
160
|
// Derive overall pipeline status from individual check runs
|
|
159
|
-
const statuses = jobs.map(
|
|
161
|
+
const statuses = jobs.map(j => j.status);
|
|
160
162
|
let overallStatus: string;
|
|
161
|
-
if (statuses.some(
|
|
162
|
-
overallStatus =
|
|
163
|
-
} else if (statuses.some(
|
|
164
|
-
overallStatus =
|
|
165
|
-
} else if (statuses.some(
|
|
166
|
-
overallStatus =
|
|
167
|
-
} else if (statuses.every(
|
|
168
|
-
overallStatus =
|
|
163
|
+
if (statuses.some(s => s === 'failed')) {
|
|
164
|
+
overallStatus = 'failed';
|
|
165
|
+
} else if (statuses.some(s => s === 'running')) {
|
|
166
|
+
overallStatus = 'running';
|
|
167
|
+
} else if (statuses.some(s => s === 'pending')) {
|
|
168
|
+
overallStatus = 'pending';
|
|
169
|
+
} else if (statuses.every(s => s === 'success' || s === 'skipped')) {
|
|
170
|
+
overallStatus = 'success';
|
|
169
171
|
} else {
|
|
170
|
-
overallStatus =
|
|
172
|
+
overallStatus = 'pending';
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
return {
|
|
@@ -175,32 +177,32 @@ function toPipeline(
|
|
|
175
177
|
status: overallStatus,
|
|
176
178
|
createdAt: null,
|
|
177
179
|
webUrl: `${prHtmlUrl}/checks`,
|
|
178
|
-
jobs
|
|
180
|
+
jobs
|
|
179
181
|
};
|
|
180
182
|
}
|
|
181
183
|
|
|
182
184
|
function normalizeCheckStatus(cr: GHCheckRun): string {
|
|
183
|
-
if (cr.status ===
|
|
185
|
+
if (cr.status === 'completed') {
|
|
184
186
|
switch (cr.conclusion) {
|
|
185
|
-
case
|
|
186
|
-
return
|
|
187
|
-
case
|
|
188
|
-
case
|
|
189
|
-
return
|
|
190
|
-
case
|
|
191
|
-
return
|
|
192
|
-
case
|
|
193
|
-
return
|
|
194
|
-
case
|
|
195
|
-
return
|
|
196
|
-
case
|
|
197
|
-
return
|
|
187
|
+
case 'success':
|
|
188
|
+
return 'success';
|
|
189
|
+
case 'failure':
|
|
190
|
+
case 'timed_out':
|
|
191
|
+
return 'failed';
|
|
192
|
+
case 'cancelled':
|
|
193
|
+
return 'canceled';
|
|
194
|
+
case 'skipped':
|
|
195
|
+
return 'skipped';
|
|
196
|
+
case 'neutral':
|
|
197
|
+
return 'success';
|
|
198
|
+
case 'action_required':
|
|
199
|
+
return 'manual';
|
|
198
200
|
default:
|
|
199
|
-
return
|
|
201
|
+
return 'pending';
|
|
200
202
|
}
|
|
201
203
|
}
|
|
202
|
-
if (cr.status ===
|
|
203
|
-
return
|
|
204
|
+
if (cr.status === 'in_progress') return 'running';
|
|
205
|
+
return 'pending'; // "queued"
|
|
204
206
|
}
|
|
205
207
|
|
|
206
208
|
// ---------------------------------------------------------------------------
|
|
@@ -208,7 +210,7 @@ function normalizeCheckStatus(cr: GHCheckRun): string {
|
|
|
208
210
|
// ---------------------------------------------------------------------------
|
|
209
211
|
|
|
210
212
|
export class GitHubProvider implements GitProvider {
|
|
211
|
-
readonly providerName =
|
|
213
|
+
readonly providerName = 'github' as const;
|
|
212
214
|
readonly baseURL: string;
|
|
213
215
|
private readonly apiBase: string;
|
|
214
216
|
private readonly token: string;
|
|
@@ -220,29 +222,46 @@ export class GitHubProvider implements GitProvider {
|
|
|
220
222
|
* @param token — A GitHub PAT (classic or fine-grained) with `repo` scope.
|
|
221
223
|
* @param options.logger — Optional logger; defaults to noop.
|
|
222
224
|
*/
|
|
223
|
-
constructor(
|
|
224
|
-
|
|
225
|
+
constructor(
|
|
226
|
+
baseURL: string,
|
|
227
|
+
token: string,
|
|
228
|
+
options: { logger?: ForgeLogger } = {}
|
|
229
|
+
) {
|
|
230
|
+
this.baseURL = baseURL.replace(/\/$/, '');
|
|
225
231
|
this.token = token;
|
|
226
232
|
this.log = options.logger ?? noopLogger;
|
|
227
233
|
|
|
228
234
|
// API base: github.com uses api.github.com; GHES uses <host>/api/v3
|
|
229
235
|
if (
|
|
230
|
-
this.baseURL ===
|
|
231
|
-
this.baseURL ===
|
|
236
|
+
this.baseURL === 'https://github.com' ||
|
|
237
|
+
this.baseURL === 'https://www.github.com'
|
|
232
238
|
) {
|
|
233
|
-
this.apiBase =
|
|
239
|
+
this.apiBase = 'https://api.github.com';
|
|
234
240
|
} else {
|
|
235
241
|
this.apiBase = `${this.baseURL}/api/v3`;
|
|
236
242
|
}
|
|
237
243
|
}
|
|
238
244
|
|
|
245
|
+
// ── Capabilities ──────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
readonly capabilities: ProviderCapabilities = {
|
|
248
|
+
canMerge: true,
|
|
249
|
+
canApprove: true,
|
|
250
|
+
canUnapprove: false,
|
|
251
|
+
canRebase: false,
|
|
252
|
+
canAutoMerge: false,
|
|
253
|
+
canResolveDiscussions: false,
|
|
254
|
+
canRetryPipeline: true,
|
|
255
|
+
canRequestReReview: true
|
|
256
|
+
};
|
|
257
|
+
|
|
239
258
|
// ── GitProvider interface ─────────────────────────────────────────────────
|
|
240
259
|
|
|
241
260
|
async validateToken(): Promise<UserRef> {
|
|
242
|
-
const res = await this.api(
|
|
261
|
+
const res = await this.api('GET', '/user');
|
|
243
262
|
if (!res.ok) {
|
|
244
263
|
throw new Error(
|
|
245
|
-
`GitHub token validation failed: ${res.status} ${res.statusText}
|
|
264
|
+
`GitHub token validation failed: ${res.status} ${res.statusText}`
|
|
246
265
|
);
|
|
247
266
|
}
|
|
248
267
|
const user = (await res.json()) as GHUser;
|
|
@@ -255,9 +274,9 @@ export class GitHubProvider implements GitProvider {
|
|
|
255
274
|
// review requests. We merge the results.
|
|
256
275
|
|
|
257
276
|
const [authored, reviewRequested, assigned] = await Promise.all([
|
|
258
|
-
this.searchPRs(
|
|
259
|
-
this.searchPRs(
|
|
260
|
-
this.searchPRs(
|
|
277
|
+
this.searchPRs('is:open is:pr author:@me'),
|
|
278
|
+
this.searchPRs('is:open is:pr review-requested:@me'),
|
|
279
|
+
this.searchPRs('is:open is:pr assignee:@me')
|
|
261
280
|
]);
|
|
262
281
|
|
|
263
282
|
// Deduplicate by PR number+repo
|
|
@@ -277,65 +296,67 @@ export class GitHubProvider implements GitProvider {
|
|
|
277
296
|
}
|
|
278
297
|
};
|
|
279
298
|
|
|
280
|
-
addAll(authored,
|
|
281
|
-
addAll(reviewRequested,
|
|
282
|
-
addAll(assigned,
|
|
299
|
+
addAll(authored, 'author');
|
|
300
|
+
addAll(reviewRequested, 'reviewer');
|
|
301
|
+
addAll(assigned, 'assignee');
|
|
283
302
|
|
|
284
303
|
// For each unique PR, fetch check runs and reviews in parallel
|
|
285
304
|
const entries = [...byKey.entries()];
|
|
286
305
|
const results = await Promise.all(
|
|
287
306
|
entries.map(async ([key, pr]) => {
|
|
288
|
-
const prRoles = roles.get(key) ?? [
|
|
307
|
+
const prRoles = roles.get(key) ?? ['author'];
|
|
289
308
|
const [reviews, checkRuns] = await Promise.all([
|
|
290
309
|
this.fetchReviews(pr.base.repo.full_name, pr.number),
|
|
291
|
-
this.fetchCheckRuns(pr.base.repo.full_name, pr.head.sha)
|
|
310
|
+
this.fetchCheckRuns(pr.base.repo.full_name, pr.head.sha)
|
|
292
311
|
]);
|
|
293
312
|
return this.toPullRequest(pr, prRoles, reviews, checkRuns);
|
|
294
|
-
})
|
|
313
|
+
})
|
|
295
314
|
);
|
|
296
315
|
|
|
297
|
-
this.log.debug(
|
|
316
|
+
this.log.debug('GitHubProvider.fetchPullRequests', {
|
|
317
|
+
count: results.length
|
|
318
|
+
});
|
|
298
319
|
return results;
|
|
299
320
|
}
|
|
300
321
|
|
|
301
322
|
async fetchSingleMR(
|
|
302
323
|
projectPath: string,
|
|
303
324
|
mrIid: number,
|
|
304
|
-
_currentUserNumericId: number | null
|
|
325
|
+
_currentUserNumericId: number | null
|
|
305
326
|
): Promise<PullRequest | null> {
|
|
306
327
|
// projectPath for GitHub is "owner/repo"
|
|
307
328
|
try {
|
|
308
|
-
const res = await this.api(
|
|
329
|
+
const res = await this.api('GET', `/repos/${projectPath}/pulls/${mrIid}`);
|
|
309
330
|
if (!res.ok) return null;
|
|
310
331
|
|
|
311
332
|
const pr = (await res.json()) as GHPullRequest;
|
|
312
333
|
const [reviews, checkRuns] = await Promise.all([
|
|
313
334
|
this.fetchReviews(projectPath, mrIid),
|
|
314
|
-
this.fetchCheckRuns(projectPath, pr.head.sha)
|
|
335
|
+
this.fetchCheckRuns(projectPath, pr.head.sha)
|
|
315
336
|
]);
|
|
316
337
|
|
|
317
338
|
// Determine roles from the current user
|
|
318
|
-
const currentUser = await this.api(
|
|
339
|
+
const currentUser = await this.api('GET', '/user');
|
|
319
340
|
const currentUserData = (await currentUser.json()) as GHUser;
|
|
320
341
|
const prRoles: string[] = [];
|
|
321
|
-
if (pr.user.id === currentUserData.id) prRoles.push(
|
|
322
|
-
if (pr.assignees.some(
|
|
323
|
-
prRoles.push(
|
|
324
|
-
if (pr.requested_reviewers.some(
|
|
325
|
-
prRoles.push(
|
|
342
|
+
if (pr.user.id === currentUserData.id) prRoles.push('author');
|
|
343
|
+
if (pr.assignees.some(a => a.id === currentUserData.id))
|
|
344
|
+
prRoles.push('assignee');
|
|
345
|
+
if (pr.requested_reviewers.some(r => r.id === currentUserData.id))
|
|
346
|
+
prRoles.push('reviewer');
|
|
326
347
|
|
|
327
348
|
return this.toPullRequest(
|
|
328
349
|
pr,
|
|
329
|
-
prRoles.length > 0 ? prRoles : [
|
|
350
|
+
prRoles.length > 0 ? prRoles : ['author'],
|
|
330
351
|
reviews,
|
|
331
|
-
checkRuns
|
|
352
|
+
checkRuns
|
|
332
353
|
);
|
|
333
354
|
} catch (err) {
|
|
334
355
|
const message = err instanceof Error ? err.message : String(err);
|
|
335
|
-
this.log.warn(
|
|
356
|
+
this.log.warn('GitHubProvider.fetchSingleMR failed', {
|
|
336
357
|
projectPath,
|
|
337
358
|
mrIid,
|
|
338
|
-
message
|
|
359
|
+
message
|
|
339
360
|
});
|
|
340
361
|
return null;
|
|
341
362
|
}
|
|
@@ -343,11 +364,11 @@ export class GitHubProvider implements GitProvider {
|
|
|
343
364
|
|
|
344
365
|
async fetchMRDiscussions(
|
|
345
366
|
repositoryId: string,
|
|
346
|
-
mrIid: number
|
|
367
|
+
mrIid: number
|
|
347
368
|
): Promise<MRDetail> {
|
|
348
|
-
const repoId = parseInt(repositoryId.split(
|
|
369
|
+
const repoId = parseInt(repositoryId.split(':').pop() ?? '0', 10);
|
|
349
370
|
// We need the repo full_name. For now, look it up from the API.
|
|
350
|
-
const repoRes = await this.api(
|
|
371
|
+
const repoRes = await this.api('GET', `/repositories/${repoId}`);
|
|
351
372
|
if (!repoRes.ok) {
|
|
352
373
|
throw new Error(`Failed to fetch repo: ${repoRes.status}`);
|
|
353
374
|
}
|
|
@@ -356,11 +377,11 @@ export class GitHubProvider implements GitProvider {
|
|
|
356
377
|
// Fetch review comments (diff-level) and issue comments (PR-level)
|
|
357
378
|
const [reviewComments, issueComments] = await Promise.all([
|
|
358
379
|
this.fetchAllPages<GHComment>(
|
|
359
|
-
`/repos/${repo.full_name}/pulls/${mrIid}/comments?per_page=100
|
|
380
|
+
`/repos/${repo.full_name}/pulls/${mrIid}/comments?per_page=100`
|
|
360
381
|
),
|
|
361
382
|
this.fetchAllPages<GHComment>(
|
|
362
|
-
`/repos/${repo.full_name}/issues/${mrIid}/comments?per_page=100
|
|
363
|
-
)
|
|
383
|
+
`/repos/${repo.full_name}/issues/${mrIid}/comments?per_page=100`
|
|
384
|
+
)
|
|
364
385
|
]);
|
|
365
386
|
|
|
366
387
|
// Group review comments into threads (by pull_request_review_id and in_reply_to_id)
|
|
@@ -372,7 +393,7 @@ export class GitHubProvider implements GitProvider {
|
|
|
372
393
|
id: `gh-issue-comment-${c.id}`,
|
|
373
394
|
resolvable: null,
|
|
374
395
|
resolved: null,
|
|
375
|
-
notes: [toNote(c)]
|
|
396
|
+
notes: [toNote(c)]
|
|
376
397
|
});
|
|
377
398
|
}
|
|
378
399
|
|
|
@@ -388,30 +409,40 @@ export class GitHubProvider implements GitProvider {
|
|
|
388
409
|
for (const [rootId, comments] of threadMap) {
|
|
389
410
|
comments.sort(
|
|
390
411
|
(a, b) =>
|
|
391
|
-
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
|
412
|
+
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
|
392
413
|
);
|
|
393
414
|
discussions.push({
|
|
394
415
|
id: `gh-review-thread-${rootId}`,
|
|
395
416
|
resolvable: true,
|
|
396
417
|
resolved: null, // GitHub doesn't have a native "resolved" state on review threads
|
|
397
|
-
notes: comments.map(toNote)
|
|
418
|
+
notes: comments.map(toNote)
|
|
398
419
|
});
|
|
399
420
|
}
|
|
400
421
|
|
|
401
422
|
return { mrIid, repositoryId, discussions };
|
|
402
423
|
}
|
|
403
424
|
|
|
404
|
-
async fetchBranchProtectionRules(
|
|
405
|
-
|
|
425
|
+
async fetchBranchProtectionRules(
|
|
426
|
+
projectPath: string
|
|
427
|
+
): Promise<BranchProtectionRule[]> {
|
|
428
|
+
const res = await this.api(
|
|
429
|
+
'GET',
|
|
430
|
+
`/repos/${projectPath}/branches?protected=true&per_page=100`
|
|
431
|
+
);
|
|
406
432
|
if (!res.ok) {
|
|
407
|
-
throw new Error(
|
|
433
|
+
throw new Error(
|
|
434
|
+
`fetchBranchProtectionRules failed: ${res.status} ${await res.text()}`
|
|
435
|
+
);
|
|
408
436
|
}
|
|
409
437
|
const branches = (await res.json()) as Array<{
|
|
410
438
|
name: string;
|
|
411
439
|
protected: boolean;
|
|
412
440
|
protection?: {
|
|
413
441
|
enabled: boolean;
|
|
414
|
-
required_status_checks?: {
|
|
442
|
+
required_status_checks?: {
|
|
443
|
+
enforcement_level: string;
|
|
444
|
+
contexts: string[];
|
|
445
|
+
} | null;
|
|
415
446
|
};
|
|
416
447
|
}>;
|
|
417
448
|
|
|
@@ -419,37 +450,49 @@ export class GitHubProvider implements GitProvider {
|
|
|
419
450
|
for (const b of branches) {
|
|
420
451
|
if (!b.protected) continue;
|
|
421
452
|
// Fetch detailed protection for each protected branch
|
|
422
|
-
const detailRes = await this.api(
|
|
453
|
+
const detailRes = await this.api(
|
|
454
|
+
'GET',
|
|
455
|
+
`/repos/${projectPath}/branches/${encodeURIComponent(b.name)}/protection`
|
|
456
|
+
);
|
|
423
457
|
if (!detailRes.ok) {
|
|
424
458
|
rules.push({
|
|
425
459
|
pattern: b.name,
|
|
426
460
|
allowForcePush: false,
|
|
427
461
|
allowDeletion: false,
|
|
428
462
|
requiredApprovals: 0,
|
|
429
|
-
requireStatusChecks: false
|
|
463
|
+
requireStatusChecks: false
|
|
430
464
|
});
|
|
431
465
|
continue;
|
|
432
466
|
}
|
|
433
467
|
const detail = (await detailRes.json()) as {
|
|
434
468
|
allow_force_pushes?: { enabled: boolean };
|
|
435
469
|
allow_deletions?: { enabled: boolean };
|
|
436
|
-
required_pull_request_reviews?: {
|
|
470
|
+
required_pull_request_reviews?: {
|
|
471
|
+
required_approving_review_count?: number;
|
|
472
|
+
} | null;
|
|
437
473
|
required_status_checks?: { strict: boolean; contexts: string[] } | null;
|
|
438
474
|
};
|
|
439
475
|
rules.push({
|
|
440
476
|
pattern: b.name,
|
|
441
477
|
allowForcePush: detail.allow_force_pushes?.enabled ?? false,
|
|
442
478
|
allowDeletion: detail.allow_deletions?.enabled ?? false,
|
|
443
|
-
requiredApprovals:
|
|
444
|
-
|
|
445
|
-
|
|
479
|
+
requiredApprovals:
|
|
480
|
+
detail.required_pull_request_reviews
|
|
481
|
+
?.required_approving_review_count ?? 0,
|
|
482
|
+
requireStatusChecks:
|
|
483
|
+
detail.required_status_checks !== null &&
|
|
484
|
+
detail.required_status_checks !== undefined,
|
|
485
|
+
raw: detail as unknown as Record<string, unknown>
|
|
446
486
|
});
|
|
447
487
|
}
|
|
448
488
|
return rules;
|
|
449
489
|
}
|
|
450
490
|
|
|
451
491
|
async deleteBranch(projectPath: string, branch: string): Promise<void> {
|
|
452
|
-
const res = await this.api(
|
|
492
|
+
const res = await this.api(
|
|
493
|
+
'DELETE',
|
|
494
|
+
`/repos/${projectPath}/git/refs/heads/${encodeURIComponent(branch)}`
|
|
495
|
+
);
|
|
453
496
|
if (!res.ok) {
|
|
454
497
|
throw new Error(`deleteBranch failed: ${res.status} ${await res.text()}`);
|
|
455
498
|
}
|
|
@@ -457,11 +500,18 @@ export class GitHubProvider implements GitProvider {
|
|
|
457
500
|
|
|
458
501
|
async fetchPullRequestByBranch(
|
|
459
502
|
projectPath: string,
|
|
460
|
-
sourceBranch: string
|
|
503
|
+
sourceBranch: string
|
|
461
504
|
): Promise<PullRequest | null> {
|
|
462
|
-
const res = await this.api(
|
|
505
|
+
const res = await this.api(
|
|
506
|
+
'GET',
|
|
507
|
+
`/repos/${projectPath}/pulls?head=${projectPath.split('/')[0]}:${encodeURIComponent(sourceBranch)}&state=open&per_page=1`
|
|
508
|
+
);
|
|
463
509
|
if (!res.ok) {
|
|
464
|
-
this.log.warn(
|
|
510
|
+
this.log.warn('fetchPullRequestByBranch failed', {
|
|
511
|
+
projectPath,
|
|
512
|
+
sourceBranch,
|
|
513
|
+
status: res.status
|
|
514
|
+
});
|
|
465
515
|
return null;
|
|
466
516
|
}
|
|
467
517
|
const prs = (await res.json()) as GHPullRequest[];
|
|
@@ -473,12 +523,16 @@ export class GitHubProvider implements GitProvider {
|
|
|
473
523
|
const body: Record<string, unknown> = {
|
|
474
524
|
head: input.sourceBranch,
|
|
475
525
|
base: input.targetBranch,
|
|
476
|
-
title: input.title
|
|
526
|
+
title: input.title
|
|
477
527
|
};
|
|
478
528
|
if (input.description != null) body.body = input.description;
|
|
479
529
|
if (input.draft != null) body.draft = input.draft;
|
|
480
530
|
|
|
481
|
-
const res = await this.api(
|
|
531
|
+
const res = await this.api(
|
|
532
|
+
'POST',
|
|
533
|
+
`/repos/${input.projectPath}/pulls`,
|
|
534
|
+
body
|
|
535
|
+
);
|
|
482
536
|
if (!res.ok) {
|
|
483
537
|
const text = await res.text();
|
|
484
538
|
throw new Error(`createPullRequest failed: ${res.status} ${text}`);
|
|
@@ -487,39 +541,60 @@ export class GitHubProvider implements GitProvider {
|
|
|
487
541
|
|
|
488
542
|
// GitHub doesn't support reviewers/assignees/labels on create — add them separately
|
|
489
543
|
if (input.reviewers?.length) {
|
|
490
|
-
await this.api(
|
|
491
|
-
|
|
492
|
-
|
|
544
|
+
await this.api(
|
|
545
|
+
'POST',
|
|
546
|
+
`/repos/${input.projectPath}/pulls/${created.number}/requested_reviewers`,
|
|
547
|
+
{
|
|
548
|
+
reviewers: input.reviewers
|
|
549
|
+
}
|
|
550
|
+
);
|
|
493
551
|
}
|
|
494
552
|
if (input.assignees?.length) {
|
|
495
|
-
await this.api(
|
|
496
|
-
|
|
497
|
-
|
|
553
|
+
await this.api(
|
|
554
|
+
'POST',
|
|
555
|
+
`/repos/${input.projectPath}/issues/${created.number}/assignees`,
|
|
556
|
+
{
|
|
557
|
+
assignees: input.assignees
|
|
558
|
+
}
|
|
559
|
+
);
|
|
498
560
|
}
|
|
499
561
|
if (input.labels?.length) {
|
|
500
|
-
await this.api(
|
|
501
|
-
|
|
502
|
-
|
|
562
|
+
await this.api(
|
|
563
|
+
'POST',
|
|
564
|
+
`/repos/${input.projectPath}/issues/${created.number}/labels`,
|
|
565
|
+
{
|
|
566
|
+
labels: input.labels
|
|
567
|
+
}
|
|
568
|
+
);
|
|
503
569
|
}
|
|
504
570
|
|
|
505
|
-
const pr = await this.fetchSingleMR(
|
|
506
|
-
|
|
571
|
+
const pr = await this.fetchSingleMR(
|
|
572
|
+
input.projectPath,
|
|
573
|
+
created.number,
|
|
574
|
+
null
|
|
575
|
+
);
|
|
576
|
+
if (!pr) throw new Error('Created PR but failed to fetch it back');
|
|
507
577
|
return pr;
|
|
508
578
|
}
|
|
509
579
|
|
|
510
580
|
async updatePullRequest(
|
|
511
581
|
projectPath: string,
|
|
512
582
|
mrIid: number,
|
|
513
|
-
input: UpdatePullRequestInput
|
|
583
|
+
input: UpdatePullRequestInput
|
|
514
584
|
): Promise<PullRequest> {
|
|
515
585
|
const body: Record<string, unknown> = {};
|
|
516
586
|
if (input.title != null) body.title = input.title;
|
|
517
587
|
if (input.description != null) body.body = input.description;
|
|
518
588
|
if (input.draft != null) body.draft = input.draft;
|
|
519
589
|
if (input.targetBranch != null) body.base = input.targetBranch;
|
|
520
|
-
if (input.stateEvent)
|
|
590
|
+
if (input.stateEvent)
|
|
591
|
+
body.state = input.stateEvent === 'close' ? 'closed' : 'open';
|
|
521
592
|
|
|
522
|
-
const res = await this.api(
|
|
593
|
+
const res = await this.api(
|
|
594
|
+
'PATCH',
|
|
595
|
+
`/repos/${projectPath}/pulls/${mrIid}`,
|
|
596
|
+
body
|
|
597
|
+
);
|
|
523
598
|
if (!res.ok) {
|
|
524
599
|
const text = await res.text();
|
|
525
600
|
throw new Error(`updatePullRequest failed: ${res.status} ${text}`);
|
|
@@ -527,54 +602,251 @@ export class GitHubProvider implements GitProvider {
|
|
|
527
602
|
|
|
528
603
|
// Handle reviewers/assignees/labels replacement if provided
|
|
529
604
|
if (input.reviewers) {
|
|
530
|
-
await this.api(
|
|
531
|
-
|
|
532
|
-
|
|
605
|
+
await this.api(
|
|
606
|
+
'POST',
|
|
607
|
+
`/repos/${projectPath}/pulls/${mrIid}/requested_reviewers`,
|
|
608
|
+
{
|
|
609
|
+
reviewers: input.reviewers
|
|
610
|
+
}
|
|
611
|
+
);
|
|
533
612
|
}
|
|
534
613
|
if (input.assignees) {
|
|
535
|
-
await this.api(
|
|
536
|
-
|
|
537
|
-
|
|
614
|
+
await this.api(
|
|
615
|
+
'POST',
|
|
616
|
+
`/repos/${projectPath}/issues/${mrIid}/assignees`,
|
|
617
|
+
{
|
|
618
|
+
assignees: input.assignees
|
|
619
|
+
}
|
|
620
|
+
);
|
|
538
621
|
}
|
|
539
622
|
if (input.labels) {
|
|
540
|
-
await this.api(
|
|
541
|
-
labels: input.labels
|
|
623
|
+
await this.api('PUT', `/repos/${projectPath}/issues/${mrIid}/labels`, {
|
|
624
|
+
labels: input.labels
|
|
542
625
|
});
|
|
543
626
|
}
|
|
544
627
|
|
|
545
628
|
const pr = await this.fetchSingleMR(projectPath, mrIid, null);
|
|
546
|
-
if (!pr) throw new Error(
|
|
629
|
+
if (!pr) throw new Error('Updated PR but failed to fetch it back');
|
|
547
630
|
return pr;
|
|
548
631
|
}
|
|
549
632
|
|
|
550
633
|
async restRequest(
|
|
551
634
|
method: string,
|
|
552
635
|
path: string,
|
|
553
|
-
body?: unknown
|
|
636
|
+
body?: unknown
|
|
554
637
|
): Promise<Response> {
|
|
555
638
|
return this.api(method, path, body);
|
|
556
639
|
}
|
|
557
640
|
|
|
641
|
+
// ── MR lifecycle mutations ──────────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
async mergePullRequest(
|
|
644
|
+
projectPath: string,
|
|
645
|
+
mrIid: number,
|
|
646
|
+
input?: MergePullRequestInput
|
|
647
|
+
): Promise<PullRequest> {
|
|
648
|
+
const body: Record<string, unknown> = {};
|
|
649
|
+
if (input?.commitMessage != null) body.commit_title = input.commitMessage;
|
|
650
|
+
if (input?.squashCommitMessage != null)
|
|
651
|
+
body.commit_title = input.squashCommitMessage;
|
|
652
|
+
if (input?.shouldRemoveSourceBranch != null)
|
|
653
|
+
body.delete_branch = input.shouldRemoveSourceBranch;
|
|
654
|
+
if (input?.sha != null) body.sha = input.sha;
|
|
655
|
+
|
|
656
|
+
// Map mergeMethod / squash to GitHub's merge_method parameter.
|
|
657
|
+
// GitHub accepts: "merge", "squash", "rebase".
|
|
658
|
+
if (input?.mergeMethod) {
|
|
659
|
+
body.merge_method = input.mergeMethod;
|
|
660
|
+
} else if (input?.squash) {
|
|
661
|
+
body.merge_method = 'squash';
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const res = await this.api(
|
|
665
|
+
'PUT',
|
|
666
|
+
`/repos/${projectPath}/pulls/${mrIid}/merge`,
|
|
667
|
+
body
|
|
668
|
+
);
|
|
669
|
+
if (!res.ok) {
|
|
670
|
+
const text = await res.text();
|
|
671
|
+
throw new Error(`mergePullRequest failed: ${res.status} ${text}`);
|
|
672
|
+
}
|
|
673
|
+
const pr = await this.fetchSingleMR(projectPath, mrIid, null);
|
|
674
|
+
if (!pr) throw new Error('Merged PR but failed to fetch it back');
|
|
675
|
+
return pr;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async approvePullRequest(projectPath: string, mrIid: number): Promise<void> {
|
|
679
|
+
const res = await this.api(
|
|
680
|
+
'POST',
|
|
681
|
+
`/repos/${projectPath}/pulls/${mrIid}/reviews`,
|
|
682
|
+
{
|
|
683
|
+
event: 'APPROVE'
|
|
684
|
+
}
|
|
685
|
+
);
|
|
686
|
+
if (!res.ok) {
|
|
687
|
+
const text = await res.text().catch(() => '');
|
|
688
|
+
throw new Error(
|
|
689
|
+
`approvePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async unapprovePullRequest(
|
|
695
|
+
_projectPath: string,
|
|
696
|
+
_mrIid: number
|
|
697
|
+
): Promise<void> {
|
|
698
|
+
// TODO: GitHub does not support unapproving via REST API.
|
|
699
|
+
// A possible workaround is to dismiss the review via
|
|
700
|
+
// PUT /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/dismissals
|
|
701
|
+
// but that requires knowing the review ID and is semantically different
|
|
702
|
+
// (dismissal vs. unapproval). Leave as stub until a use case emerges.
|
|
703
|
+
throw new Error(
|
|
704
|
+
'unapprovePullRequest is not supported by GitHub. ' +
|
|
705
|
+
'Check provider.capabilities.canUnapprove before calling.'
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async rebasePullRequest(_projectPath: string, _mrIid: number): Promise<void> {
|
|
710
|
+
// TODO: GitHub has no native "rebase" API for pull requests.
|
|
711
|
+
// The closest equivalent is the update-branch API:
|
|
712
|
+
// PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch
|
|
713
|
+
// which updates the PR branch with the latest from the base branch,
|
|
714
|
+
// but it's a merge (not a rebase). True rebase requires pushing
|
|
715
|
+
// locally rebased commits.
|
|
716
|
+
throw new Error(
|
|
717
|
+
'rebasePullRequest is not supported by GitHub. ' +
|
|
718
|
+
'Check provider.capabilities.canRebase before calling.'
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async setAutoMerge(_projectPath: string, _mrIid: number): Promise<void> {
|
|
723
|
+
// TODO: GitHub supports auto-merge via GraphQL mutation:
|
|
724
|
+
// mutation { enablePullRequestAutoMerge(input: { pullRequestId: "..." }) { ... } }
|
|
725
|
+
// Requires the repository to have "Allow auto-merge" enabled in settings.
|
|
726
|
+
// The REST API does not support this — GraphQL only.
|
|
727
|
+
throw new Error(
|
|
728
|
+
'setAutoMerge is not supported by the GitHub REST API. ' +
|
|
729
|
+
'Check provider.capabilities.canAutoMerge before calling.'
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async cancelAutoMerge(_projectPath: string, _mrIid: number): Promise<void> {
|
|
734
|
+
// TODO: GitHub GraphQL mutation:
|
|
735
|
+
// mutation { disablePullRequestAutoMerge(input: { pullRequestId: "..." }) { ... } }
|
|
736
|
+
// Same pre-requisites as setAutoMerge.
|
|
737
|
+
throw new Error(
|
|
738
|
+
'cancelAutoMerge is not supported by the GitHub REST API. ' +
|
|
739
|
+
'Check provider.capabilities.canAutoMerge before calling.'
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ── Discussion mutations ────────────────────────────────────────────────
|
|
744
|
+
|
|
745
|
+
async resolveDiscussion(
|
|
746
|
+
_projectPath: string,
|
|
747
|
+
_mrIid: number,
|
|
748
|
+
_discussionId: string
|
|
749
|
+
): Promise<void> {
|
|
750
|
+
// TODO: GitHub GraphQL mutation:
|
|
751
|
+
// mutation { resolveReviewThread(input: { threadId: "..." }) { ... } }
|
|
752
|
+
// REST API does not support resolving review threads.
|
|
753
|
+
throw new Error(
|
|
754
|
+
'resolveDiscussion is not supported by the GitHub REST API. ' +
|
|
755
|
+
'Check provider.capabilities.canResolveDiscussions before calling.'
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async unresolveDiscussion(
|
|
760
|
+
_projectPath: string,
|
|
761
|
+
_mrIid: number,
|
|
762
|
+
_discussionId: string
|
|
763
|
+
): Promise<void> {
|
|
764
|
+
// TODO: GitHub GraphQL mutation:
|
|
765
|
+
// mutation { unresolveReviewThread(input: { threadId: "..." }) { ... } }
|
|
766
|
+
// REST API does not support unresolving review threads.
|
|
767
|
+
throw new Error(
|
|
768
|
+
'unresolveDiscussion is not supported by the GitHub REST API. ' +
|
|
769
|
+
'Check provider.capabilities.canResolveDiscussions before calling.'
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ── Pipeline mutations ──────────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
async retryPipeline(projectPath: string, pipelineId: number): Promise<void> {
|
|
776
|
+
// GitHub Actions: re-run a workflow run.
|
|
777
|
+
// pipelineId maps to the workflow run ID.
|
|
778
|
+
const res = await this.api(
|
|
779
|
+
'POST',
|
|
780
|
+
`/repos/${projectPath}/actions/runs/${pipelineId}/rerun`
|
|
781
|
+
);
|
|
782
|
+
if (!res.ok) {
|
|
783
|
+
const text = await res.text().catch(() => '');
|
|
784
|
+
throw new Error(
|
|
785
|
+
`retryPipeline failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ── Review mutations ────────────────────────────────────────────────────
|
|
791
|
+
|
|
792
|
+
async requestReReview(
|
|
793
|
+
projectPath: string,
|
|
794
|
+
mrIid: number,
|
|
795
|
+
reviewerUsernames?: string[]
|
|
796
|
+
): Promise<void> {
|
|
797
|
+
// GitHub: POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers
|
|
798
|
+
// If no usernames provided, we'd need to fetch the current PR to get
|
|
799
|
+
// the existing reviewer list. For now, require explicit usernames.
|
|
800
|
+
if (!reviewerUsernames?.length) {
|
|
801
|
+
// Fetch current reviewers from the PR
|
|
802
|
+
const prRes = await this.api(
|
|
803
|
+
'GET',
|
|
804
|
+
`/repos/${projectPath}/pulls/${mrIid}`
|
|
805
|
+
);
|
|
806
|
+
if (!prRes.ok) {
|
|
807
|
+
throw new Error(`requestReReview: failed to fetch PR: ${prRes.status}`);
|
|
808
|
+
}
|
|
809
|
+
const pr = (await prRes.json()) as GHPullRequest;
|
|
810
|
+
reviewerUsernames = pr.requested_reviewers.map(r => r.login);
|
|
811
|
+
if (!reviewerUsernames.length) {
|
|
812
|
+
// Nothing to re-request
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const res = await this.api(
|
|
818
|
+
'POST',
|
|
819
|
+
`/repos/${projectPath}/pulls/${mrIid}/requested_reviewers`,
|
|
820
|
+
{ reviewers: reviewerUsernames }
|
|
821
|
+
);
|
|
822
|
+
if (!res.ok) {
|
|
823
|
+
const text = await res.text().catch(() => '');
|
|
824
|
+
throw new Error(
|
|
825
|
+
`requestReReview failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
558
830
|
// ── Private helpers ─────────────────────────────────────────────────────
|
|
559
831
|
|
|
560
832
|
private async api(
|
|
561
833
|
method: string,
|
|
562
834
|
path: string,
|
|
563
|
-
body?: unknown
|
|
835
|
+
body?: unknown
|
|
564
836
|
): Promise<Response> {
|
|
565
837
|
const url = `${this.apiBase}${path}`;
|
|
566
838
|
const headers: Record<string, string> = {
|
|
567
839
|
Authorization: `Bearer ${this.token}`,
|
|
568
|
-
Accept:
|
|
569
|
-
|
|
840
|
+
Accept: 'application/vnd.github+json',
|
|
841
|
+
'X-GitHub-Api-Version': '2022-11-28'
|
|
570
842
|
};
|
|
571
843
|
if (body !== undefined) {
|
|
572
|
-
headers[
|
|
844
|
+
headers['Content-Type'] = 'application/json';
|
|
573
845
|
}
|
|
574
846
|
return fetch(url, {
|
|
575
847
|
method,
|
|
576
848
|
headers,
|
|
577
|
-
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
849
|
+
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
578
850
|
});
|
|
579
851
|
}
|
|
580
852
|
|
|
@@ -585,11 +857,11 @@ export class GitHubProvider implements GitProvider {
|
|
|
585
857
|
private async searchPRs(qualifiers: string): Promise<GHPullRequest[]> {
|
|
586
858
|
const q = encodeURIComponent(qualifiers);
|
|
587
859
|
const res = await this.api(
|
|
588
|
-
|
|
589
|
-
`/search/issues?q=${q}&per_page=100&sort=updated
|
|
860
|
+
'GET',
|
|
861
|
+
`/search/issues?q=${q}&per_page=100&sort=updated`
|
|
590
862
|
);
|
|
591
863
|
if (!res.ok) {
|
|
592
|
-
this.log.warn(
|
|
864
|
+
this.log.warn('GitHub search failed', { status: res.status, qualifiers });
|
|
593
865
|
return [];
|
|
594
866
|
}
|
|
595
867
|
|
|
@@ -603,16 +875,16 @@ export class GitHubProvider implements GitProvider {
|
|
|
603
875
|
|
|
604
876
|
// The search API returns issue-shaped results; fetch full PR details
|
|
605
877
|
const prPromises = data.items
|
|
606
|
-
.filter(
|
|
607
|
-
.map(async
|
|
878
|
+
.filter(item => item.pull_request) // Only PRs
|
|
879
|
+
.map(async item => {
|
|
608
880
|
// Extract owner/repo from repository_url
|
|
609
881
|
const repoPath = item.repository_url.replace(
|
|
610
882
|
`${this.apiBase}/repos/`,
|
|
611
|
-
|
|
883
|
+
''
|
|
612
884
|
);
|
|
613
885
|
const res = await this.api(
|
|
614
|
-
|
|
615
|
-
`/repos/${repoPath}/pulls/${item.number}
|
|
886
|
+
'GET',
|
|
887
|
+
`/repos/${repoPath}/pulls/${item.number}`
|
|
616
888
|
);
|
|
617
889
|
if (!res.ok) return null;
|
|
618
890
|
return (await res.json()) as GHPullRequest;
|
|
@@ -624,21 +896,21 @@ export class GitHubProvider implements GitProvider {
|
|
|
624
896
|
|
|
625
897
|
private async fetchReviews(
|
|
626
898
|
repoPath: string,
|
|
627
|
-
prNumber: number
|
|
899
|
+
prNumber: number
|
|
628
900
|
): Promise<GHReview[]> {
|
|
629
901
|
return this.fetchAllPages<GHReview>(
|
|
630
|
-
`/repos/${repoPath}/pulls/${prNumber}/reviews?per_page=100
|
|
902
|
+
`/repos/${repoPath}/pulls/${prNumber}/reviews?per_page=100`
|
|
631
903
|
);
|
|
632
904
|
}
|
|
633
905
|
|
|
634
906
|
private async fetchCheckRuns(
|
|
635
907
|
repoPath: string,
|
|
636
|
-
sha: string
|
|
908
|
+
sha: string
|
|
637
909
|
): Promise<GHCheckRun[]> {
|
|
638
910
|
try {
|
|
639
911
|
const res = await this.api(
|
|
640
|
-
|
|
641
|
-
`/repos/${repoPath}/commits/${sha}/check-runs?per_page=100
|
|
912
|
+
'GET',
|
|
913
|
+
`/repos/${repoPath}/commits/${sha}/check-runs?per_page=100`
|
|
642
914
|
);
|
|
643
915
|
if (!res.ok) return [];
|
|
644
916
|
const data = (await res.json()) as GHCheckSuite;
|
|
@@ -653,17 +925,17 @@ export class GitHubProvider implements GitProvider {
|
|
|
653
925
|
let url: string | null = path;
|
|
654
926
|
|
|
655
927
|
while (url) {
|
|
656
|
-
const res = await this.api(
|
|
928
|
+
const res = await this.api('GET', url);
|
|
657
929
|
if (!res.ok) break;
|
|
658
930
|
const items = (await res.json()) as T[];
|
|
659
931
|
results.push(...items);
|
|
660
932
|
|
|
661
933
|
// Parse Link header for pagination
|
|
662
|
-
const linkHeader = res.headers.get(
|
|
934
|
+
const linkHeader = res.headers.get('Link');
|
|
663
935
|
const nextMatch = linkHeader?.match(/<([^>]+)>;\s*rel="next"/);
|
|
664
936
|
if (nextMatch) {
|
|
665
937
|
// Strip apiBase prefix — `api()` will re-add it
|
|
666
|
-
url = nextMatch[1]!.replace(this.apiBase,
|
|
938
|
+
url = nextMatch[1]!.replace(this.apiBase, '');
|
|
667
939
|
} else {
|
|
668
940
|
url = null;
|
|
669
941
|
}
|
|
@@ -679,14 +951,13 @@ export class GitHubProvider implements GitProvider {
|
|
|
679
951
|
pr: GHPullRequest,
|
|
680
952
|
roles: string[],
|
|
681
953
|
reviews: GHReview[],
|
|
682
|
-
checkRuns: GHCheckRun[]
|
|
954
|
+
checkRuns: GHCheckRun[]
|
|
683
955
|
): PullRequest {
|
|
684
956
|
// Compute approvals: latest review per user, count "APPROVED" ones
|
|
685
957
|
const latestReviewByUser = new Map<number, GHReview>();
|
|
686
958
|
for (const r of reviews.sort(
|
|
687
959
|
(a, b) =>
|
|
688
|
-
new Date(a.submitted_at).getTime() -
|
|
689
|
-
new Date(b.submitted_at).getTime(),
|
|
960
|
+
new Date(a.submitted_at).getTime() - new Date(b.submitted_at).getTime()
|
|
690
961
|
)) {
|
|
691
962
|
latestReviewByUser.set(r.user.id, r);
|
|
692
963
|
}
|
|
@@ -694,9 +965,9 @@ export class GitHubProvider implements GitProvider {
|
|
|
694
965
|
const approvedBy: UserRef[] = [];
|
|
695
966
|
let changesRequested = 0;
|
|
696
967
|
for (const r of latestReviewByUser.values()) {
|
|
697
|
-
if (r.state ===
|
|
968
|
+
if (r.state === 'APPROVED') {
|
|
698
969
|
approvedBy.push(toUserRef(r.user));
|
|
699
|
-
} else if (r.state ===
|
|
970
|
+
} else if (r.state === 'CHANGES_REQUESTED') {
|
|
700
971
|
changesRequested++;
|
|
701
972
|
}
|
|
702
973
|
}
|
|
@@ -710,13 +981,12 @@ export class GitHubProvider implements GitProvider {
|
|
|
710
981
|
? {
|
|
711
982
|
additions: pr.additions!,
|
|
712
983
|
deletions: pr.deletions ?? 0,
|
|
713
|
-
filesChanged: pr.changed_files ?? 0
|
|
984
|
+
filesChanged: pr.changed_files ?? 0
|
|
714
985
|
}
|
|
715
986
|
: null;
|
|
716
987
|
|
|
717
988
|
// Conflicts: GitHub's mergeable_state "dirty" indicates conflicts
|
|
718
|
-
const conflicts =
|
|
719
|
-
pr.mergeable === false || pr.mergeable_state === "dirty";
|
|
989
|
+
const conflicts = pr.mergeable === false || pr.mergeable_state === 'dirty';
|
|
720
990
|
|
|
721
991
|
const pipeline = toPipeline(checkRuns, pr.html_url);
|
|
722
992
|
|
|
@@ -745,7 +1015,7 @@ export class GitHubProvider implements GitProvider {
|
|
|
745
1015
|
approved: approvedBy.length > 0 && changesRequested === 0,
|
|
746
1016
|
approvedBy,
|
|
747
1017
|
diffStats,
|
|
748
|
-
detailedMergeStatus: null
|
|
1018
|
+
detailedMergeStatus: null // GitHub-specific status not applicable
|
|
749
1019
|
};
|
|
750
1020
|
}
|
|
751
1021
|
}
|
|
@@ -761,7 +1031,7 @@ function toNote(c: GHComment): Note {
|
|
|
761
1031
|
oldPath: c.path,
|
|
762
1032
|
newLine: c.line ?? null,
|
|
763
1033
|
oldLine: c.original_line ?? null,
|
|
764
|
-
positionType: c.path ?
|
|
1034
|
+
positionType: c.path ? 'text' : null
|
|
765
1035
|
}
|
|
766
1036
|
: null;
|
|
767
1037
|
|
|
@@ -771,10 +1041,10 @@ function toNote(c: GHComment): Note {
|
|
|
771
1041
|
author: toNoteAuthor(c.user),
|
|
772
1042
|
createdAt: c.created_at,
|
|
773
1043
|
system: false,
|
|
774
|
-
type: c.path ?
|
|
1044
|
+
type: c.path ? 'DiffNote' : 'DiscussionNote',
|
|
775
1045
|
resolvable: c.path ? true : null,
|
|
776
1046
|
resolved: null,
|
|
777
|
-
position
|
|
1047
|
+
position
|
|
778
1048
|
};
|
|
779
1049
|
}
|
|
780
1050
|
|
|
@@ -783,6 +1053,6 @@ function toNoteAuthor(u: GHUser): NoteAuthor {
|
|
|
783
1053
|
id: `github:user:${u.id}`,
|
|
784
1054
|
username: u.login,
|
|
785
1055
|
name: u.name ?? u.login,
|
|
786
|
-
avatarUrl: u.avatar_url
|
|
1056
|
+
avatarUrl: u.avatar_url
|
|
787
1057
|
};
|
|
788
1058
|
}
|