@brainervirus/workit-core 2.3.0 → 2.5.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/package.json +1 -1
- package/src/autonomy.ts +135 -0
- package/src/core/external-action-effects.ts +4 -9
- package/src/core/pr-create.ts +2 -71
- package/src/forge/exec.ts +221 -0
- package/src/forge/github.ts +529 -0
- package/src/forge/gitlab.ts +556 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-ops.ts +450 -0
- package/src/forge/redact.ts +191 -0
- package/src/forge/report.ts +618 -0
- package/src/forge/reruns.ts +127 -0
- package/src/forge/resolve.ts +240 -0
- package/src/forge/types.ts +166 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/ops.ts +626 -0
- package/src/git/rev.ts +120 -11
- package/src/ledger.ts +17 -1
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// GitLab adapter: `glab api` REST only (design §2.0 GitLab endpoints): the MR
|
|
2
|
+
// (`detailed_merge_status`, `has_conflicts`, `head_pipeline`), pipeline jobs,
|
|
3
|
+
// job traces, discussions, and pipeline/job retry.
|
|
4
|
+
import { apiJson, apiText, apiWrite, FORGE_TIMEOUTS, type ForgeRunner } from "./exec";
|
|
5
|
+
import { logTail, redactText, shortBody } from "./redact";
|
|
6
|
+
import {
|
|
7
|
+
failure,
|
|
8
|
+
success,
|
|
9
|
+
type Forge,
|
|
10
|
+
type ForgeCheck,
|
|
11
|
+
type ForgePrStatus,
|
|
12
|
+
type ForgeResult,
|
|
13
|
+
type ForgeThread,
|
|
14
|
+
type PrState,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
/** Page cap for jobs and discussions (100 per page). */
|
|
18
|
+
export const GITLAB_MAX_PAGES = 10;
|
|
19
|
+
const PER_PAGE = 100;
|
|
20
|
+
|
|
21
|
+
type GlUser = { username?: string; bot?: boolean } | null;
|
|
22
|
+
|
|
23
|
+
type GlMr = {
|
|
24
|
+
iid: number;
|
|
25
|
+
web_url: string;
|
|
26
|
+
state: string;
|
|
27
|
+
draft?: boolean;
|
|
28
|
+
work_in_progress?: boolean;
|
|
29
|
+
detailed_merge_status?: string;
|
|
30
|
+
has_conflicts?: boolean;
|
|
31
|
+
target_branch: string;
|
|
32
|
+
source_branch: string;
|
|
33
|
+
sha: string;
|
|
34
|
+
diff_refs?: { base_sha?: string | null } | null;
|
|
35
|
+
source_project_id?: number;
|
|
36
|
+
head_pipeline?: {
|
|
37
|
+
id: number;
|
|
38
|
+
sha: string;
|
|
39
|
+
/** Merged-results / merge-train pipelines run on a merge ref; this is the MR head. */
|
|
40
|
+
source_sha?: string | null;
|
|
41
|
+
/** `refs/merge-requests/<iid>/merge` (merged results) or `/train` (merge train). */
|
|
42
|
+
ref?: string;
|
|
43
|
+
project_id: number;
|
|
44
|
+
status: string;
|
|
45
|
+
web_url?: string;
|
|
46
|
+
} | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type GlBridge = {
|
|
50
|
+
id: number;
|
|
51
|
+
name: string;
|
|
52
|
+
stage: string;
|
|
53
|
+
status: string;
|
|
54
|
+
allow_failure?: boolean;
|
|
55
|
+
web_url?: string;
|
|
56
|
+
downstream_pipeline?: {
|
|
57
|
+
id: number;
|
|
58
|
+
status: string;
|
|
59
|
+
project_id?: number;
|
|
60
|
+
web_url?: string;
|
|
61
|
+
} | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type GlJob = {
|
|
65
|
+
id: number;
|
|
66
|
+
name: string;
|
|
67
|
+
stage: string;
|
|
68
|
+
status: string;
|
|
69
|
+
allow_failure?: boolean;
|
|
70
|
+
web_url?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type GlNote = {
|
|
74
|
+
id: number;
|
|
75
|
+
body: string;
|
|
76
|
+
author: GlUser;
|
|
77
|
+
system?: boolean;
|
|
78
|
+
resolvable?: boolean;
|
|
79
|
+
resolved?: boolean;
|
|
80
|
+
position?: {
|
|
81
|
+
new_path?: string | null;
|
|
82
|
+
new_line?: number | null;
|
|
83
|
+
old_path?: string | null;
|
|
84
|
+
} | null;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type GlDiscussion = { id: string; notes: GlNote[] };
|
|
88
|
+
|
|
89
|
+
const mrState = (value: string): PrState =>
|
|
90
|
+
value === "merged" ? "merged" : value === "opened" ? "open" : "closed";
|
|
91
|
+
|
|
92
|
+
const PENDING = new Set([
|
|
93
|
+
"created",
|
|
94
|
+
"pending",
|
|
95
|
+
"running",
|
|
96
|
+
"waiting_for_resource",
|
|
97
|
+
"waiting_for_callback",
|
|
98
|
+
"preparing",
|
|
99
|
+
"scheduled",
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
const stateOf = (status: string, allowFailure: boolean): ForgeCheck["state"] => {
|
|
103
|
+
const failed = status === "failed" || status === "canceled";
|
|
104
|
+
return PENDING.has(status)
|
|
105
|
+
? "pending"
|
|
106
|
+
: failed
|
|
107
|
+
? allowFailure
|
|
108
|
+
? "passing"
|
|
109
|
+
: "failing"
|
|
110
|
+
: status === "success"
|
|
111
|
+
? "passing"
|
|
112
|
+
: "skipped";
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** A trigger/bridge job: its state is the downstream pipeline's. */
|
|
116
|
+
export function checkFromBridge(bridge: GlBridge, pipelineId: number, scope: string): ForgeCheck {
|
|
117
|
+
const status = bridge.downstream_pipeline?.status ?? bridge.status;
|
|
118
|
+
const allow = bridge.allow_failure === true;
|
|
119
|
+
const failed = status === "failed" || status === "canceled";
|
|
120
|
+
return {
|
|
121
|
+
name: `${bridge.stage} / ${bridge.name}`,
|
|
122
|
+
context: bridge.name,
|
|
123
|
+
state: stateOf(status, allow),
|
|
124
|
+
conclusion: failed && allow ? "allowed_failure" : `downstream_${status}`,
|
|
125
|
+
url: safeUrl(bridge.downstream_pipeline?.web_url ?? bridge.web_url ?? null),
|
|
126
|
+
runId: pipelineId,
|
|
127
|
+
// Not a log-bearing job; rerun it on the downstream pipeline.
|
|
128
|
+
jobId: null,
|
|
129
|
+
scope,
|
|
130
|
+
required: !allow,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function checkFromJob(job: GlJob, pipelineId: number, scope: string): ForgeCheck {
|
|
135
|
+
const failed = job.status === "failed" || job.status === "canceled";
|
|
136
|
+
return {
|
|
137
|
+
name: `${job.stage} / ${job.name}`,
|
|
138
|
+
context: job.name,
|
|
139
|
+
state: stateOf(job.status, job.allow_failure === true),
|
|
140
|
+
conclusion: failed && job.allow_failure ? "allowed_failure" : job.status,
|
|
141
|
+
url: safeUrl(job.web_url ?? null),
|
|
142
|
+
runId: pipelineId,
|
|
143
|
+
jobId: job.id,
|
|
144
|
+
scope,
|
|
145
|
+
// Every job that may not fail gates the pipeline, which gates the MR.
|
|
146
|
+
required: job.allow_failure !== true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A pipeline-level check when no job explains the pipeline's state. */
|
|
151
|
+
const pipelineCheck = (
|
|
152
|
+
state: ForgeCheck["state"],
|
|
153
|
+
conclusion: string,
|
|
154
|
+
pipeline: { id: number; project_id: number; web_url?: string } | null,
|
|
155
|
+
): ForgeCheck => ({
|
|
156
|
+
name: "pipeline",
|
|
157
|
+
context: "pipeline",
|
|
158
|
+
state,
|
|
159
|
+
conclusion,
|
|
160
|
+
url: safeUrl(pipeline?.web_url ?? null),
|
|
161
|
+
runId: pipeline?.id ?? null,
|
|
162
|
+
jobId: null,
|
|
163
|
+
scope: pipeline ? String(pipeline.project_id) : null,
|
|
164
|
+
required: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const safeUrl = (value: string | null): string | null => (value ? redactText(value) : null);
|
|
168
|
+
|
|
169
|
+
const isBot = (author: GlUser): boolean =>
|
|
170
|
+
author?.bot === true || /(?:^project_\d+_bot|bot$|\[bot\]$)/iu.test(author?.username ?? "");
|
|
171
|
+
|
|
172
|
+
// Statuses where GitLab has not finished computing mergeability.
|
|
173
|
+
const UNSETTLED = new Set(["checking", "unchecked", "preparing", "approvals_syncing"]);
|
|
174
|
+
|
|
175
|
+
export function createGitLabForge(options: {
|
|
176
|
+
apiHost: string;
|
|
177
|
+
repo: string;
|
|
178
|
+
runner: ForgeRunner;
|
|
179
|
+
maxPages?: number;
|
|
180
|
+
}): Forge {
|
|
181
|
+
const { apiHost, repo, runner } = options;
|
|
182
|
+
const maxPages = options.maxPages ?? GITLAB_MAX_PAGES;
|
|
183
|
+
const project = `projects/${encodeURIComponent(repo)}`;
|
|
184
|
+
|
|
185
|
+
const api = (endpoint: string): string[] => ["api", endpoint];
|
|
186
|
+
|
|
187
|
+
/** Every page of a list endpoint, up to the cap. */
|
|
188
|
+
const pages = (
|
|
189
|
+
endpoint: string,
|
|
190
|
+
what: string,
|
|
191
|
+
): ForgeResult<{ items: unknown[]; truncated: boolean }> => {
|
|
192
|
+
const items: unknown[] = [];
|
|
193
|
+
const sep = endpoint.includes("?") ? "&" : "?";
|
|
194
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
195
|
+
const chunk = apiJson<unknown>(
|
|
196
|
+
runner,
|
|
197
|
+
"glab",
|
|
198
|
+
apiHost,
|
|
199
|
+
api(`${endpoint}${sep}per_page=${PER_PAGE}&page=${page}`),
|
|
200
|
+
what,
|
|
201
|
+
);
|
|
202
|
+
if (!chunk.ok) return chunk;
|
|
203
|
+
if (!Array.isArray(chunk.data)) return failure("failed", `${what}: expected a list`);
|
|
204
|
+
items.push(...chunk.data);
|
|
205
|
+
if (chunk.data.length < PER_PAGE) return success({ items, truncated: false });
|
|
206
|
+
}
|
|
207
|
+
return success({ items, truncated: true });
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** True when merge commit `sha` (in project `scope`) has `head` as a parent. */
|
|
211
|
+
const mergeCommitTests = (scope: number, sha: string, head: string): boolean => {
|
|
212
|
+
const commit = apiJson<{ parent_ids?: string[] }>(
|
|
213
|
+
runner,
|
|
214
|
+
"glab",
|
|
215
|
+
apiHost,
|
|
216
|
+
api(`projects/${scope}/repository/commits/${sha}`),
|
|
217
|
+
`commit ${sha.slice(0, 12)}`,
|
|
218
|
+
);
|
|
219
|
+
return (
|
|
220
|
+
commit.ok && Array.isArray(commit.data.parent_ids) && commit.data.parent_ids.includes(head)
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** The current tip of the target branch (diff_refs.base_sha is the merge base). */
|
|
225
|
+
const baseTip = (branch: string): string | null => {
|
|
226
|
+
const tip = apiJson<{ commit?: { id?: string } }>(
|
|
227
|
+
runner,
|
|
228
|
+
"glab",
|
|
229
|
+
apiHost,
|
|
230
|
+
api(`${project}/repository/branches/${encodeURIComponent(branch)}`),
|
|
231
|
+
`branch ${branch}`,
|
|
232
|
+
);
|
|
233
|
+
return tip.ok && typeof tip.data.commit?.id === "string" ? tip.data.commit.id : null;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
kind: "gitlab",
|
|
238
|
+
apiHost,
|
|
239
|
+
repo,
|
|
240
|
+
|
|
241
|
+
identity(expected) {
|
|
242
|
+
const user = apiJson<{ username?: unknown }>(runner, "glab", apiHost, api("user"), "user");
|
|
243
|
+
// CI job tokens cannot read /user; reads still work, so skip the check.
|
|
244
|
+
if (!user.ok && user.code === "failed" && /\b403\b|forbidden/iu.test(user.error))
|
|
245
|
+
return success({
|
|
246
|
+
login: null,
|
|
247
|
+
expected,
|
|
248
|
+
matches: null,
|
|
249
|
+
note: "the credential cannot read /user (job or project token); identity not checked",
|
|
250
|
+
});
|
|
251
|
+
if (!user.ok) return user;
|
|
252
|
+
const login = typeof user.data.username === "string" ? user.data.username : "";
|
|
253
|
+
if (!login) return failure("failed", "glab api user returned no username");
|
|
254
|
+
return success({
|
|
255
|
+
login,
|
|
256
|
+
expected,
|
|
257
|
+
matches: expected ? login.toLowerCase() === expected.toLowerCase() : null,
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
repoInfo(target) {
|
|
262
|
+
const info = apiJson<{ id?: number; forked_from_project?: { path_with_namespace?: string } }>(
|
|
263
|
+
runner,
|
|
264
|
+
"glab",
|
|
265
|
+
apiHost,
|
|
266
|
+
api(`projects/${encodeURIComponent(target)}`),
|
|
267
|
+
`project ${target}`,
|
|
268
|
+
);
|
|
269
|
+
if (!info.ok) return info;
|
|
270
|
+
return success({
|
|
271
|
+
id: typeof info.data.id === "number" ? info.data.id : null,
|
|
272
|
+
parent: info.data.forked_from_project?.path_with_namespace ?? null,
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
findPr(head, match) {
|
|
277
|
+
const list = apiJson<GlMr[]>(
|
|
278
|
+
runner,
|
|
279
|
+
"glab",
|
|
280
|
+
apiHost,
|
|
281
|
+
api(
|
|
282
|
+
`${project}/merge_requests?source_branch=${encodeURIComponent(head)}&order_by=created_at&sort=desc&per_page=20`,
|
|
283
|
+
),
|
|
284
|
+
`merge requests for ${head}`,
|
|
285
|
+
);
|
|
286
|
+
if (!list.ok) return list;
|
|
287
|
+
if (!Array.isArray(list.data)) return failure("failed", "merge request list is not a list");
|
|
288
|
+
// Same-named branches in other forks are not ours: match the source
|
|
289
|
+
// project or the exact head sha.
|
|
290
|
+
const matches = list.data.filter(
|
|
291
|
+
(mr) =>
|
|
292
|
+
mr.source_branch === head &&
|
|
293
|
+
((match.projectId !== null && mr.source_project_id === match.projectId) ||
|
|
294
|
+
(match.sha !== null && mr.sha === match.sha)),
|
|
295
|
+
);
|
|
296
|
+
const pick = matches.find((mr) => mr.state === "opened") ?? matches[0];
|
|
297
|
+
return success(
|
|
298
|
+
pick
|
|
299
|
+
? {
|
|
300
|
+
number: pick.iid,
|
|
301
|
+
url: pick.web_url,
|
|
302
|
+
state: mrState(pick.state),
|
|
303
|
+
headBranch: pick.source_branch,
|
|
304
|
+
headSha: pick.sha ?? null,
|
|
305
|
+
}
|
|
306
|
+
: null,
|
|
307
|
+
);
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
prStatus(iid) {
|
|
311
|
+
const mr = apiJson<GlMr>(
|
|
312
|
+
runner,
|
|
313
|
+
"glab",
|
|
314
|
+
apiHost,
|
|
315
|
+
api(`${project}/merge_requests/${iid}`),
|
|
316
|
+
`merge request !${iid}`,
|
|
317
|
+
);
|
|
318
|
+
if (!mr.ok) return mr;
|
|
319
|
+
const value = mr.data;
|
|
320
|
+
let truncated = false;
|
|
321
|
+
let checks: ForgeCheck[] = [];
|
|
322
|
+
const pipeline = value.head_pipeline ?? null;
|
|
323
|
+
const open = value.state === "opened";
|
|
324
|
+
// Merged-results and merge-train pipelines run on a merge ref whose sha
|
|
325
|
+
// is a merge commit, not the MR head. They test the current head when
|
|
326
|
+
// source_sha says so or, when the response has no source_sha, when the
|
|
327
|
+
// merge commit's parents include the head (GET …/repository/commits/:sha).
|
|
328
|
+
const current =
|
|
329
|
+
pipeline !== null &&
|
|
330
|
+
(pipeline.sha === value.sha ||
|
|
331
|
+
pipeline.source_sha === value.sha ||
|
|
332
|
+
(!pipeline.source_sha &&
|
|
333
|
+
new RegExp(`^refs/merge-requests/${value.iid}/(?:merge|train)$`, "u").test(
|
|
334
|
+
pipeline.ref ?? "",
|
|
335
|
+
) &&
|
|
336
|
+
mergeCommitTests(pipeline.project_id, pipeline.sha, value.sha)));
|
|
337
|
+
if (pipeline && current) {
|
|
338
|
+
const scope = String(pipeline.project_id);
|
|
339
|
+
const jobs = pages(
|
|
340
|
+
`projects/${scope}/pipelines/${pipeline.id}/jobs`,
|
|
341
|
+
`pipeline ${pipeline.id} jobs`,
|
|
342
|
+
);
|
|
343
|
+
if (!jobs.ok) return jobs;
|
|
344
|
+
const bridges = pages(
|
|
345
|
+
`projects/${scope}/pipelines/${pipeline.id}/bridges`,
|
|
346
|
+
`pipeline ${pipeline.id} bridges`,
|
|
347
|
+
);
|
|
348
|
+
if (!bridges.ok) return bridges;
|
|
349
|
+
truncated ||= jobs.data.truncated || bridges.data.truncated;
|
|
350
|
+
checks = [
|
|
351
|
+
...(jobs.data.items as GlJob[]).map((job) => checkFromJob(job, pipeline.id, scope)),
|
|
352
|
+
...(bridges.data.items as GlBridge[]).map((bridge) =>
|
|
353
|
+
checkFromBridge(bridge, pipeline.id, scope),
|
|
354
|
+
),
|
|
355
|
+
];
|
|
356
|
+
// The pipeline status is a floor: a failed pipeline is never green,
|
|
357
|
+
// and a running one is never done, whatever the visible jobs say.
|
|
358
|
+
const pipelineState = stateOf(pipeline.status, false);
|
|
359
|
+
if (pipelineState === "failing" && !checks.some((check) => check.state === "failing"))
|
|
360
|
+
checks.push(pipelineCheck("failing", pipeline.status, pipeline));
|
|
361
|
+
if (pipelineState === "pending" && !checks.some((check) => check.state === "pending"))
|
|
362
|
+
checks.push(pipelineCheck("pending", pipeline.status, pipeline));
|
|
363
|
+
} else if (open && pipeline) {
|
|
364
|
+
// The head pipeline belongs to an older head: CI for this head has
|
|
365
|
+
// not reported yet. Pending, never "no checks".
|
|
366
|
+
checks = [pipelineCheck("pending", "awaiting_pipeline_for_head", null)];
|
|
367
|
+
}
|
|
368
|
+
const discussions = pages(
|
|
369
|
+
`${project}/merge_requests/${iid}/discussions`,
|
|
370
|
+
`merge request !${iid} discussions`,
|
|
371
|
+
);
|
|
372
|
+
if (!discussions.ok) return discussions;
|
|
373
|
+
truncated ||= discussions.data.truncated;
|
|
374
|
+
const threads: ForgeThread[] = (discussions.data.items as GlDiscussion[])
|
|
375
|
+
.filter((discussion) =>
|
|
376
|
+
discussion.notes.some((note) => note.resolvable === true && note.resolved !== true),
|
|
377
|
+
)
|
|
378
|
+
.map((discussion) => {
|
|
379
|
+
const note = discussion.notes[0];
|
|
380
|
+
return {
|
|
381
|
+
id: discussion.id,
|
|
382
|
+
path: note.position?.new_path ?? note.position?.old_path ?? null,
|
|
383
|
+
line: note.position?.new_line ?? null,
|
|
384
|
+
author: note.author?.username ?? null,
|
|
385
|
+
isBot: isBot(note.author),
|
|
386
|
+
outdated: false,
|
|
387
|
+
url: safeUrl(`${value.web_url}#note_${note.id}`),
|
|
388
|
+
body: shortBody(note.body ?? ""),
|
|
389
|
+
};
|
|
390
|
+
});
|
|
391
|
+
const detailed = value.detailed_merge_status ?? "unchecked";
|
|
392
|
+
const conflicts = value.has_conflicts === true || detailed === "conflict";
|
|
393
|
+
const status: ForgePrStatus = {
|
|
394
|
+
number: value.iid,
|
|
395
|
+
url: value.web_url,
|
|
396
|
+
state: mrState(value.state),
|
|
397
|
+
draft: value.draft ?? value.work_in_progress ?? false,
|
|
398
|
+
base: value.target_branch,
|
|
399
|
+
baseSha: open ? baseTip(value.target_branch) : null,
|
|
400
|
+
head: { branch: value.source_branch, sha: value.sha },
|
|
401
|
+
mergeable: conflicts ? "no" : UNSETTLED.has(detailed) ? "unknown" : "yes",
|
|
402
|
+
conflicts,
|
|
403
|
+
rebaseRequired: detailed === "need_rebase",
|
|
404
|
+
reviewDecision:
|
|
405
|
+
detailed === "not_approved"
|
|
406
|
+
? "review_required"
|
|
407
|
+
: detailed === "requested_changes"
|
|
408
|
+
? "changes_requested"
|
|
409
|
+
: null,
|
|
410
|
+
mergeState: detailed,
|
|
411
|
+
inMergeQueue: false,
|
|
412
|
+
checks,
|
|
413
|
+
// GitLab gates on the whole pipeline (jobs that may not fail).
|
|
414
|
+
requiredContexts: [],
|
|
415
|
+
threads,
|
|
416
|
+
truncated,
|
|
417
|
+
};
|
|
418
|
+
return success(status);
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
jobLogTail(job, lines) {
|
|
422
|
+
const scope = job.scope ?? encodeURIComponent(repo);
|
|
423
|
+
const text = apiText(
|
|
424
|
+
runner,
|
|
425
|
+
"glab",
|
|
426
|
+
apiHost,
|
|
427
|
+
["api", `projects/${scope}/jobs/${job.jobId}/trace`],
|
|
428
|
+
`job ${job.jobId} trace`,
|
|
429
|
+
FORGE_TIMEOUTS.log,
|
|
430
|
+
);
|
|
431
|
+
return text.ok ? success(logTail(text.data, lines)) : text;
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
rerun(target) {
|
|
435
|
+
const scope = target.scope ?? encodeURIComponent(repo);
|
|
436
|
+
const endpoint =
|
|
437
|
+
target.kind === "failed_in_run"
|
|
438
|
+
? `projects/${scope}/pipelines/${target.runId}/retry`
|
|
439
|
+
: `projects/${scope}/jobs/${target.jobId}/retry`;
|
|
440
|
+
const what =
|
|
441
|
+
target.kind === "failed_in_run"
|
|
442
|
+
? `retry of pipeline ${target.runId}`
|
|
443
|
+
: `retry of job ${target.jobId}`;
|
|
444
|
+
const run = apiText(
|
|
445
|
+
runner,
|
|
446
|
+
"glab",
|
|
447
|
+
apiHost,
|
|
448
|
+
["api", "-X", "POST", endpoint],
|
|
449
|
+
what,
|
|
450
|
+
FORGE_TIMEOUTS.api,
|
|
451
|
+
);
|
|
452
|
+
return run.ok ? success(undefined) : run;
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
createPr(input) {
|
|
456
|
+
// An MR from a fork is created in the source project and targets ours.
|
|
457
|
+
const fromFork = input.headRepo !== repo;
|
|
458
|
+
let source = project;
|
|
459
|
+
const extra: string[] = [];
|
|
460
|
+
if (fromFork) {
|
|
461
|
+
const target = apiJson<{ id?: number }>(
|
|
462
|
+
runner,
|
|
463
|
+
"glab",
|
|
464
|
+
apiHost,
|
|
465
|
+
api(project),
|
|
466
|
+
`project ${repo}`,
|
|
467
|
+
);
|
|
468
|
+
if (!target.ok) return target;
|
|
469
|
+
if (input.headProjectId === null || typeof target.data.id !== "number")
|
|
470
|
+
return failure(
|
|
471
|
+
"failed",
|
|
472
|
+
`cannot resolve the project ids for an MR from ${input.headRepo}`,
|
|
473
|
+
);
|
|
474
|
+
source = `projects/${input.headProjectId}`;
|
|
475
|
+
extra.push("-F", `target_project_id=${target.data.id}`);
|
|
476
|
+
}
|
|
477
|
+
const title =
|
|
478
|
+
input.draft && !/^draft:/iu.test(input.title) ? `Draft: ${input.title}` : input.title;
|
|
479
|
+
const created = apiWrite<GlMr>(
|
|
480
|
+
runner,
|
|
481
|
+
"glab",
|
|
482
|
+
apiHost,
|
|
483
|
+
[
|
|
484
|
+
"api",
|
|
485
|
+
"-X",
|
|
486
|
+
"POST",
|
|
487
|
+
`${source}/merge_requests`,
|
|
488
|
+
"-f",
|
|
489
|
+
`source_branch=${input.head}`,
|
|
490
|
+
"-f",
|
|
491
|
+
`target_branch=${input.base}`,
|
|
492
|
+
"-f",
|
|
493
|
+
`title=${title}`,
|
|
494
|
+
"-f",
|
|
495
|
+
`description=${input.body}`,
|
|
496
|
+
...extra,
|
|
497
|
+
],
|
|
498
|
+
`merge request for ${input.head}`,
|
|
499
|
+
);
|
|
500
|
+
if (!created.ok) return created;
|
|
501
|
+
const value = created.data;
|
|
502
|
+
if (typeof value.iid !== "number" || typeof value.web_url !== "string")
|
|
503
|
+
return failure("failed", "glab api merge_requests returned no merge request iid");
|
|
504
|
+
return success({
|
|
505
|
+
number: value.iid,
|
|
506
|
+
url: value.web_url,
|
|
507
|
+
state: mrState(value.state),
|
|
508
|
+
headBranch: value.source_branch ?? input.head,
|
|
509
|
+
headSha: value.sha ?? null,
|
|
510
|
+
});
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
merge(iid, mergeOptions) {
|
|
514
|
+
// GitLab's merge method is a project setting; squash is per request.
|
|
515
|
+
if (mergeOptions.method === "rebase")
|
|
516
|
+
return failure(
|
|
517
|
+
"invalid_input",
|
|
518
|
+
"GitLab merges with the project's merge method; pass --method squash or merge",
|
|
519
|
+
);
|
|
520
|
+
const merged = apiWrite<
|
|
521
|
+
GlMr & { merge_commit_sha?: string | null; squash_commit_sha?: string | null }
|
|
522
|
+
>(
|
|
523
|
+
runner,
|
|
524
|
+
"glab",
|
|
525
|
+
apiHost,
|
|
526
|
+
[
|
|
527
|
+
"api",
|
|
528
|
+
"-X",
|
|
529
|
+
"PUT",
|
|
530
|
+
`${project}/merge_requests/${iid}/merge`,
|
|
531
|
+
"-f",
|
|
532
|
+
`sha=${mergeOptions.sha}`,
|
|
533
|
+
"-F",
|
|
534
|
+
`squash=${mergeOptions.method === "squash" ? "true" : "false"}`,
|
|
535
|
+
],
|
|
536
|
+
`merge of merge request !${iid}`,
|
|
537
|
+
);
|
|
538
|
+
if (!merged.ok) return merged;
|
|
539
|
+
const value = merged.data;
|
|
540
|
+
return success({
|
|
541
|
+
mergeSha: value.merge_commit_sha ?? value.squash_commit_sha ?? null,
|
|
542
|
+
});
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
updateBase(iid, base) {
|
|
546
|
+
const updated = apiWrite<unknown>(
|
|
547
|
+
runner,
|
|
548
|
+
"glab",
|
|
549
|
+
apiHost,
|
|
550
|
+
["api", "-X", "PUT", `${project}/merge_requests/${iid}`, "-f", `target_branch=${base}`],
|
|
551
|
+
`retarget of merge request !${iid}`,
|
|
552
|
+
);
|
|
553
|
+
return updated.ok ? success(undefined) : updated;
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// PR/MR body issue linking (port of scripts/pr-create.sh --build-body), shared
|
|
2
|
+
// by the managed `hosting.pull_request` action (core/pr-create.ts re-exports
|
|
3
|
+
// these until S16) and `workit pr create` (S11), which passes typed input
|
|
4
|
+
// instead of environment variables.
|
|
5
|
+
import { vcsConfig } from "../core/vcs-config";
|
|
6
|
+
|
|
7
|
+
export function parseGhRepo(remote: string): string | null {
|
|
8
|
+
remote = (remote || "").trim().replace(/\/+$/, "");
|
|
9
|
+
if (!remote) return null;
|
|
10
|
+
if (remote.endsWith(".git")) remote = remote.slice(0, -4);
|
|
11
|
+
if (remote.includes(":")) remote = remote.split(":").pop() ?? ""; // drop git@host part (scp-style URL)
|
|
12
|
+
const parts = remote.split("/").filter(Boolean);
|
|
13
|
+
return parts.length >= 2 ? parts.slice(-2).join("/") : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseGhIssue(value: string): string {
|
|
17
|
+
const m = /issues\/(\d+)/.exec(value);
|
|
18
|
+
return m ? m[1] : String(value).trim().replace(/^#/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// RL-03/CA-25/AR-08: a branch-derived numeric issue id must be a bare number at
|
|
22
|
+
// a segment or dash boundary — and never part of a date segment. Year-first
|
|
23
|
+
// (feature/2024-01-15/x) and day-first (feature/15-01-2024/x) dates are both
|
|
24
|
+
// skipped — a complete date anywhere in a segment (release-2024-01-15,
|
|
25
|
+
// v2-2024-01-15-fix) — so no date digit ever closes an issue. Deliberate
|
|
26
|
+
// numeric issue branches (feature/42-title, feature/2024-fix) keep linking.
|
|
27
|
+
function deriveGhIssueFromBranch(branch: string): string {
|
|
28
|
+
for (const segment of branch.split("/")) {
|
|
29
|
+
if (/^\d{4}-\d/.test(segment)) continue; // year-first date-like segment (incl. year-month)
|
|
30
|
+
if (/\d{4}-\d{1,2}-\d{1,2}/.test(segment)) continue; // complete year-first date anywhere
|
|
31
|
+
if (/\d{1,2}-\d{1,2}-\d{4}/.test(segment)) continue; // complete day-first date anywhere
|
|
32
|
+
const m = /(?:^|-)(\d+)(?:-|$)/.exec(segment);
|
|
33
|
+
if (m) return m[1];
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildBody(
|
|
39
|
+
body: string,
|
|
40
|
+
branch: string,
|
|
41
|
+
linkIssues: boolean,
|
|
42
|
+
baseUrl: string,
|
|
43
|
+
ytIssue: string,
|
|
44
|
+
ghLinkOnPr: boolean,
|
|
45
|
+
ghIssue: string,
|
|
46
|
+
ghRelation: string,
|
|
47
|
+
ghRepo: string | null,
|
|
48
|
+
): string {
|
|
49
|
+
let line: string | null = null;
|
|
50
|
+
if (linkIssues) {
|
|
51
|
+
let issue = ytIssue;
|
|
52
|
+
if (!issue && branch) {
|
|
53
|
+
// anchored prefix + \b boundary, 3+ digits so version-like tokens (POSTGRES-16, HTTP-3) never link
|
|
54
|
+
const m = /(?:^|\/|-)([A-Z]{2,}-\d{3,})\b/.exec(branch);
|
|
55
|
+
if (m) issue = m[1];
|
|
56
|
+
}
|
|
57
|
+
if (issue && baseUrl) line = `Related to: ${baseUrl.replace(/\/+$/, "")}/issue/${issue}`;
|
|
58
|
+
} else if (ghLinkOnPr) {
|
|
59
|
+
let issue = parseGhIssue(ghIssue);
|
|
60
|
+
if (!issue && branch) {
|
|
61
|
+
// pure-number issue id (feature/42-title -> 42); digits must be followed by a dash or end-of-string
|
|
62
|
+
// so version tokens (release/1.2.3, backport/8.0.1, lodash-4.17.21, 2024.1) never link
|
|
63
|
+
issue = deriveGhIssueFromBranch(branch);
|
|
64
|
+
}
|
|
65
|
+
if (issue) {
|
|
66
|
+
if (ghRelation === "related") {
|
|
67
|
+
line = `Related to #${issue}`;
|
|
68
|
+
if (ghRepo) line += ` — https://github.com/${ghRepo}/issues/${issue}`;
|
|
69
|
+
} else {
|
|
70
|
+
line = `Closes #${issue}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (line === null) return body;
|
|
75
|
+
return body ? `${body}\n\n${line}` : line;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The body `workit pr create` sends: the agent's text plus the workspace's
|
|
80
|
+
* issue link (YouTrack `link_issues`, or GitHub `issues.link_on_pr` with the
|
|
81
|
+
* issue number derived from the branch). Reads config, never writes it.
|
|
82
|
+
*/
|
|
83
|
+
export function prBodyFor(
|
|
84
|
+
cwd: string,
|
|
85
|
+
input: { body: string; branch: string; repo: string | null },
|
|
86
|
+
): string {
|
|
87
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
88
|
+
if (resolved.ok === false) return input.body;
|
|
89
|
+
return buildBody(
|
|
90
|
+
input.body,
|
|
91
|
+
input.branch,
|
|
92
|
+
resolved.link_issues === true,
|
|
93
|
+
typeof resolved.youtrack_base_url === "string" ? resolved.youtrack_base_url : "",
|
|
94
|
+
"",
|
|
95
|
+
resolved.link_on_pr === true,
|
|
96
|
+
"",
|
|
97
|
+
"closes",
|
|
98
|
+
input.repo,
|
|
99
|
+
);
|
|
100
|
+
}
|