@remogram/provider-gitlab-api 0.1.0-beta.1 → 0.1.0-beta.10
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/branch-protection-internal.js +13 -0
- package/index.js +666 -40
- package/package.json +4 -3
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** @type {((ctx: object, opts: { branchRef: string }) => Promise<object>) | null} */
|
|
2
|
+
let branchProtectionImpl = null;
|
|
3
|
+
|
|
4
|
+
export function setBranchProtectionImpl(fn) {
|
|
5
|
+
branchProtectionImpl = fn;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function resolveBranchProtection(ctx, opts) {
|
|
9
|
+
if (typeof branchProtectionImpl !== 'function') {
|
|
10
|
+
throw new Error('branch protection impl not registered');
|
|
11
|
+
}
|
|
12
|
+
return branchProtectionImpl(ctx, opts);
|
|
13
|
+
}
|
package/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
fetchJson,
|
|
3
|
+
fetchJsonWithMeta,
|
|
3
4
|
sanitizeField,
|
|
4
5
|
sanitizeUrl,
|
|
5
6
|
assertGitRef,
|
|
@@ -7,22 +8,81 @@ import {
|
|
|
7
8
|
gitRevParse,
|
|
8
9
|
gitCurrentBranch,
|
|
9
10
|
gitAheadBehind,
|
|
11
|
+
refsInventory,
|
|
12
|
+
crInventory,
|
|
13
|
+
buildMergePlanFromProviderFacts,
|
|
10
14
|
ERROR_CODES,
|
|
11
15
|
forgeError,
|
|
16
|
+
LIVE_REACHABILITY_TIMEOUT_MS,
|
|
12
17
|
forgeIngestCapabilityFacts,
|
|
18
|
+
forgeWriteFieldCapabilityFacts,
|
|
19
|
+
checkPaginationCapabilityFacts,
|
|
20
|
+
openPullListCapabilityFacts,
|
|
21
|
+
DEFAULT_CHECK_STATUS_PAGE_SIZE,
|
|
22
|
+
MAX_CHECK_STATUS_PAGES,
|
|
23
|
+
DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
|
|
24
|
+
paginateCheckStatusPages,
|
|
25
|
+
paginateOffsetListPages,
|
|
26
|
+
fetchWithIngestPageBackoff,
|
|
27
|
+
fetchPageWithIngestBackoff,
|
|
28
|
+
withPerPageParam,
|
|
29
|
+
apiProviderCommands,
|
|
30
|
+
normalizeCrInventorySort,
|
|
31
|
+
DEFAULT_CR_INVENTORY_SLICE_SORT,
|
|
32
|
+
parseTotalCountHeader,
|
|
33
|
+
isCrInventoryFastPathEligible,
|
|
34
|
+
validateFastPathPageLength,
|
|
35
|
+
isNumberSortFastPathEligible,
|
|
36
|
+
isNumberSortFullCollectRequired,
|
|
37
|
+
resolveListTruncatedWithTrustedTotal,
|
|
38
|
+
orderOpenPullNumbers,
|
|
39
|
+
buildOpenPullListMeta,
|
|
40
|
+
gitlabOpenPullSortQuery,
|
|
41
|
+
appendSortQuery,
|
|
42
|
+
buildProviderIdentityFromGitLabUser,
|
|
43
|
+
buildBranchProtectionFromGitLabProtection,
|
|
44
|
+
buildPrChecksBody,
|
|
45
|
+
buildCrFilesFromGitLabChanges,
|
|
46
|
+
buildCrCommentsBody,
|
|
47
|
+
buildCrCommentsFromGitLabDiscussions,
|
|
48
|
+
parseSinceObservedAt,
|
|
49
|
+
buildForgeChangesFromGiteaPulls,
|
|
50
|
+
buildChecksConclusionObservedEvent,
|
|
51
|
+
appendForgeChangeEvents,
|
|
52
|
+
parseStatusSetArgs,
|
|
53
|
+
buildCommitStatusSetBody,
|
|
54
|
+
idempotencyPacketFields,
|
|
55
|
+
normalizeStatusSetState,
|
|
56
|
+
MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
|
|
57
|
+
statusSetIdempotencyScanCapabilityFacts,
|
|
58
|
+
assertWriteCommandConfigured,
|
|
13
59
|
} from '@remogram/core';
|
|
60
|
+
import {
|
|
61
|
+
resolveBranchProtection,
|
|
62
|
+
setBranchProtectionImpl,
|
|
63
|
+
} from './branch-protection-internal.js';
|
|
14
64
|
|
|
15
65
|
const PUBLIC_GITLAB_HOST = 'gitlab.com';
|
|
16
66
|
const PUBLIC_GITLAB_API = 'https://gitlab.com/api/v4';
|
|
17
67
|
const AUTH_CAPABILITIES = [
|
|
18
68
|
'repo_status',
|
|
19
69
|
'ref_compare',
|
|
70
|
+
'ref_inventory',
|
|
71
|
+
'cr_inventory',
|
|
20
72
|
'pr_status',
|
|
21
73
|
'pr_checks',
|
|
22
74
|
'merge_plan',
|
|
23
75
|
'sync_plan',
|
|
76
|
+
'status_set',
|
|
77
|
+
'whoami',
|
|
24
78
|
];
|
|
25
|
-
const STRUCTURED_COMMANDS =
|
|
79
|
+
const STRUCTURED_COMMANDS = apiProviderCommands({
|
|
80
|
+
branchProtectionImplemented: true,
|
|
81
|
+
crFilesImplemented: true,
|
|
82
|
+
crCommentsImplemented: true,
|
|
83
|
+
forgeChangesImplemented: true,
|
|
84
|
+
statusSetImplemented: true,
|
|
85
|
+
});
|
|
26
86
|
|
|
27
87
|
export function gitlabToken() {
|
|
28
88
|
return process.env.GITLAB_TOKEN || null;
|
|
@@ -115,35 +175,72 @@ export async function gitlabFetch(config, parsed, path, options = {}) {
|
|
|
115
175
|
});
|
|
116
176
|
}
|
|
117
177
|
|
|
118
|
-
|
|
178
|
+
export async function gitlabFetchWithMeta(config, parsed, path, options = {}) {
|
|
179
|
+
const base = apiBase(config, parsed);
|
|
180
|
+
const token = requireToken();
|
|
181
|
+
const url = `${base}${path}`;
|
|
182
|
+
return fetchJsonWithMeta(url, {
|
|
183
|
+
...options,
|
|
184
|
+
headers: { ...authHeaders(token), ...(options.headers || {}) },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function apiReachability(ctx) {
|
|
189
|
+
if (!gitlabToken()) {
|
|
190
|
+
throw Object.assign(new Error('GITLAB_TOKEN not set'), {
|
|
191
|
+
forgeError: forgeError(ERROR_CODES.UNAUTHENTICATED_PROVIDER, 'GITLAB_TOKEN not set'),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const token = requireToken();
|
|
195
|
+
const url = `${apiBase(ctx.config, ctx.parsed)}${projectApiPath(ctx.config)}`;
|
|
196
|
+
await fetchJson(
|
|
197
|
+
url,
|
|
198
|
+
{ headers: authHeaders(token) },
|
|
199
|
+
LIVE_REACHABILITY_TIMEOUT_MS,
|
|
200
|
+
);
|
|
201
|
+
return { repo_accessible: true };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const MAX_CHECK_PAGES = MAX_CHECK_STATUS_PAGES;
|
|
119
205
|
const GITLAB_PAGE_SIZE = 100;
|
|
120
206
|
|
|
121
207
|
export async function gitlabFetchPaginated(config, parsed, path) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
return all;
|
|
208
|
+
return paginateCheckStatusPages({
|
|
209
|
+
fetchPage: async ({ page, limit }) => {
|
|
210
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
211
|
+
const body = await gitlabFetch(
|
|
212
|
+
config,
|
|
213
|
+
parsed,
|
|
214
|
+
`${path}${separator}per_page=${limit}&page=${page}`,
|
|
215
|
+
);
|
|
216
|
+
return Array.isArray(body) ? body : [];
|
|
217
|
+
},
|
|
218
|
+
});
|
|
135
219
|
}
|
|
136
220
|
|
|
137
|
-
export function providerCapabilities() {
|
|
221
|
+
export function providerCapabilities(ctx = {}) {
|
|
222
|
+
const check_sources = ['commit_statuses', 'pipelines'];
|
|
138
223
|
return {
|
|
139
224
|
commands: STRUCTURED_COMMANDS,
|
|
140
225
|
auth_envs: ['GITLAB_TOKEN'],
|
|
141
|
-
check_sources
|
|
226
|
+
check_sources,
|
|
142
227
|
mergeability_confidence: 'derived',
|
|
143
228
|
host_binding: 'verified_remote_host',
|
|
144
229
|
pagination: 'supported',
|
|
145
|
-
write_support:
|
|
230
|
+
write_support: true,
|
|
231
|
+
write_commands: ['status_set'],
|
|
146
232
|
...forgeIngestCapabilityFacts(),
|
|
233
|
+
...forgeWriteFieldCapabilityFacts(ctx.writeFieldPolicy),
|
|
234
|
+
...statusSetIdempotencyScanCapabilityFacts(),
|
|
235
|
+
...checkPaginationCapabilityFacts({
|
|
236
|
+
strategy: 'offset_limit',
|
|
237
|
+
pageSizeParam: 'per_page',
|
|
238
|
+
sourceCount: check_sources.length,
|
|
239
|
+
}),
|
|
240
|
+
...openPullListCapabilityFacts({
|
|
241
|
+
totalCountSource: 'response_header',
|
|
242
|
+
totalCountHeader: 'X-Total',
|
|
243
|
+
}),
|
|
147
244
|
};
|
|
148
245
|
}
|
|
149
246
|
|
|
@@ -174,10 +271,10 @@ export async function refsCompare(ctx, baseRef, headRef) {
|
|
|
174
271
|
});
|
|
175
272
|
}
|
|
176
273
|
return {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
274
|
+
compare_base_ref: sanitizeField(baseRef),
|
|
275
|
+
compare_base_sha: baseSha,
|
|
276
|
+
compare_head_ref: sanitizeField(headRef),
|
|
277
|
+
compare_head_sha: headSha,
|
|
181
278
|
...gitAheadBehind(ctx.cwd, baseSha, headSha),
|
|
182
279
|
};
|
|
183
280
|
}
|
|
@@ -214,10 +311,10 @@ export async function prView(ctx, opts) {
|
|
|
214
311
|
url: sanitizeUrl(mr.web_url ?? mr.url),
|
|
215
312
|
title: sanitizeField(mr.title),
|
|
216
313
|
state: normalizeMrState(mr.state),
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
314
|
+
forge_target_branch_ref: sanitizeField(mr.target_branch),
|
|
315
|
+
forge_target_sha: sanitizeField(mr.diff_refs?.base_sha),
|
|
316
|
+
forge_source_branch_ref: sanitizeField(mr.source_branch),
|
|
317
|
+
forge_source_sha: sanitizeField(mr.sha ?? mr.diff_refs?.head_sha),
|
|
221
318
|
mergeability: mergeability(mr),
|
|
222
319
|
};
|
|
223
320
|
}
|
|
@@ -241,6 +338,7 @@ export async function prChecks(ctx, opts) {
|
|
|
241
338
|
apiBase(ctx.config, ctx.parsed);
|
|
242
339
|
requireToken();
|
|
243
340
|
let sha;
|
|
341
|
+
let requiredContexts = [];
|
|
244
342
|
if (opts.ref) {
|
|
245
343
|
assertGitRef(opts.ref, 'ref');
|
|
246
344
|
sha = gitRevParse(ctx.cwd, opts.ref);
|
|
@@ -252,6 +350,11 @@ export async function prChecks(ctx, opts) {
|
|
|
252
350
|
} else {
|
|
253
351
|
const mr = await getMergeRequest(ctx, opts);
|
|
254
352
|
sha = mr.sha ?? mr.diff_refs?.head_sha;
|
|
353
|
+
const targetBranch = mr.target_branch;
|
|
354
|
+
if (targetBranch) {
|
|
355
|
+
const protection = await resolveBranchProtection(ctx, { branchRef: targetBranch });
|
|
356
|
+
requiredContexts = protection.required_status_contexts ?? [];
|
|
357
|
+
}
|
|
255
358
|
}
|
|
256
359
|
if (!sha) {
|
|
257
360
|
throw Object.assign(new Error('No SHA'), {
|
|
@@ -259,7 +362,7 @@ export async function prChecks(ctx, opts) {
|
|
|
259
362
|
});
|
|
260
363
|
}
|
|
261
364
|
|
|
262
|
-
const [
|
|
365
|
+
const [statusResult, pipelineResult] = await Promise.all([
|
|
263
366
|
gitlabFetchPaginated(
|
|
264
367
|
ctx.config,
|
|
265
368
|
ctx.parsed,
|
|
@@ -271,37 +374,352 @@ export async function prChecks(ctx, opts) {
|
|
|
271
374
|
`${projectApiPath(ctx.config, 'pipelines')}?sha=${encodeURIComponent(sha)}`,
|
|
272
375
|
),
|
|
273
376
|
]);
|
|
377
|
+
const statusRecords = statusResult.items;
|
|
378
|
+
const pipelineRecords = pipelineResult.items;
|
|
274
379
|
const mappedStatuses = statusRecords.map((status) => ({
|
|
275
380
|
context: sanitizeField(status.name || status.context),
|
|
276
381
|
state: normalizeStatusState(status.status),
|
|
277
382
|
description: sanitizeField(status.description || status.status),
|
|
383
|
+
...(status.target_url ? { target_url: sanitizeField(status.target_url) } : {}),
|
|
384
|
+
sha,
|
|
385
|
+
source: 'commit_status',
|
|
278
386
|
}));
|
|
279
387
|
const mappedPipelines = pipelineRecords.map((pipeline) => ({
|
|
280
388
|
context: sanitizeField(pipeline.name || `pipeline:${pipeline.id}`),
|
|
281
389
|
state: normalizeStatusState(pipeline.status),
|
|
282
390
|
description: sanitizeField(pipeline.status),
|
|
391
|
+
sha,
|
|
392
|
+
source: 'pipeline',
|
|
283
393
|
}));
|
|
284
394
|
const mapped = [...mappedStatuses, ...mappedPipelines];
|
|
285
|
-
|
|
395
|
+
const checks_truncated = statusResult.truncated || pipelineResult.truncated;
|
|
396
|
+
return buildPrChecksBody({
|
|
397
|
+
forge_source_sha: sha,
|
|
398
|
+
check_conclusion: summarizeChecks(mapped),
|
|
399
|
+
checks_truncated,
|
|
400
|
+
statuses: mapped,
|
|
401
|
+
required_contexts: requiredContexts,
|
|
402
|
+
});
|
|
286
403
|
}
|
|
287
404
|
|
|
288
405
|
export async function mergePlan(ctx, opts) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
406
|
+
return buildMergePlanFromProviderFacts(ctx, opts, { prView, prChecks, crFiles });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function crFiles(ctx, { number }) {
|
|
410
|
+
requireToken();
|
|
411
|
+
if (number == null) {
|
|
412
|
+
throw Object.assign(new Error('--number required'), {
|
|
413
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for MR changed paths'),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const data = await gitlabFetch(
|
|
417
|
+
ctx.config,
|
|
418
|
+
ctx.parsed,
|
|
419
|
+
projectApiPath(ctx.config, 'merge_requests', number, 'changes'),
|
|
420
|
+
);
|
|
421
|
+
return buildCrFilesFromGitLabChanges(number, data?.changes);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export async function crComments(ctx, { number }) {
|
|
425
|
+
requireToken();
|
|
426
|
+
if (number == null) {
|
|
427
|
+
throw Object.assign(new Error('--number required'), {
|
|
428
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for MR review comments'),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const path = projectApiPath(ctx.config, 'merge_requests', number, 'discussions');
|
|
432
|
+
const pageSep = path.includes('?') ? '&' : '?';
|
|
433
|
+
let activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
|
|
434
|
+
const allDiscussions = [];
|
|
435
|
+
let listTruncated = false;
|
|
436
|
+
let entryCount = 0;
|
|
437
|
+
|
|
438
|
+
for (let page = 1; page <= MAX_CHECK_STATUS_PAGES; page += 1) {
|
|
439
|
+
const { items, usedLimit } = await fetchPageWithIngestBackoff(
|
|
440
|
+
async ({ page: pageNum, limit }) => {
|
|
441
|
+
const body = await gitlabFetch(
|
|
442
|
+
ctx.config,
|
|
443
|
+
ctx.parsed,
|
|
444
|
+
`${path}${pageSep}per_page=${limit}&page=${pageNum}`,
|
|
445
|
+
);
|
|
446
|
+
if (!Array.isArray(body)) {
|
|
447
|
+
throw Object.assign(new Error('Provider returned non-array MR discussions list'), {
|
|
448
|
+
forgeError: forgeError(
|
|
449
|
+
ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
|
|
450
|
+
'Provider returned non-array MR discussions list',
|
|
451
|
+
),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return body;
|
|
455
|
+
},
|
|
456
|
+
page,
|
|
457
|
+
activeLimit,
|
|
458
|
+
);
|
|
459
|
+
activeLimit = usedLimit;
|
|
460
|
+
for (const discussion of items) {
|
|
461
|
+
const notes = Array.isArray(discussion?.notes) ? discussion.notes : [];
|
|
462
|
+
for (const note of notes) {
|
|
463
|
+
if (note?.system === true) continue;
|
|
464
|
+
entryCount += 1;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
allDiscussions.push(...items);
|
|
468
|
+
if (items.length < usedLimit) break;
|
|
469
|
+
if (page === MAX_CHECK_STATUS_PAGES) {
|
|
470
|
+
listTruncated = true;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const body = buildCrCommentsFromGitLabDiscussions(number, allDiscussions);
|
|
475
|
+
if (listTruncated) {
|
|
476
|
+
return buildCrCommentsBody({
|
|
477
|
+
pr_number: body.pr_number,
|
|
478
|
+
comments: body.comments,
|
|
479
|
+
comments_truncated: true,
|
|
480
|
+
comment_count: entryCount,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return body;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function gitlabMergeRequestAsPull(mr) {
|
|
487
|
+
if (mr == null || mr.iid == null) return null;
|
|
488
|
+
let state = 'unknown';
|
|
489
|
+
if (mr.state === 'opened') state = 'open';
|
|
490
|
+
else if (mr.state === 'merged' || mr.state === 'closed') state = 'closed';
|
|
297
491
|
return {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
492
|
+
number: mr.iid,
|
|
493
|
+
title: mr.title,
|
|
494
|
+
html_url: mr.web_url,
|
|
495
|
+
state,
|
|
496
|
+
created_at: mr.created_at,
|
|
497
|
+
updated_at: mr.updated_at,
|
|
498
|
+
closed_at: mr.closed_at,
|
|
499
|
+
merged_at: mr.merged_at,
|
|
500
|
+
head: { sha: mr.sha ?? mr.diff_refs?.head_sha ?? null },
|
|
302
501
|
};
|
|
303
502
|
}
|
|
304
503
|
|
|
504
|
+
export async function forgeChanges(ctx, { since }) {
|
|
505
|
+
requireToken();
|
|
506
|
+
const sinceIso = parseSinceObservedAt(since);
|
|
507
|
+
const path = `${projectApiPath(ctx.config, 'merge_requests')}?state=all&order_by=updated_at&sort=desc`;
|
|
508
|
+
const pageSep = '&';
|
|
509
|
+
let activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
|
|
510
|
+
const allMrs = [];
|
|
511
|
+
let listTruncated = false;
|
|
512
|
+
|
|
513
|
+
for (let page = 1; page <= MAX_CHECK_STATUS_PAGES; page += 1) {
|
|
514
|
+
const { items, usedLimit } = await fetchPageWithIngestBackoff(
|
|
515
|
+
async ({ page: pageNum, limit }) => {
|
|
516
|
+
const body = await gitlabFetch(
|
|
517
|
+
ctx.config,
|
|
518
|
+
ctx.parsed,
|
|
519
|
+
`${path}${pageSep}per_page=${limit}&page=${pageNum}`,
|
|
520
|
+
);
|
|
521
|
+
if (!Array.isArray(body)) {
|
|
522
|
+
throw Object.assign(new Error('Provider returned non-array merge request list'), {
|
|
523
|
+
forgeError: forgeError(
|
|
524
|
+
ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
|
|
525
|
+
'Provider returned non-array merge request list',
|
|
526
|
+
),
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return body;
|
|
530
|
+
},
|
|
531
|
+
page,
|
|
532
|
+
activeLimit,
|
|
533
|
+
);
|
|
534
|
+
activeLimit = usedLimit;
|
|
535
|
+
allMrs.push(...items);
|
|
536
|
+
if (items.length < usedLimit) break;
|
|
537
|
+
if (page === MAX_CHECK_STATUS_PAGES) {
|
|
538
|
+
listTruncated = true;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const pulls = allMrs.map(gitlabMergeRequestAsPull).filter(Boolean);
|
|
543
|
+
let body = buildForgeChangesFromGiteaPulls(sinceIso, pulls, { listTruncated });
|
|
544
|
+
const checkNumbers = new Set();
|
|
545
|
+
for (const event of body.events) {
|
|
546
|
+
if (event.kind === 'pr_opened' || event.kind === 'head_sha_moved') {
|
|
547
|
+
checkNumbers.add(event.pr_number);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const checkEvents = [];
|
|
552
|
+
for (const number of checkNumbers) {
|
|
553
|
+
const checks = await prChecks(ctx, { number });
|
|
554
|
+
checkEvents.push(buildChecksConclusionObservedEvent(number, checks));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (checkEvents.length > 0) {
|
|
558
|
+
body = appendForgeChangeEvents(body, checkEvents, { listTruncated });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return body;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const GITLAB_OPEN_PULL_COMPLIANT_MAX =
|
|
565
|
+
DEFAULT_OPEN_PULL_LIST_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
|
|
566
|
+
|
|
567
|
+
async function probeGitlabOpenPullPageOne(ctx, retainMax, sliceSort) {
|
|
568
|
+
const maxTrusted = GITLAB_OPEN_PULL_COMPLIANT_MAX * 2;
|
|
569
|
+
let path = `${projectApiPath(ctx.config, 'merge_requests')}?state=opened`;
|
|
570
|
+
path = appendSortQuery(path, gitlabOpenPullSortQuery(sliceSort));
|
|
571
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
572
|
+
const requestLimit = Math.min(retainMax, GITLAB_PAGE_SIZE);
|
|
573
|
+
try {
|
|
574
|
+
const { body, headers } = await gitlabFetchWithMeta(
|
|
575
|
+
ctx.config,
|
|
576
|
+
ctx.parsed,
|
|
577
|
+
`${path}${separator}per_page=${requestLimit}&page=1`,
|
|
578
|
+
);
|
|
579
|
+
if (!Array.isArray(body)) return null;
|
|
580
|
+
const totalCount = parseTotalCountHeader(headers, 'X-Total', { maxTrusted });
|
|
581
|
+
if (totalCount == null) return null;
|
|
582
|
+
const listTruncated = totalCount > GITLAB_OPEN_PULL_COMPLIANT_MAX;
|
|
583
|
+
return { body, totalCount, listTruncated, requestLimit };
|
|
584
|
+
} catch {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function buildGitlabOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, listTruncated) {
|
|
590
|
+
let numbers = orderOpenPullNumbers(body, (mr) => mr?.iid, sliceSort);
|
|
591
|
+
if (numbers.length > retainMax) numbers = numbers.slice(0, retainMax);
|
|
592
|
+
return buildOpenPullListMeta({
|
|
593
|
+
totalCount,
|
|
594
|
+
numbers,
|
|
595
|
+
listTruncated,
|
|
596
|
+
sliceSort,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function gitlabProbePaginationOpts(probe, extra = {}) {
|
|
601
|
+
const { body, totalCount, requestLimit } = probe;
|
|
602
|
+
return {
|
|
603
|
+
trustedTotalCount: totalCount,
|
|
604
|
+
seededFirstPage: { items: body, usedLimit: requestLimit },
|
|
605
|
+
...extra,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function paginateGitlabOpenPullList(ctx, opts, sliceSort, paginationOpts = {}) {
|
|
610
|
+
const {
|
|
611
|
+
trustedTotalCount = null,
|
|
612
|
+
numberSortFullCollect = false,
|
|
613
|
+
seededFirstPage = null,
|
|
614
|
+
startPage = 1,
|
|
615
|
+
maxPages = MAX_CHECK_STATUS_PAGES,
|
|
616
|
+
suppressFinalPageProbe = false,
|
|
617
|
+
} = paginationOpts;
|
|
618
|
+
const listLimit =
|
|
619
|
+
opts.limit != null && Number.isInteger(Number(opts.limit)) && Number(opts.limit) > 0
|
|
620
|
+
? Number(opts.limit)
|
|
621
|
+
: null;
|
|
622
|
+
const retainMax =
|
|
623
|
+
listLimit == null &&
|
|
624
|
+
opts.retain_max != null &&
|
|
625
|
+
Number.isInteger(Number(opts.retain_max)) &&
|
|
626
|
+
Number(opts.retain_max) > 0
|
|
627
|
+
? Number(opts.retain_max)
|
|
628
|
+
: null;
|
|
629
|
+
let path = `${projectApiPath(ctx.config, 'merge_requests')}?state=opened`;
|
|
630
|
+
path = appendSortQuery(path, gitlabOpenPullSortQuery(sliceSort));
|
|
631
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
632
|
+
const effectiveRetainMax = numberSortFullCollect ? null : retainMax;
|
|
633
|
+
const {
|
|
634
|
+
items: all,
|
|
635
|
+
list_truncated: listTruncated,
|
|
636
|
+
entry_count: entryCount,
|
|
637
|
+
walked_count: walkedCount,
|
|
638
|
+
} = await paginateOffsetListPages({
|
|
639
|
+
pageSize: GITLAB_PAGE_SIZE,
|
|
640
|
+
listLimit,
|
|
641
|
+
retainMax: effectiveRetainMax,
|
|
642
|
+
trustedEntryCount: trustedTotalCount,
|
|
643
|
+
seededFirstPage,
|
|
644
|
+
startPage,
|
|
645
|
+
maxPages,
|
|
646
|
+
suppressFinalPageProbe,
|
|
647
|
+
...(listLimit != null ? { maxPagesTruncatesWithLimit: true } : {}),
|
|
648
|
+
fetchPage: async ({ page, limit }) => {
|
|
649
|
+
const body = await gitlabFetch(
|
|
650
|
+
ctx.config,
|
|
651
|
+
ctx.parsed,
|
|
652
|
+
`${path}${separator}per_page=${limit}&page=${page}`,
|
|
653
|
+
);
|
|
654
|
+
return Array.isArray(body) ? body : [];
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
let numbers = orderOpenPullNumbers(all, (mr) => mr?.iid, sliceSort);
|
|
658
|
+
const outputCap = listLimit ?? retainMax;
|
|
659
|
+
if (outputCap != null && numbers.length > outputCap) {
|
|
660
|
+
numbers = numbers.slice(0, outputCap);
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
numbers,
|
|
664
|
+
list_truncated: resolveListTruncatedWithTrustedTotal({
|
|
665
|
+
listTruncated,
|
|
666
|
+
trustedTotalCount,
|
|
667
|
+
walkedCount,
|
|
668
|
+
fullCollect: numberSortFullCollect,
|
|
669
|
+
}),
|
|
670
|
+
...(entryCount != null ? { entry_count: entryCount } : {}),
|
|
671
|
+
slice_sort: sliceSort,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export async function listOpenPullsWithMeta(ctx, opts = {}) {
|
|
676
|
+
apiBase(ctx.config, ctx.parsed);
|
|
677
|
+
requireToken();
|
|
678
|
+
const sliceSort = normalizeCrInventorySort(opts.sort ?? DEFAULT_CR_INVENTORY_SLICE_SORT);
|
|
679
|
+
if (!isCrInventoryFastPathEligible(opts)) {
|
|
680
|
+
return paginateGitlabOpenPullList(ctx, opts, sliceSort);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const retainMax = Number(opts.retain_max);
|
|
684
|
+
const probe = await probeGitlabOpenPullPageOne(ctx, retainMax, sliceSort);
|
|
685
|
+
if (!probe) {
|
|
686
|
+
return paginateGitlabOpenPullList(ctx, opts, sliceSort);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const { body, totalCount, listTruncated, requestLimit } = probe;
|
|
690
|
+
|
|
691
|
+
if (listTruncated) {
|
|
692
|
+
if (body.length === 0) {
|
|
693
|
+
return paginateGitlabOpenPullList(ctx, opts, sliceSort, gitlabProbePaginationOpts(probe));
|
|
694
|
+
}
|
|
695
|
+
return buildGitlabOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, true);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (
|
|
699
|
+
isNumberSortFastPathEligible(totalCount, retainMax, sliceSort) &&
|
|
700
|
+
validateFastPathPageLength(totalCount, requestLimit, body.length)
|
|
701
|
+
) {
|
|
702
|
+
return buildGitlabOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, false);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const numberSortFullCollect = isNumberSortFullCollectRequired(totalCount, retainMax, sliceSort);
|
|
706
|
+
return paginateGitlabOpenPullList(
|
|
707
|
+
ctx,
|
|
708
|
+
opts,
|
|
709
|
+
sliceSort,
|
|
710
|
+
gitlabProbePaginationOpts(probe, { numberSortFullCollect }),
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export async function listOpenPulls(ctx, opts = {}) {
|
|
715
|
+
const meta = await listOpenPullsWithMeta(ctx, opts);
|
|
716
|
+
return meta.numbers;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export async function crInventorySlice(ctx, opts = {}) {
|
|
720
|
+
return crInventory(ctx, { listOpenPulls, listOpenPullsWithMeta, prView, prChecks }, opts);
|
|
721
|
+
}
|
|
722
|
+
|
|
305
723
|
export async function syncPlan(ctx, remoteName = 'origin') {
|
|
306
724
|
assertGitRemote(remoteName, 'remote');
|
|
307
725
|
apiBase(ctx.config, ctx.parsed);
|
|
@@ -330,13 +748,221 @@ export async function syncPlan(ctx, remoteName = 'origin') {
|
|
|
330
748
|
};
|
|
331
749
|
}
|
|
332
750
|
|
|
751
|
+
async function fetchGitLabPatSelf(ctx) {
|
|
752
|
+
try {
|
|
753
|
+
return await gitlabFetch(ctx.config, ctx.parsed, '/personal_access_tokens/self');
|
|
754
|
+
} catch {
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export async function whoami(ctx) {
|
|
760
|
+
requireToken();
|
|
761
|
+
const user = await gitlabFetch(ctx.config, ctx.parsed, '/user');
|
|
762
|
+
const patSelf = await fetchGitLabPatSelf(ctx);
|
|
763
|
+
return buildProviderIdentityFromGitLabUser(user, patSelf);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function approvalRulesForBranch(rules, branchRef) {
|
|
767
|
+
if (!Array.isArray(rules)) return [];
|
|
768
|
+
return rules.filter((rule) => {
|
|
769
|
+
const branches = rule?.protected_branches;
|
|
770
|
+
if (!Array.isArray(branches)) return false;
|
|
771
|
+
return branches.some((branch) => branch?.name === branchRef);
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export async function branchProtection(ctx, { branchRef }) {
|
|
776
|
+
assertGitRef(branchRef, '--branch-ref');
|
|
777
|
+
requireToken();
|
|
778
|
+
let protectedBranch = null;
|
|
779
|
+
try {
|
|
780
|
+
protectedBranch = await gitlabFetch(
|
|
781
|
+
ctx.config,
|
|
782
|
+
ctx.parsed,
|
|
783
|
+
projectApiPath(ctx.config, 'protected_branches', branchRef),
|
|
784
|
+
);
|
|
785
|
+
} catch (err) {
|
|
786
|
+
if (err?.status !== 404) throw err;
|
|
787
|
+
}
|
|
788
|
+
let approvalRules = [];
|
|
789
|
+
if (protectedBranch != null) {
|
|
790
|
+
try {
|
|
791
|
+
const allRules = await gitlabFetch(
|
|
792
|
+
ctx.config,
|
|
793
|
+
ctx.parsed,
|
|
794
|
+
projectApiPath(ctx.config, 'approval_rules'),
|
|
795
|
+
);
|
|
796
|
+
approvalRules = approvalRulesForBranch(allRules, branchRef);
|
|
797
|
+
} catch {
|
|
798
|
+
approvalRules = [];
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return buildBranchProtectionFromGitLabProtection(branchRef, { protectedBranch, approvalRules });
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function gitlabStatusRecordOrder(a, b) {
|
|
805
|
+
const aUpdated = Date.parse(a.updated_at ?? a.created_at ?? '') || 0;
|
|
806
|
+
const bUpdated = Date.parse(b.updated_at ?? b.created_at ?? '') || 0;
|
|
807
|
+
if (aUpdated !== bUpdated) return aUpdated - bUpdated;
|
|
808
|
+
const aId = Number(a.id) || 0;
|
|
809
|
+
const bId = Number(b.id) || 0;
|
|
810
|
+
return aId - bId;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function gitlabStatusAsRemogramState(status) {
|
|
814
|
+
const normalized = String(status ?? '').toLowerCase();
|
|
815
|
+
if (normalized === 'failed' || normalized === 'canceled') return 'failure';
|
|
816
|
+
if (
|
|
817
|
+
normalized === 'running'
|
|
818
|
+
|| normalized === 'created'
|
|
819
|
+
|| normalized === 'waiting_for_resource'
|
|
820
|
+
|| normalized === 'preparing'
|
|
821
|
+
) {
|
|
822
|
+
return 'pending';
|
|
823
|
+
}
|
|
824
|
+
return normalizeStatusSetState(normalized);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function remogramStateToGitlabPostState(state) {
|
|
828
|
+
if (state === 'failure' || state === 'error') return 'failed';
|
|
829
|
+
return state;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function statusSetIdempotencyScanIncompleteError(pagesScanned, pageSizeUsed) {
|
|
833
|
+
return forgeError(
|
|
834
|
+
ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE,
|
|
835
|
+
'Cannot prove no commit status exists for sha+context within scan limit; retry or set manually',
|
|
836
|
+
null,
|
|
837
|
+
{
|
|
838
|
+
idempotency_scan: {
|
|
839
|
+
pages: pagesScanned,
|
|
840
|
+
max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
|
|
841
|
+
page_size: pageSizeUsed,
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Paginated commit-status scan for idempotent status set; fail-closed when scan cap prevents proof of absence. */
|
|
848
|
+
export async function findCommitStatusByContext(ctx, sha, context) {
|
|
849
|
+
requireToken();
|
|
850
|
+
const path = projectApiPath(ctx.config, 'repository', 'commits', sha, 'statuses');
|
|
851
|
+
const pageSep = path.includes('?') ? '&' : '?';
|
|
852
|
+
let activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
|
|
853
|
+
let bestMatch = null;
|
|
854
|
+
|
|
855
|
+
for (let page = 1; page <= MAX_OPEN_PULL_IDEMPOTENCY_PAGES; page += 1) {
|
|
856
|
+
const { items, usedLimit } = await fetchPageWithIngestBackoff(
|
|
857
|
+
async ({ page: pageNum, limit }) => {
|
|
858
|
+
const body = await gitlabFetch(
|
|
859
|
+
ctx.config,
|
|
860
|
+
ctx.parsed,
|
|
861
|
+
`${path}${pageSep}per_page=${limit}&page=${pageNum}`,
|
|
862
|
+
);
|
|
863
|
+
if (!Array.isArray(body)) {
|
|
864
|
+
throw Object.assign(new Error('Provider returned non-array commit status list'), {
|
|
865
|
+
forgeError: forgeError(
|
|
866
|
+
ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
|
|
867
|
+
'Provider returned non-array commit status list',
|
|
868
|
+
),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
return body;
|
|
872
|
+
},
|
|
873
|
+
page,
|
|
874
|
+
activeLimit,
|
|
875
|
+
);
|
|
876
|
+
activeLimit = usedLimit;
|
|
877
|
+
|
|
878
|
+
for (const record of items) {
|
|
879
|
+
if (record?.name !== context) continue;
|
|
880
|
+
if (!bestMatch || gitlabStatusRecordOrder(record, bestMatch) > 0) {
|
|
881
|
+
bestMatch = record;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (items.length < usedLimit) return bestMatch;
|
|
886
|
+
if (page === MAX_OPEN_PULL_IDEMPOTENCY_PAGES) {
|
|
887
|
+
throw Object.assign(new Error('Commit status idempotency scan incomplete'), {
|
|
888
|
+
forgeError: statusSetIdempotencyScanIncompleteError(page, usedLimit),
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return bestMatch;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export async function statusSet(ctx, args) {
|
|
896
|
+
assertWriteCommandConfigured(ctx.writePolicy ?? ctx.config, 'status_set');
|
|
897
|
+
const { idempotencyFingerprint = null, ...rest } = args;
|
|
898
|
+
const parsed = parseStatusSetArgs(rest);
|
|
899
|
+
const existing = await findCommitStatusByContext(ctx, parsed.sha, parsed.context);
|
|
900
|
+
if (existing) {
|
|
901
|
+
const requestedGitlabState = remogramStateToGitlabPostState(parsed.state);
|
|
902
|
+
const existingGitlabState = String(existing.status ?? existing.state ?? '').toLowerCase();
|
|
903
|
+
if (existingGitlabState === requestedGitlabState) {
|
|
904
|
+
const remogramState = gitlabStatusAsRemogramState(existing.status ?? existing.state);
|
|
905
|
+
return buildCommitStatusSetBody(
|
|
906
|
+
{ ...existing, status: remogramState },
|
|
907
|
+
parsed,
|
|
908
|
+
{
|
|
909
|
+
reusedExisting: true,
|
|
910
|
+
idempotencyFields: idempotencyFingerprint
|
|
911
|
+
? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: true })
|
|
912
|
+
: null,
|
|
913
|
+
},
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
const payload = {
|
|
918
|
+
state: remogramStateToGitlabPostState(parsed.state),
|
|
919
|
+
name: parsed.context,
|
|
920
|
+
};
|
|
921
|
+
if (parsed.description != null) payload.description = parsed.description;
|
|
922
|
+
if (parsed.target_url != null) payload.target_url = parsed.target_url;
|
|
923
|
+
const response = await gitlabFetch(
|
|
924
|
+
ctx.config,
|
|
925
|
+
ctx.parsed,
|
|
926
|
+
projectApiPath(ctx.config, 'statuses', parsed.sha),
|
|
927
|
+
{
|
|
928
|
+
method: 'POST',
|
|
929
|
+
headers: { 'Content-Type': 'application/json' },
|
|
930
|
+
body: JSON.stringify(payload),
|
|
931
|
+
},
|
|
932
|
+
);
|
|
933
|
+
return buildCommitStatusSetBody(
|
|
934
|
+
{
|
|
935
|
+
...response,
|
|
936
|
+
status: gitlabStatusAsRemogramState(response?.status ?? response?.state ?? parsed.state),
|
|
937
|
+
},
|
|
938
|
+
parsed,
|
|
939
|
+
{
|
|
940
|
+
idempotencyFields: idempotencyFingerprint
|
|
941
|
+
? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: false })
|
|
942
|
+
: null,
|
|
943
|
+
},
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
333
947
|
export const provider = {
|
|
334
948
|
id: 'gitlab-api',
|
|
335
949
|
providerCapabilities,
|
|
950
|
+
apiReachability,
|
|
336
951
|
repoStatus,
|
|
337
952
|
refsCompare,
|
|
953
|
+
refsInventory,
|
|
954
|
+
listOpenPulls,
|
|
955
|
+
crInventory: crInventorySlice,
|
|
338
956
|
prView,
|
|
339
957
|
prChecks,
|
|
340
958
|
mergePlan,
|
|
341
959
|
syncPlan,
|
|
960
|
+
whoami,
|
|
961
|
+
branchProtection,
|
|
962
|
+
crFiles,
|
|
963
|
+
crComments,
|
|
964
|
+
forgeChanges,
|
|
965
|
+
statusSet,
|
|
342
966
|
};
|
|
967
|
+
|
|
968
|
+
setBranchProtectionImpl(branchProtection);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remogram/provider-gitlab-api",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.10",
|
|
4
4
|
"description": "GitLab API provider for remogram",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
"*.js"
|
|
17
17
|
],
|
|
18
18
|
"exports": {
|
|
19
|
-
".": "./index.js"
|
|
19
|
+
".": "./index.js",
|
|
20
|
+
"./branch-protection-internal.js": "./branch-protection-internal.js"
|
|
20
21
|
},
|
|
21
22
|
"engines": {
|
|
22
23
|
"node": ">=20"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
25
|
-
"@remogram/core": "0.1.0-beta.
|
|
26
|
+
"@remogram/core": "0.1.0-beta.10"
|
|
26
27
|
}
|
|
27
28
|
}
|