@forge-glance/sdk 0.1.0 → 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 +19 -3
- package/dist/GitHubProvider.js +217 -1
- package/dist/GitLabProvider.d.ts +25 -3
- package/dist/GitLabProvider.js +285 -1
- package/dist/GitProvider.d.ts +89 -1
- package/dist/index.d.ts +14 -14
- package/dist/index.js +502 -2
- package/dist/providers.js +502 -2
- package/dist/types.d.ts +103 -0
- package/package.json +1 -1
- package/src/GitHubProvider.ts +534 -115
- package/src/GitLabProvider.ts +519 -56
- package/src/GitProvider.ts +155 -13
- package/src/index.ts +25 -15
- package/src/types.ts +111 -0
package/src/GitLabProvider.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import type { GitProvider } from
|
|
1
|
+
import type { GitProvider } from './GitProvider.ts';
|
|
2
2
|
import type {
|
|
3
|
+
BranchProtectionRule,
|
|
4
|
+
CreatePullRequestInput,
|
|
3
5
|
DiffStats,
|
|
6
|
+
MergePullRequestInput,
|
|
4
7
|
MRDetail,
|
|
5
8
|
Pipeline,
|
|
6
9
|
PipelineJob,
|
|
10
|
+
ProviderCapabilities,
|
|
7
11
|
PullRequest,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
+
UpdatePullRequestInput,
|
|
13
|
+
UserRef
|
|
14
|
+
} from './types.ts';
|
|
15
|
+
import { type ForgeLogger, noopLogger } from './logger.ts';
|
|
16
|
+
import { MRDetailFetcher } from './MRDetailFetcher.ts';
|
|
12
17
|
|
|
13
18
|
// ---------------------------------------------------------------------------
|
|
14
19
|
// Repository ID helpers
|
|
@@ -20,8 +25,8 @@ import { MRDetailFetcher } from "./MRDetailFetcher.ts";
|
|
|
20
25
|
* e.g. "gitlab:42" → 42
|
|
21
26
|
*/
|
|
22
27
|
export function parseGitLabRepoId(repositoryId: string): number {
|
|
23
|
-
const parts = repositoryId.split(
|
|
24
|
-
return parseInt(parts.at(-1) ??
|
|
28
|
+
const parts = repositoryId.split(':');
|
|
29
|
+
return parseInt(parts.at(-1) ?? '0', 10);
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
// ---------------------------------------------------------------------------
|
|
@@ -178,8 +183,8 @@ interface AssignedResponse {
|
|
|
178
183
|
|
|
179
184
|
/** Extract numeric ID from a GQL global ID like "gid://gitlab/MergeRequest/12345". */
|
|
180
185
|
function numericId(gid: string): number {
|
|
181
|
-
const parts = gid.split(
|
|
182
|
-
return parseInt(parts[parts.length - 1] ??
|
|
186
|
+
const parts = gid.split('/');
|
|
187
|
+
return parseInt(parts[parts.length - 1] ?? '0', 10);
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
/** Build a scoped domain ID from a GitLab numeric integer. */
|
|
@@ -192,29 +197,28 @@ function toUserRef(u: GQLUser): UserRef {
|
|
|
192
197
|
id: `gitlab:user:${numericId(u.id)}`,
|
|
193
198
|
username: u.username,
|
|
194
199
|
name: u.name,
|
|
195
|
-
avatarUrl: u.avatarUrl
|
|
200
|
+
avatarUrl: u.avatarUrl
|
|
196
201
|
};
|
|
197
202
|
}
|
|
198
203
|
|
|
199
|
-
|
|
200
204
|
function toPipeline(p: GQLPipeline, baseURL: string): Pipeline {
|
|
201
|
-
const allJobs: PipelineJob[] = p.stages.nodes.flatMap(
|
|
202
|
-
stage.jobs.nodes.map(
|
|
205
|
+
const allJobs: PipelineJob[] = p.stages.nodes.flatMap(stage =>
|
|
206
|
+
stage.jobs.nodes.map(job => ({
|
|
203
207
|
id: `gitlab:job:${numericId(job.id)}`,
|
|
204
208
|
name: job.name,
|
|
205
209
|
stage: job.stage.name,
|
|
206
210
|
status: job.status,
|
|
207
211
|
allowFailure: job.allowFailure,
|
|
208
|
-
webUrl: job.webPath ? `${baseURL}${job.webPath}` : null
|
|
209
|
-
}))
|
|
212
|
+
webUrl: job.webPath ? `${baseURL}${job.webPath}` : null
|
|
213
|
+
}))
|
|
210
214
|
);
|
|
211
215
|
|
|
212
216
|
return {
|
|
213
|
-
id: domainId(
|
|
217
|
+
id: domainId('pipeline', numericId(p.id)),
|
|
214
218
|
status: normalizePipelineStatus(p),
|
|
215
219
|
createdAt: p.createdAt,
|
|
216
220
|
webUrl: p.path ? `${baseURL}${p.path}` : null,
|
|
217
|
-
jobs: allJobs
|
|
221
|
+
jobs: allJobs
|
|
218
222
|
};
|
|
219
223
|
}
|
|
220
224
|
|
|
@@ -224,12 +228,12 @@ function toPipeline(p: GQLPipeline, baseURL: string): Pipeline {
|
|
|
224
228
|
* if any allow-failure job failed but overall status is success → "success_with_warnings"
|
|
225
229
|
*/
|
|
226
230
|
function normalizePipelineStatus(p: GQLPipeline): string {
|
|
227
|
-
const allJobs = p.stages.nodes.flatMap(
|
|
231
|
+
const allJobs = p.stages.nodes.flatMap(s => s.jobs.nodes);
|
|
228
232
|
const hasAllowFailFailed = allJobs.some(
|
|
229
|
-
|
|
233
|
+
j => j.allowFailure && j.status === 'failed'
|
|
230
234
|
);
|
|
231
|
-
if (p.status ===
|
|
232
|
-
return
|
|
235
|
+
if (p.status === 'success' && hasAllowFailFailed) {
|
|
236
|
+
return 'success_with_warnings';
|
|
233
237
|
}
|
|
234
238
|
return p.status;
|
|
235
239
|
}
|
|
@@ -243,7 +247,7 @@ function toMR(gql: GQLMR, role: string, baseURL: string): PullRequest {
|
|
|
243
247
|
? {
|
|
244
248
|
additions: gql.diffStatsSummary.additions,
|
|
245
249
|
deletions: gql.diffStatsSummary.deletions,
|
|
246
|
-
filesChanged: gql.diffStatsSummary.fileCount
|
|
250
|
+
filesChanged: gql.diffStatsSummary.fileCount
|
|
247
251
|
}
|
|
248
252
|
: null;
|
|
249
253
|
|
|
@@ -255,7 +259,7 @@ function toMR(gql: GQLMR, role: string, baseURL: string): PullRequest {
|
|
|
255
259
|
description: gql.description ?? null,
|
|
256
260
|
state: gql.state,
|
|
257
261
|
draft: gql.draft,
|
|
258
|
-
conflicts: gql.conflicts || gql.detailedMergeStatus ===
|
|
262
|
+
conflicts: gql.conflicts || gql.detailedMergeStatus === 'conflict',
|
|
259
263
|
webUrl: gql.webUrl,
|
|
260
264
|
sourceBranch: gql.sourceBranch,
|
|
261
265
|
targetBranch: gql.targetBranch,
|
|
@@ -272,7 +276,7 @@ function toMR(gql: GQLMR, role: string, baseURL: string): PullRequest {
|
|
|
272
276
|
approved: gql.approved ?? false,
|
|
273
277
|
approvedBy: gql.approvedBy.nodes.map(toUserRef),
|
|
274
278
|
diffStats,
|
|
275
|
-
detailedMergeStatus: gql.detailedMergeStatus ?? null
|
|
279
|
+
detailedMergeStatus: gql.detailedMergeStatus ?? null
|
|
276
280
|
};
|
|
277
281
|
}
|
|
278
282
|
|
|
@@ -300,29 +304,50 @@ interface MRDetailResponse {
|
|
|
300
304
|
// ---------------------------------------------------------------------------
|
|
301
305
|
|
|
302
306
|
export class GitLabProvider implements GitProvider {
|
|
303
|
-
readonly providerName =
|
|
307
|
+
readonly providerName = 'gitlab' as const;
|
|
304
308
|
readonly baseURL: string;
|
|
305
309
|
private readonly token: string;
|
|
306
310
|
private readonly log: ForgeLogger;
|
|
307
311
|
private readonly mrDetailFetcher: MRDetailFetcher;
|
|
308
312
|
|
|
309
|
-
constructor(
|
|
313
|
+
constructor(
|
|
314
|
+
baseURL: string,
|
|
315
|
+
token: string,
|
|
316
|
+
options: { logger?: ForgeLogger } = {}
|
|
317
|
+
) {
|
|
310
318
|
// Strip trailing slash for consistent URL building
|
|
311
|
-
this.baseURL = baseURL.replace(/\/$/,
|
|
319
|
+
this.baseURL = baseURL.replace(/\/$/, '');
|
|
312
320
|
this.token = token;
|
|
313
321
|
this.log = options.logger ?? noopLogger;
|
|
314
|
-
this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, {
|
|
322
|
+
this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, {
|
|
323
|
+
logger: this.log
|
|
324
|
+
});
|
|
315
325
|
}
|
|
316
326
|
|
|
327
|
+
// ── Capabilities ──────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
readonly capabilities: ProviderCapabilities = {
|
|
330
|
+
canMerge: true,
|
|
331
|
+
canApprove: true,
|
|
332
|
+
canUnapprove: true,
|
|
333
|
+
canRebase: true,
|
|
334
|
+
canAutoMerge: true,
|
|
335
|
+
canResolveDiscussions: true,
|
|
336
|
+
canRetryPipeline: true,
|
|
337
|
+
canRequestReReview: true
|
|
338
|
+
};
|
|
339
|
+
|
|
317
340
|
// MARK: - GitProvider
|
|
318
341
|
|
|
319
342
|
async validateToken(): Promise<UserRef> {
|
|
320
343
|
const url = `${this.baseURL}/api/v4/user`;
|
|
321
344
|
const res = await fetch(url, {
|
|
322
|
-
headers: {
|
|
345
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
323
346
|
});
|
|
324
347
|
if (!res.ok) {
|
|
325
|
-
throw new Error(
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Token validation failed: ${res.status} ${res.statusText}`
|
|
350
|
+
);
|
|
326
351
|
}
|
|
327
352
|
const user = (await res.json()) as {
|
|
328
353
|
id: number;
|
|
@@ -334,7 +359,7 @@ export class GitLabProvider implements GitProvider {
|
|
|
334
359
|
id: `gitlab:user:${user.id}`,
|
|
335
360
|
username: user.username,
|
|
336
361
|
name: user.name,
|
|
337
|
-
avatarUrl: user.avatar_url
|
|
362
|
+
avatarUrl: user.avatar_url
|
|
338
363
|
};
|
|
339
364
|
}
|
|
340
365
|
|
|
@@ -349,17 +374,17 @@ export class GitLabProvider implements GitProvider {
|
|
|
349
374
|
async fetchSingleMR(
|
|
350
375
|
projectPath: string,
|
|
351
376
|
mrIid: number,
|
|
352
|
-
currentUserNumericId: number | null
|
|
377
|
+
currentUserNumericId: number | null
|
|
353
378
|
): Promise<PullRequest | null> {
|
|
354
379
|
let resp: MRDetailResponse;
|
|
355
380
|
try {
|
|
356
381
|
resp = await this.runQuery<MRDetailResponse>(MR_DETAIL_QUERY, {
|
|
357
382
|
projectPath,
|
|
358
|
-
iid: String(mrIid)
|
|
383
|
+
iid: String(mrIid)
|
|
359
384
|
});
|
|
360
385
|
} catch (err) {
|
|
361
386
|
const message = err instanceof Error ? err.message : String(err);
|
|
362
|
-
this.log.warn(
|
|
387
|
+
this.log.warn('fetchSingleMR failed', { projectPath, mrIid, message });
|
|
363
388
|
return null;
|
|
364
389
|
}
|
|
365
390
|
|
|
@@ -370,13 +395,15 @@ export class GitLabProvider implements GitProvider {
|
|
|
370
395
|
const roles: string[] = [];
|
|
371
396
|
if (currentUserNumericId !== null) {
|
|
372
397
|
const userGqlId = `gid://gitlab/User/${currentUserNumericId}`;
|
|
373
|
-
if (gql.author.id === userGqlId) roles.push(
|
|
374
|
-
if (gql.assignees.nodes.some(
|
|
375
|
-
|
|
398
|
+
if (gql.author.id === userGqlId) roles.push('author');
|
|
399
|
+
if (gql.assignees.nodes.some(u => u.id === userGqlId))
|
|
400
|
+
roles.push('assignee');
|
|
401
|
+
if (gql.reviewers.nodes.some(u => u.id === userGqlId))
|
|
402
|
+
roles.push('reviewer');
|
|
376
403
|
}
|
|
377
404
|
|
|
378
405
|
// Use "author" as the primary role for toMR, then overwrite with full computed roles.
|
|
379
|
-
const pr = toMR(gql, roles[0] ??
|
|
406
|
+
const pr = toMR(gql, roles[0] ?? 'author', this.baseURL);
|
|
380
407
|
pr.roles = roles.length > 0 ? roles : pr.roles;
|
|
381
408
|
return pr;
|
|
382
409
|
}
|
|
@@ -385,7 +412,7 @@ export class GitLabProvider implements GitProvider {
|
|
|
385
412
|
const [authored, reviewing, assigned] = await Promise.all([
|
|
386
413
|
this.runQuery<AuthoredResponse>(AUTHORED_QUERY),
|
|
387
414
|
this.runQuery<ReviewingResponse>(REVIEWING_QUERY),
|
|
388
|
-
this.runQuery<AssignedResponse>(ASSIGNED_QUERY)
|
|
415
|
+
this.runQuery<AssignedResponse>(ASSIGNED_QUERY)
|
|
389
416
|
]);
|
|
390
417
|
|
|
391
418
|
// Merge all three sets, deduplicating by MR global ID.
|
|
@@ -405,51 +432,487 @@ export class GitLabProvider implements GitProvider {
|
|
|
405
432
|
}
|
|
406
433
|
};
|
|
407
434
|
|
|
408
|
-
addAll(authored.currentUser.authoredMergeRequests.nodes,
|
|
409
|
-
addAll(
|
|
410
|
-
|
|
435
|
+
addAll(authored.currentUser.authoredMergeRequests.nodes, 'author');
|
|
436
|
+
addAll(
|
|
437
|
+
reviewing.currentUser.reviewRequestedMergeRequests.nodes,
|
|
438
|
+
'reviewer'
|
|
439
|
+
);
|
|
440
|
+
addAll(assigned.currentUser.assignedMergeRequests.nodes, 'assignee');
|
|
411
441
|
|
|
412
442
|
const prs = [...byId.values()];
|
|
413
|
-
this.log.debug(
|
|
443
|
+
this.log.debug('fetchPullRequests', { count: prs.length });
|
|
414
444
|
return prs;
|
|
415
445
|
}
|
|
416
446
|
|
|
417
|
-
async fetchMRDiscussions(
|
|
447
|
+
async fetchMRDiscussions(
|
|
448
|
+
repositoryId: string,
|
|
449
|
+
mrIid: number
|
|
450
|
+
): Promise<MRDetail> {
|
|
418
451
|
const projectId = parseGitLabRepoId(repositoryId);
|
|
419
452
|
return this.mrDetailFetcher.fetchDetail(projectId, mrIid);
|
|
420
453
|
}
|
|
421
454
|
|
|
422
|
-
async
|
|
455
|
+
async fetchBranchProtectionRules(
|
|
456
|
+
projectPath: string
|
|
457
|
+
): Promise<BranchProtectionRule[]> {
|
|
458
|
+
const encoded = encodeURIComponent(projectPath);
|
|
459
|
+
const res = await fetch(
|
|
460
|
+
`${this.baseURL}/api/v4/projects/${encoded}/protected_branches?per_page=100`,
|
|
461
|
+
{ headers: { 'PRIVATE-TOKEN': this.token } }
|
|
462
|
+
);
|
|
463
|
+
if (!res.ok) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
`fetchBranchProtectionRules failed: ${res.status} ${await res.text()}`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
const branches = (await res.json()) as Array<{
|
|
469
|
+
name: string;
|
|
470
|
+
allow_force_push: boolean;
|
|
471
|
+
push_access_levels: Array<{ access_level: number }>;
|
|
472
|
+
merge_access_levels: Array<{ access_level: number }>;
|
|
473
|
+
code_owner_approval_required?: boolean;
|
|
474
|
+
}>;
|
|
475
|
+
return branches.map(b => ({
|
|
476
|
+
pattern: b.name,
|
|
477
|
+
allowForcePush: b.allow_force_push,
|
|
478
|
+
allowDeletion: false,
|
|
479
|
+
requiredApprovals: 0,
|
|
480
|
+
requireStatusChecks: false,
|
|
481
|
+
raw: b as unknown as Record<string, unknown>
|
|
482
|
+
}));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async deleteBranch(projectPath: string, branch: string): Promise<void> {
|
|
486
|
+
const encoded = encodeURIComponent(projectPath);
|
|
487
|
+
const res = await fetch(
|
|
488
|
+
`${this.baseURL}/api/v4/projects/${encoded}/repository/branches/${encodeURIComponent(branch)}`,
|
|
489
|
+
{ method: 'DELETE', headers: { 'PRIVATE-TOKEN': this.token } }
|
|
490
|
+
);
|
|
491
|
+
if (!res.ok) {
|
|
492
|
+
throw new Error(`deleteBranch failed: ${res.status} ${await res.text()}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async fetchPullRequestByBranch(
|
|
497
|
+
projectPath: string,
|
|
498
|
+
sourceBranch: string
|
|
499
|
+
): Promise<PullRequest | null> {
|
|
500
|
+
const encoded = encodeURIComponent(projectPath);
|
|
501
|
+
const url = `${this.baseURL}/api/v4/projects/${encoded}/merge_requests?source_branch=${encodeURIComponent(sourceBranch)}&state=opened&per_page=1`;
|
|
502
|
+
const res = await fetch(url, {
|
|
503
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
504
|
+
});
|
|
505
|
+
if (!res.ok) {
|
|
506
|
+
this.log.warn('fetchPullRequestByBranch failed', {
|
|
507
|
+
projectPath,
|
|
508
|
+
sourceBranch,
|
|
509
|
+
status: res.status
|
|
510
|
+
});
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
const mrs = (await res.json()) as Array<{ iid: number }>;
|
|
514
|
+
if (!mrs[0]) return null;
|
|
515
|
+
return this.fetchSingleMR(projectPath, mrs[0].iid, null);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async createPullRequest(input: CreatePullRequestInput): Promise<PullRequest> {
|
|
519
|
+
const encoded = encodeURIComponent(input.projectPath);
|
|
520
|
+
const body: Record<string, unknown> = {
|
|
521
|
+
source_branch: input.sourceBranch,
|
|
522
|
+
target_branch: input.targetBranch,
|
|
523
|
+
title: input.title
|
|
524
|
+
};
|
|
525
|
+
if (input.description != null) body.description = input.description;
|
|
526
|
+
if (input.draft != null) body.draft = input.draft;
|
|
527
|
+
if (input.labels?.length) body.labels = input.labels.join(',');
|
|
528
|
+
if (input.assignees?.length) body.assignee_ids = input.assignees;
|
|
529
|
+
if (input.reviewers?.length) body.reviewer_ids = input.reviewers;
|
|
530
|
+
|
|
531
|
+
const res = await fetch(
|
|
532
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests`,
|
|
533
|
+
{
|
|
534
|
+
method: 'POST',
|
|
535
|
+
headers: {
|
|
536
|
+
'PRIVATE-TOKEN': this.token,
|
|
537
|
+
'Content-Type': 'application/json'
|
|
538
|
+
},
|
|
539
|
+
body: JSON.stringify(body)
|
|
540
|
+
}
|
|
541
|
+
);
|
|
542
|
+
if (!res.ok) {
|
|
543
|
+
const text = await res.text();
|
|
544
|
+
throw new Error(`createPullRequest failed: ${res.status} ${text}`);
|
|
545
|
+
}
|
|
546
|
+
const created = (await res.json()) as { iid: number };
|
|
547
|
+
return this.fetchSingleMRWithRetry(
|
|
548
|
+
input.projectPath,
|
|
549
|
+
created.iid,
|
|
550
|
+
'Created MR but failed to fetch it back'
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async updatePullRequest(
|
|
555
|
+
projectPath: string,
|
|
556
|
+
mrIid: number,
|
|
557
|
+
input: UpdatePullRequestInput
|
|
558
|
+
): Promise<PullRequest> {
|
|
559
|
+
const encoded = encodeURIComponent(projectPath);
|
|
560
|
+
const body: Record<string, unknown> = {};
|
|
561
|
+
if (input.title != null) body.title = input.title;
|
|
562
|
+
if (input.description != null) body.description = input.description;
|
|
563
|
+
if (input.draft != null) body.draft = input.draft;
|
|
564
|
+
if (input.targetBranch != null) body.target_branch = input.targetBranch;
|
|
565
|
+
if (input.labels) body.labels = input.labels.join(',');
|
|
566
|
+
if (input.assignees) body.assignee_ids = input.assignees;
|
|
567
|
+
if (input.reviewers) body.reviewer_ids = input.reviewers;
|
|
568
|
+
if (input.stateEvent) body.state_event = input.stateEvent;
|
|
569
|
+
|
|
570
|
+
const res = await fetch(
|
|
571
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`,
|
|
572
|
+
{
|
|
573
|
+
method: 'PUT',
|
|
574
|
+
headers: {
|
|
575
|
+
'PRIVATE-TOKEN': this.token,
|
|
576
|
+
'Content-Type': 'application/json'
|
|
577
|
+
},
|
|
578
|
+
body: JSON.stringify(body)
|
|
579
|
+
}
|
|
580
|
+
);
|
|
581
|
+
if (!res.ok) {
|
|
582
|
+
const text = await res.text();
|
|
583
|
+
throw new Error(`updatePullRequest failed: ${res.status} ${text}`);
|
|
584
|
+
}
|
|
585
|
+
return this.fetchSingleMRWithRetry(
|
|
586
|
+
projectPath,
|
|
587
|
+
mrIid,
|
|
588
|
+
'Updated MR but failed to fetch it back'
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async restRequest(
|
|
593
|
+
method: string,
|
|
594
|
+
path: string,
|
|
595
|
+
body?: unknown
|
|
596
|
+
): Promise<Response> {
|
|
423
597
|
const url = `${this.baseURL}${path}`;
|
|
424
598
|
const headers: Record<string, string> = {
|
|
425
|
-
|
|
599
|
+
'PRIVATE-TOKEN': this.token
|
|
426
600
|
};
|
|
427
601
|
if (body !== undefined) {
|
|
428
|
-
headers[
|
|
602
|
+
headers['Content-Type'] = 'application/json';
|
|
429
603
|
}
|
|
430
604
|
return fetch(url, {
|
|
431
605
|
method,
|
|
432
606
|
headers,
|
|
433
|
-
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
607
|
+
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
434
608
|
});
|
|
435
609
|
}
|
|
436
610
|
|
|
611
|
+
// ── MR lifecycle mutations ──────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
async mergePullRequest(
|
|
614
|
+
projectPath: string,
|
|
615
|
+
mrIid: number,
|
|
616
|
+
input?: MergePullRequestInput
|
|
617
|
+
): Promise<PullRequest> {
|
|
618
|
+
const encoded = encodeURIComponent(projectPath);
|
|
619
|
+
const body: Record<string, unknown> = {};
|
|
620
|
+
if (input?.commitMessage != null)
|
|
621
|
+
body.merge_commit_message = input.commitMessage;
|
|
622
|
+
if (input?.squashCommitMessage != null)
|
|
623
|
+
body.squash_commit_message = input.squashCommitMessage;
|
|
624
|
+
if (input?.squash != null) body.squash = input.squash;
|
|
625
|
+
if (input?.shouldRemoveSourceBranch != null)
|
|
626
|
+
body.should_remove_source_branch = input.shouldRemoveSourceBranch;
|
|
627
|
+
if (input?.sha != null) body.sha = input.sha;
|
|
628
|
+
// mergeMethod: GitLab uses the project's configured merge method by default.
|
|
629
|
+
// The merge REST API doesn't accept a merge_method param — it honours the project setting.
|
|
630
|
+
// If the caller passes mergeMethod: "squash", we set squash = true as a hint.
|
|
631
|
+
if (input?.mergeMethod === 'squash' && input?.squash == null)
|
|
632
|
+
body.squash = true;
|
|
633
|
+
|
|
634
|
+
const res = await fetch(
|
|
635
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/merge`,
|
|
636
|
+
{
|
|
637
|
+
method: 'PUT',
|
|
638
|
+
headers: {
|
|
639
|
+
'PRIVATE-TOKEN': this.token,
|
|
640
|
+
'Content-Type': 'application/json'
|
|
641
|
+
},
|
|
642
|
+
body: JSON.stringify(body)
|
|
643
|
+
}
|
|
644
|
+
);
|
|
645
|
+
if (!res.ok) {
|
|
646
|
+
const text = await res.text();
|
|
647
|
+
throw new Error(`mergePullRequest failed: ${res.status} ${text}`);
|
|
648
|
+
}
|
|
649
|
+
return this.fetchSingleMRWithRetry(
|
|
650
|
+
projectPath,
|
|
651
|
+
mrIid,
|
|
652
|
+
'Merged MR but failed to fetch it back'
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async approvePullRequest(projectPath: string, mrIid: number): Promise<void> {
|
|
657
|
+
const encoded = encodeURIComponent(projectPath);
|
|
658
|
+
const res = await fetch(
|
|
659
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/approve`,
|
|
660
|
+
{
|
|
661
|
+
method: 'POST',
|
|
662
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
663
|
+
}
|
|
664
|
+
);
|
|
665
|
+
if (!res.ok) {
|
|
666
|
+
const text = await res.text().catch(() => '');
|
|
667
|
+
throw new Error(
|
|
668
|
+
`approvePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async unapprovePullRequest(
|
|
674
|
+
projectPath: string,
|
|
675
|
+
mrIid: number
|
|
676
|
+
): Promise<void> {
|
|
677
|
+
const encoded = encodeURIComponent(projectPath);
|
|
678
|
+
const res = await fetch(
|
|
679
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/unapprove`,
|
|
680
|
+
{
|
|
681
|
+
method: 'POST',
|
|
682
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
683
|
+
}
|
|
684
|
+
);
|
|
685
|
+
if (!res.ok) {
|
|
686
|
+
const text = await res.text().catch(() => '');
|
|
687
|
+
throw new Error(
|
|
688
|
+
`unapprovePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async rebasePullRequest(projectPath: string, mrIid: number): Promise<void> {
|
|
694
|
+
const encoded = encodeURIComponent(projectPath);
|
|
695
|
+
const res = await fetch(
|
|
696
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/rebase`,
|
|
697
|
+
{
|
|
698
|
+
method: 'PUT',
|
|
699
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
700
|
+
}
|
|
701
|
+
);
|
|
702
|
+
if (!res.ok) {
|
|
703
|
+
const text = await res.text().catch(() => '');
|
|
704
|
+
throw new Error(
|
|
705
|
+
`rebasePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async setAutoMerge(projectPath: string, mrIid: number): Promise<void> {
|
|
711
|
+
// REST: PUT merge with merge_when_pipeline_succeeds = true
|
|
712
|
+
// This tells GitLab to merge automatically once the head pipeline succeeds.
|
|
713
|
+
const encoded = encodeURIComponent(projectPath);
|
|
714
|
+
const res = await fetch(
|
|
715
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/merge`,
|
|
716
|
+
{
|
|
717
|
+
method: 'PUT',
|
|
718
|
+
headers: {
|
|
719
|
+
'PRIVATE-TOKEN': this.token,
|
|
720
|
+
'Content-Type': 'application/json'
|
|
721
|
+
},
|
|
722
|
+
body: JSON.stringify({ merge_when_pipeline_succeeds: true })
|
|
723
|
+
}
|
|
724
|
+
);
|
|
725
|
+
if (!res.ok) {
|
|
726
|
+
const text = await res.text().catch(() => '');
|
|
727
|
+
throw new Error(
|
|
728
|
+
`setAutoMerge failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async cancelAutoMerge(projectPath: string, mrIid: number): Promise<void> {
|
|
734
|
+
// REST: POST cancel_merge_when_pipeline_succeeds
|
|
735
|
+
const encoded = encodeURIComponent(projectPath);
|
|
736
|
+
const res = await fetch(
|
|
737
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/cancel_merge_when_pipeline_succeeds`,
|
|
738
|
+
{
|
|
739
|
+
method: 'POST',
|
|
740
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
741
|
+
}
|
|
742
|
+
);
|
|
743
|
+
if (!res.ok) {
|
|
744
|
+
const text = await res.text().catch(() => '');
|
|
745
|
+
throw new Error(
|
|
746
|
+
`cancelAutoMerge failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ── Discussion mutations ────────────────────────────────────────────────
|
|
752
|
+
|
|
753
|
+
async resolveDiscussion(
|
|
754
|
+
projectPath: string,
|
|
755
|
+
mrIid: number,
|
|
756
|
+
discussionId: string
|
|
757
|
+
): Promise<void> {
|
|
758
|
+
const encoded = encodeURIComponent(projectPath);
|
|
759
|
+
const res = await fetch(
|
|
760
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/discussions/${discussionId}`,
|
|
761
|
+
{
|
|
762
|
+
method: 'PUT',
|
|
763
|
+
headers: {
|
|
764
|
+
'PRIVATE-TOKEN': this.token,
|
|
765
|
+
'Content-Type': 'application/json'
|
|
766
|
+
},
|
|
767
|
+
body: JSON.stringify({ resolved: true })
|
|
768
|
+
}
|
|
769
|
+
);
|
|
770
|
+
if (!res.ok) {
|
|
771
|
+
const text = await res.text().catch(() => '');
|
|
772
|
+
throw new Error(
|
|
773
|
+
`resolveDiscussion failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async unresolveDiscussion(
|
|
779
|
+
projectPath: string,
|
|
780
|
+
mrIid: number,
|
|
781
|
+
discussionId: string
|
|
782
|
+
): Promise<void> {
|
|
783
|
+
const encoded = encodeURIComponent(projectPath);
|
|
784
|
+
const res = await fetch(
|
|
785
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/discussions/${discussionId}`,
|
|
786
|
+
{
|
|
787
|
+
method: 'PUT',
|
|
788
|
+
headers: {
|
|
789
|
+
'PRIVATE-TOKEN': this.token,
|
|
790
|
+
'Content-Type': 'application/json'
|
|
791
|
+
},
|
|
792
|
+
body: JSON.stringify({ resolved: false })
|
|
793
|
+
}
|
|
794
|
+
);
|
|
795
|
+
if (!res.ok) {
|
|
796
|
+
const text = await res.text().catch(() => '');
|
|
797
|
+
throw new Error(
|
|
798
|
+
`unresolveDiscussion failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ── Pipeline mutations ──────────────────────────────────────────────────
|
|
804
|
+
|
|
805
|
+
async retryPipeline(projectPath: string, pipelineId: number): Promise<void> {
|
|
806
|
+
const encoded = encodeURIComponent(projectPath);
|
|
807
|
+
const res = await fetch(
|
|
808
|
+
`${this.baseURL}/api/v4/projects/${encoded}/pipelines/${pipelineId}/retry`,
|
|
809
|
+
{
|
|
810
|
+
method: 'POST',
|
|
811
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
812
|
+
}
|
|
813
|
+
);
|
|
814
|
+
if (!res.ok) {
|
|
815
|
+
const text = await res.text().catch(() => '');
|
|
816
|
+
throw new Error(
|
|
817
|
+
`retryPipeline failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ── Review mutations ────────────────────────────────────────────────────
|
|
823
|
+
|
|
824
|
+
async requestReReview(
|
|
825
|
+
projectPath: string,
|
|
826
|
+
mrIid: number,
|
|
827
|
+
_reviewerUsernames?: string[]
|
|
828
|
+
): Promise<void> {
|
|
829
|
+
// GitLab does not have a dedicated "re-request review" endpoint.
|
|
830
|
+
// The approach: fetch the current MR to get reviewer IDs, then
|
|
831
|
+
// re-assign them via PUT to trigger review-requested notifications.
|
|
832
|
+
const encoded = encodeURIComponent(projectPath);
|
|
833
|
+
|
|
834
|
+
// Fetch current MR to get existing reviewer IDs
|
|
835
|
+
const mrRes = await fetch(
|
|
836
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`,
|
|
837
|
+
{ headers: { 'PRIVATE-TOKEN': this.token } }
|
|
838
|
+
);
|
|
839
|
+
if (!mrRes.ok) {
|
|
840
|
+
const text = await mrRes.text().catch(() => '');
|
|
841
|
+
throw new Error(
|
|
842
|
+
`requestReReview: failed to fetch MR: ${mrRes.status}${text ? ` — ${text}` : ''}`
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const mr = (await mrRes.json()) as {
|
|
847
|
+
reviewers?: Array<{ id: number }>;
|
|
848
|
+
};
|
|
849
|
+
const reviewerIds = mr.reviewers?.map(r => r.id) ?? [];
|
|
850
|
+
if (reviewerIds.length === 0) {
|
|
851
|
+
// No reviewers to re-request
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Re-assign the same reviewers to trigger notifications
|
|
856
|
+
const res = await fetch(
|
|
857
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`,
|
|
858
|
+
{
|
|
859
|
+
method: 'PUT',
|
|
860
|
+
headers: {
|
|
861
|
+
'PRIVATE-TOKEN': this.token,
|
|
862
|
+
'Content-Type': 'application/json'
|
|
863
|
+
},
|
|
864
|
+
body: JSON.stringify({ reviewer_ids: reviewerIds })
|
|
865
|
+
}
|
|
866
|
+
);
|
|
867
|
+
if (!res.ok) {
|
|
868
|
+
const text = await res.text().catch(() => '');
|
|
869
|
+
throw new Error(
|
|
870
|
+
`requestReReview failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
437
875
|
// MARK: - Private
|
|
438
876
|
|
|
439
|
-
|
|
877
|
+
/**
|
|
878
|
+
* Retry `fetchSingleMR` with exponential backoff to handle REST→GraphQL
|
|
879
|
+
* eventual consistency. GitLab's GraphQL may not immediately reflect
|
|
880
|
+
* changes made via REST. 3 attempts: 0ms, 300ms, 600ms delay.
|
|
881
|
+
*/
|
|
882
|
+
private async fetchSingleMRWithRetry(
|
|
883
|
+
projectPath: string,
|
|
884
|
+
mrIid: number,
|
|
885
|
+
errorMessage: string
|
|
886
|
+
): Promise<PullRequest> {
|
|
887
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
888
|
+
if (attempt > 0) {
|
|
889
|
+
await new Promise(r => setTimeout(r, attempt * 300));
|
|
890
|
+
}
|
|
891
|
+
const pr = await this.fetchSingleMR(projectPath, mrIid, null);
|
|
892
|
+
if (pr) return pr;
|
|
893
|
+
}
|
|
894
|
+
throw new Error(errorMessage);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private async runQuery<T>(
|
|
898
|
+
query: string,
|
|
899
|
+
variables?: Record<string, unknown>
|
|
900
|
+
): Promise<T> {
|
|
440
901
|
const url = `${this.baseURL}/api/graphql`;
|
|
441
902
|
const body = JSON.stringify({ query, variables: variables ?? {} });
|
|
442
903
|
const res = await fetch(url, {
|
|
443
|
-
method:
|
|
904
|
+
method: 'POST',
|
|
444
905
|
headers: {
|
|
445
|
-
|
|
446
|
-
Authorization: `Bearer ${this.token}
|
|
906
|
+
'Content-Type': 'application/json',
|
|
907
|
+
Authorization: `Bearer ${this.token}`
|
|
447
908
|
},
|
|
448
|
-
body
|
|
909
|
+
body
|
|
449
910
|
});
|
|
450
911
|
|
|
451
912
|
if (!res.ok) {
|
|
452
|
-
throw new Error(
|
|
913
|
+
throw new Error(
|
|
914
|
+
`GraphQL request failed: ${res.status} ${res.statusText}`
|
|
915
|
+
);
|
|
453
916
|
}
|
|
454
917
|
|
|
455
918
|
const envelope = (await res.json()) as {
|
|
@@ -458,12 +921,12 @@ export class GitLabProvider implements GitProvider {
|
|
|
458
921
|
};
|
|
459
922
|
|
|
460
923
|
if (envelope.errors?.length) {
|
|
461
|
-
const msg = envelope.errors.map(
|
|
924
|
+
const msg = envelope.errors.map(e => e.message).join('; ');
|
|
462
925
|
throw new Error(`GraphQL errors: ${msg}`);
|
|
463
926
|
}
|
|
464
927
|
|
|
465
928
|
if (!envelope.data) {
|
|
466
|
-
throw new Error(
|
|
929
|
+
throw new Error('GraphQL response missing data');
|
|
467
930
|
}
|
|
468
931
|
|
|
469
932
|
return envelope.data;
|