@remogram/provider-gitlab-api 0.1.0-beta.0 → 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 +682 -32
- 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,21 +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,
|
|
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,
|
|
12
59
|
} from '@remogram/core';
|
|
60
|
+
import {
|
|
61
|
+
resolveBranchProtection,
|
|
62
|
+
setBranchProtectionImpl,
|
|
63
|
+
} from './branch-protection-internal.js';
|
|
13
64
|
|
|
14
65
|
const PUBLIC_GITLAB_HOST = 'gitlab.com';
|
|
15
66
|
const PUBLIC_GITLAB_API = 'https://gitlab.com/api/v4';
|
|
16
67
|
const AUTH_CAPABILITIES = [
|
|
17
68
|
'repo_status',
|
|
18
69
|
'ref_compare',
|
|
70
|
+
'ref_inventory',
|
|
71
|
+
'cr_inventory',
|
|
19
72
|
'pr_status',
|
|
20
73
|
'pr_checks',
|
|
21
74
|
'merge_plan',
|
|
22
75
|
'sync_plan',
|
|
76
|
+
'status_set',
|
|
77
|
+
'whoami',
|
|
23
78
|
];
|
|
24
|
-
const STRUCTURED_COMMANDS =
|
|
79
|
+
const STRUCTURED_COMMANDS = apiProviderCommands({
|
|
80
|
+
branchProtectionImplemented: true,
|
|
81
|
+
crFilesImplemented: true,
|
|
82
|
+
crCommentsImplemented: true,
|
|
83
|
+
forgeChangesImplemented: true,
|
|
84
|
+
statusSetImplemented: true,
|
|
85
|
+
});
|
|
25
86
|
|
|
26
87
|
export function gitlabToken() {
|
|
27
88
|
return process.env.GITLAB_TOKEN || null;
|
|
@@ -114,15 +175,72 @@ export async function gitlabFetch(config, parsed, path, options = {}) {
|
|
|
114
175
|
});
|
|
115
176
|
}
|
|
116
177
|
|
|
117
|
-
export function
|
|
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;
|
|
205
|
+
const GITLAB_PAGE_SIZE = 100;
|
|
206
|
+
|
|
207
|
+
export async function gitlabFetchPaginated(config, parsed, path) {
|
|
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
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function providerCapabilities(ctx = {}) {
|
|
222
|
+
const check_sources = ['commit_statuses', 'pipelines'];
|
|
118
223
|
return {
|
|
119
224
|
commands: STRUCTURED_COMMANDS,
|
|
120
225
|
auth_envs: ['GITLAB_TOKEN'],
|
|
121
|
-
check_sources
|
|
226
|
+
check_sources,
|
|
122
227
|
mergeability_confidence: 'derived',
|
|
123
228
|
host_binding: 'verified_remote_host',
|
|
124
|
-
pagination: '
|
|
125
|
-
write_support:
|
|
229
|
+
pagination: 'supported',
|
|
230
|
+
write_support: true,
|
|
231
|
+
write_commands: ['status_set'],
|
|
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
|
+
}),
|
|
126
244
|
};
|
|
127
245
|
}
|
|
128
246
|
|
|
@@ -143,7 +261,6 @@ export async function repoStatus(ctx) {
|
|
|
143
261
|
|
|
144
262
|
export async function refsCompare(ctx, baseRef, headRef) {
|
|
145
263
|
apiBase(ctx.config, ctx.parsed);
|
|
146
|
-
requireToken();
|
|
147
264
|
assertGitRef(baseRef, 'base');
|
|
148
265
|
assertGitRef(headRef, 'head');
|
|
149
266
|
const baseSha = gitRevParse(ctx.cwd, baseRef);
|
|
@@ -154,10 +271,10 @@ export async function refsCompare(ctx, baseRef, headRef) {
|
|
|
154
271
|
});
|
|
155
272
|
}
|
|
156
273
|
return {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
274
|
+
compare_base_ref: sanitizeField(baseRef),
|
|
275
|
+
compare_base_sha: baseSha,
|
|
276
|
+
compare_head_ref: sanitizeField(headRef),
|
|
277
|
+
compare_head_sha: headSha,
|
|
161
278
|
...gitAheadBehind(ctx.cwd, baseSha, headSha),
|
|
162
279
|
};
|
|
163
280
|
}
|
|
@@ -194,10 +311,10 @@ export async function prView(ctx, opts) {
|
|
|
194
311
|
url: sanitizeUrl(mr.web_url ?? mr.url),
|
|
195
312
|
title: sanitizeField(mr.title),
|
|
196
313
|
state: normalizeMrState(mr.state),
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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),
|
|
201
318
|
mergeability: mergeability(mr),
|
|
202
319
|
};
|
|
203
320
|
}
|
|
@@ -221,6 +338,7 @@ export async function prChecks(ctx, opts) {
|
|
|
221
338
|
apiBase(ctx.config, ctx.parsed);
|
|
222
339
|
requireToken();
|
|
223
340
|
let sha;
|
|
341
|
+
let requiredContexts = [];
|
|
224
342
|
if (opts.ref) {
|
|
225
343
|
assertGitRef(opts.ref, 'ref');
|
|
226
344
|
sha = gitRevParse(ctx.cwd, opts.ref);
|
|
@@ -232,6 +350,11 @@ export async function prChecks(ctx, opts) {
|
|
|
232
350
|
} else {
|
|
233
351
|
const mr = await getMergeRequest(ctx, opts);
|
|
234
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
|
+
}
|
|
235
358
|
}
|
|
236
359
|
if (!sha) {
|
|
237
360
|
throw Object.assign(new Error('No SHA'), {
|
|
@@ -239,45 +362,364 @@ export async function prChecks(ctx, opts) {
|
|
|
239
362
|
});
|
|
240
363
|
}
|
|
241
364
|
|
|
242
|
-
const [
|
|
243
|
-
|
|
244
|
-
|
|
365
|
+
const [statusResult, pipelineResult] = await Promise.all([
|
|
366
|
+
gitlabFetchPaginated(
|
|
367
|
+
ctx.config,
|
|
368
|
+
ctx.parsed,
|
|
369
|
+
projectApiPath(ctx.config, 'repository', 'commits', sha, 'statuses'),
|
|
370
|
+
),
|
|
371
|
+
gitlabFetchPaginated(
|
|
245
372
|
ctx.config,
|
|
246
373
|
ctx.parsed,
|
|
247
374
|
`${projectApiPath(ctx.config, 'pipelines')}?sha=${encodeURIComponent(sha)}`,
|
|
248
375
|
),
|
|
249
376
|
]);
|
|
250
|
-
const
|
|
377
|
+
const statusRecords = statusResult.items;
|
|
378
|
+
const pipelineRecords = pipelineResult.items;
|
|
379
|
+
const mappedStatuses = statusRecords.map((status) => ({
|
|
251
380
|
context: sanitizeField(status.name || status.context),
|
|
252
381
|
state: normalizeStatusState(status.status),
|
|
253
382
|
description: sanitizeField(status.description || status.status),
|
|
383
|
+
...(status.target_url ? { target_url: sanitizeField(status.target_url) } : {}),
|
|
384
|
+
sha,
|
|
385
|
+
source: 'commit_status',
|
|
254
386
|
}));
|
|
255
|
-
const mappedPipelines =
|
|
387
|
+
const mappedPipelines = pipelineRecords.map((pipeline) => ({
|
|
256
388
|
context: sanitizeField(pipeline.name || `pipeline:${pipeline.id}`),
|
|
257
389
|
state: normalizeStatusState(pipeline.status),
|
|
258
390
|
description: sanitizeField(pipeline.status),
|
|
391
|
+
sha,
|
|
392
|
+
source: 'pipeline',
|
|
259
393
|
}));
|
|
260
394
|
const mapped = [...mappedStatuses, ...mappedPipelines];
|
|
261
|
-
|
|
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
|
+
});
|
|
262
403
|
}
|
|
263
404
|
|
|
264
405
|
export async function mergePlan(ctx, opts) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
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';
|
|
273
491
|
return {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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 },
|
|
278
501
|
};
|
|
279
502
|
}
|
|
280
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
|
+
|
|
281
723
|
export async function syncPlan(ctx, remoteName = 'origin') {
|
|
282
724
|
assertGitRemote(remoteName, 'remote');
|
|
283
725
|
apiBase(ctx.config, ctx.parsed);
|
|
@@ -306,13 +748,221 @@ export async function syncPlan(ctx, remoteName = 'origin') {
|
|
|
306
748
|
};
|
|
307
749
|
}
|
|
308
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
|
+
|
|
309
947
|
export const provider = {
|
|
310
948
|
id: 'gitlab-api',
|
|
311
949
|
providerCapabilities,
|
|
950
|
+
apiReachability,
|
|
312
951
|
repoStatus,
|
|
313
952
|
refsCompare,
|
|
953
|
+
refsInventory,
|
|
954
|
+
listOpenPulls,
|
|
955
|
+
crInventory: crInventorySlice,
|
|
314
956
|
prView,
|
|
315
957
|
prChecks,
|
|
316
958
|
mergePlan,
|
|
317
959
|
syncPlan,
|
|
960
|
+
whoami,
|
|
961
|
+
branchProtection,
|
|
962
|
+
crFiles,
|
|
963
|
+
crComments,
|
|
964
|
+
forgeChanges,
|
|
965
|
+
statusSet,
|
|
318
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
|
}
|