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