@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,618 @@
|
|
|
1
|
+
// `workit pr status` / `ci wait` / `ci rerun` logic (design §2.1, S10): one
|
|
2
|
+
// forge-neutral status document, the `next` priority, the CI-wait verdict,
|
|
3
|
+
// and rerun planning with the rerun-once rule.
|
|
4
|
+
import { aheadBehind, currentBranch, fetchRefs, hasCommit, headSha, resolveRef } from "../git/rev";
|
|
5
|
+
import type { ResolvedForge } from "./resolve";
|
|
6
|
+
import { rerunCounts, recordReruns, withRerunLock, type RerunRow } from "./reruns";
|
|
7
|
+
import {
|
|
8
|
+
failure,
|
|
9
|
+
success,
|
|
10
|
+
type ForgeCheck,
|
|
11
|
+
type ForgePrStatus,
|
|
12
|
+
type ForgeResult,
|
|
13
|
+
type ForgeThread,
|
|
14
|
+
type PrState,
|
|
15
|
+
type RerunTarget,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What to do next, in priority order: conflicts > required rebase > threads
|
|
20
|
+
* > CI > review > draft > merge queue > other merge blockers > READY.
|
|
21
|
+
*/
|
|
22
|
+
export type NextAction =
|
|
23
|
+
| "MERGED"
|
|
24
|
+
| "CLOSED"
|
|
25
|
+
| "RESOLVE_CONFLICTS"
|
|
26
|
+
| "REBASE"
|
|
27
|
+
| "RESOLVE_THREADS"
|
|
28
|
+
| "FIX_CI"
|
|
29
|
+
| "WAITING_CI"
|
|
30
|
+
| "ADDRESS_REVIEW"
|
|
31
|
+
| "REVIEW"
|
|
32
|
+
| "MARK_READY"
|
|
33
|
+
| "IN_MERGE_QUEUE"
|
|
34
|
+
| "NOT_MERGEABLE"
|
|
35
|
+
| "READY";
|
|
36
|
+
|
|
37
|
+
export type FailingCheck = {
|
|
38
|
+
name: string;
|
|
39
|
+
url: string | null;
|
|
40
|
+
runId: number | null;
|
|
41
|
+
jobId: number | null;
|
|
42
|
+
conclusion: string | null;
|
|
43
|
+
/** Required by branch protection; an optional failure does not block the merge. */
|
|
44
|
+
required: boolean | null;
|
|
45
|
+
logTail: string[];
|
|
46
|
+
logError?: string;
|
|
47
|
+
rerunsOnHead: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type BehindBase = {
|
|
51
|
+
behind: number | null;
|
|
52
|
+
ahead: number | null;
|
|
53
|
+
baseSha: string | null;
|
|
54
|
+
upToDate: boolean | null;
|
|
55
|
+
/** Why the counts are null (fetch failed, commit not available). */
|
|
56
|
+
error?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type ChecksState = "failing" | "pending" | "passing" | "none";
|
|
60
|
+
|
|
61
|
+
export type PrStatusDoc = {
|
|
62
|
+
forge: "github" | "gitlab";
|
|
63
|
+
repo: string;
|
|
64
|
+
/** The push repository when it is a fork of `repo`. */
|
|
65
|
+
headRepo: string | null;
|
|
66
|
+
number: number;
|
|
67
|
+
url: string;
|
|
68
|
+
state: PrState;
|
|
69
|
+
draft: boolean;
|
|
70
|
+
base: string;
|
|
71
|
+
head: { branch: string; sha: string; localSha: string | null; pushed: boolean | null };
|
|
72
|
+
mergeable: "yes" | "no" | "unknown";
|
|
73
|
+
mergeState: string | null;
|
|
74
|
+
conflicts: boolean;
|
|
75
|
+
rebaseRequired: boolean;
|
|
76
|
+
inMergeQueue: boolean;
|
|
77
|
+
behindBase: BehindBase | null;
|
|
78
|
+
checks: {
|
|
79
|
+
/** Over the checks that gate the merge (required ones when protection names them). */
|
|
80
|
+
state: ChecksState;
|
|
81
|
+
failing: FailingCheck[];
|
|
82
|
+
pending: string[];
|
|
83
|
+
passing: number;
|
|
84
|
+
/** Required contexts that have not reported on this head; null when unknown. */
|
|
85
|
+
missingRequired: string[] | null;
|
|
86
|
+
};
|
|
87
|
+
reviews: { decision: string | null; unresolvedThreads: ForgeThread[] };
|
|
88
|
+
/** Every reason the PR cannot merge right now (empty when READY). */
|
|
89
|
+
blockers: string[];
|
|
90
|
+
identity: { login: string | null; credential: string; note?: string } | null;
|
|
91
|
+
truncated: boolean;
|
|
92
|
+
next: NextAction;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Failing checks whose log tail is fetched; the rest list without a tail. */
|
|
96
|
+
export const MAX_LOG_TAILS = 5;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The checks that gate the merge: the required ones when branch protection
|
|
100
|
+
* names any; otherwise every check (an unprotected or unreadable branch is
|
|
101
|
+
* treated conservatively).
|
|
102
|
+
*/
|
|
103
|
+
export function gatingChecks(status: ForgePrStatus): {
|
|
104
|
+
checks: ForgeCheck[];
|
|
105
|
+
/** True when branch protection decides which checks gate. */
|
|
106
|
+
protected: boolean;
|
|
107
|
+
} {
|
|
108
|
+
const required = status.checks.filter((check) => check.required === true);
|
|
109
|
+
const protectionNamesChecks = (status.requiredContexts?.length ?? 0) > 0;
|
|
110
|
+
return required.length > 0 || protectionNamesChecks
|
|
111
|
+
? { checks: required, protected: true }
|
|
112
|
+
: { checks: status.checks, protected: false };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function missingRequired(status: ForgePrStatus): string[] | null {
|
|
116
|
+
if (status.requiredContexts === null) return null;
|
|
117
|
+
const seen = new Set(status.checks.map((check) => check.context));
|
|
118
|
+
return status.requiredContexts.filter((context) => !seen.has(context));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function checksState(
|
|
122
|
+
checks: readonly ForgeCheck[],
|
|
123
|
+
missing: readonly string[] | null = [],
|
|
124
|
+
): ChecksState {
|
|
125
|
+
if (checks.some((check) => check.state === "failing")) return "failing";
|
|
126
|
+
if (checks.some((check) => check.state === "pending") || (missing?.length ?? 0) > 0)
|
|
127
|
+
return "pending";
|
|
128
|
+
return checks.length > 0 ? "passing" : "none";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// GitLab detailed_merge_status values that mean "mergeable now".
|
|
132
|
+
const GITLAB_MERGEABLE = new Set(["mergeable"]);
|
|
133
|
+
// GitHub mergeStateStatus values that allow a merge (UNSTABLE: only
|
|
134
|
+
// non-required checks fail; HAS_HOOKS: pre-receive hooks will run).
|
|
135
|
+
const GITHUB_MERGEABLE = new Set(["clean", "unstable", "has_hooks"]);
|
|
136
|
+
|
|
137
|
+
export type NextInput = {
|
|
138
|
+
forge: "github" | "gitlab";
|
|
139
|
+
state: PrState;
|
|
140
|
+
draft: boolean;
|
|
141
|
+
conflicts: boolean;
|
|
142
|
+
rebaseRequired: boolean;
|
|
143
|
+
mergeable: "yes" | "no" | "unknown";
|
|
144
|
+
mergeState: string | null;
|
|
145
|
+
inMergeQueue: boolean;
|
|
146
|
+
reviewDecision: string | null;
|
|
147
|
+
threads: number;
|
|
148
|
+
checks: ChecksState;
|
|
149
|
+
/** Branch protection names the gating checks (else every check gates). */
|
|
150
|
+
checksRequired?: boolean;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** The ordered merge blockers; the first one is `next`. */
|
|
154
|
+
export function blockersOf(input: NextInput): Array<{ next: NextAction; reason: string }> {
|
|
155
|
+
if (input.state === "merged") return [{ next: "MERGED", reason: "merged" }];
|
|
156
|
+
if (input.state === "closed") return [{ next: "CLOSED", reason: "closed" }];
|
|
157
|
+
const out: Array<{ next: NextAction; reason: string }> = [];
|
|
158
|
+
const ms = input.mergeState ?? "";
|
|
159
|
+
if (input.conflicts) out.push({ next: "RESOLVE_CONFLICTS", reason: "conflicts" });
|
|
160
|
+
if (input.rebaseRequired) out.push({ next: "REBASE", reason: "behind_base_required" });
|
|
161
|
+
if (input.threads > 0 || ms === "discussions_not_resolved")
|
|
162
|
+
out.push({ next: "RESOLVE_THREADS", reason: "unresolved_threads" });
|
|
163
|
+
if (input.checks === "failing")
|
|
164
|
+
out.push({
|
|
165
|
+
next: "FIX_CI",
|
|
166
|
+
reason: input.checksRequired ? "required_checks_failing" : "checks_failing",
|
|
167
|
+
});
|
|
168
|
+
else if (
|
|
169
|
+
input.checks === "pending" ||
|
|
170
|
+
ms === "ci_still_running" ||
|
|
171
|
+
(ms === "ci_must_pass" && input.checks !== "passing")
|
|
172
|
+
)
|
|
173
|
+
out.push({ next: "WAITING_CI", reason: "checks_pending" });
|
|
174
|
+
if (input.reviewDecision === "changes_requested" || ms === "requested_changes")
|
|
175
|
+
out.push({ next: "ADDRESS_REVIEW", reason: "changes_requested" });
|
|
176
|
+
else if (input.reviewDecision === "review_required" || ms === "not_approved")
|
|
177
|
+
out.push({ next: "REVIEW", reason: "review_required" });
|
|
178
|
+
if (input.draft || ms === "draft" || ms === "draft_status")
|
|
179
|
+
out.push({ next: "MARK_READY", reason: "draft" });
|
|
180
|
+
if (input.inMergeQueue) out.push({ next: "IN_MERGE_QUEUE", reason: "in_merge_queue" });
|
|
181
|
+
if (out.length === 0) {
|
|
182
|
+
const allowed = input.forge === "github" ? GITHUB_MERGEABLE.has(ms) : GITLAB_MERGEABLE.has(ms);
|
|
183
|
+
if (input.mergeable === "unknown")
|
|
184
|
+
out.push({ next: "NOT_MERGEABLE", reason: "mergeability_unknown" });
|
|
185
|
+
else if (!allowed)
|
|
186
|
+
out.push({ next: "NOT_MERGEABLE", reason: `merge_state_${ms || "unknown"}` });
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function nextAction(input: NextInput): NextAction {
|
|
192
|
+
return blockersOf(input)[0]?.next ?? "READY";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Count head against the base tip the API reported, locally. A pure read:
|
|
197
|
+
* missing commits are fetched by id (no destination ref, no FETCH_HEAD), so
|
|
198
|
+
* no ref in the shared repository moves.
|
|
199
|
+
*/
|
|
200
|
+
export function computeBehindBase(
|
|
201
|
+
cwd: string,
|
|
202
|
+
remotes: { base: string; head: string },
|
|
203
|
+
baseSha: string | null,
|
|
204
|
+
headSha_: string,
|
|
205
|
+
options: { timeoutMs?: number } = {},
|
|
206
|
+
): ForgeResult<BehindBase> {
|
|
207
|
+
const empty = { behind: null, ahead: null, upToDate: null };
|
|
208
|
+
if (!baseSha)
|
|
209
|
+
return success({ ...empty, baseSha: null, error: "the forge reported no base tip" });
|
|
210
|
+
for (const [sha, remote] of [
|
|
211
|
+
[baseSha, remotes.base],
|
|
212
|
+
[headSha_, remotes.head],
|
|
213
|
+
] as const) {
|
|
214
|
+
if (hasCommit(cwd, sha)) continue;
|
|
215
|
+
const fetched = fetchRefs(cwd, remote, [sha], options);
|
|
216
|
+
if (!fetched.ok && remote !== remotes.base) fetchRefs(cwd, remotes.base, [sha], options);
|
|
217
|
+
if (!hasCommit(cwd, sha))
|
|
218
|
+
return success({
|
|
219
|
+
...empty,
|
|
220
|
+
baseSha,
|
|
221
|
+
error: fetched.ok ? `${sha.slice(0, 12)} is not available locally` : fetched.error,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const counts = aheadBehind(cwd, baseSha, headSha_);
|
|
225
|
+
return success(
|
|
226
|
+
counts ? { ...counts, baseSha } : { ...empty, baseSha, error: "ahead/behind did not resolve" },
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export type ReportOptions = {
|
|
231
|
+
pr?: number | null;
|
|
232
|
+
branch?: string | null;
|
|
233
|
+
logLines?: number;
|
|
234
|
+
/** Skip the behind-base count (ci wait polls). */
|
|
235
|
+
behind?: boolean;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** The PR/MR number for `--pr`, `--branch`, or the current branch. */
|
|
239
|
+
export function selectPr(
|
|
240
|
+
cwd: string,
|
|
241
|
+
resolved: ResolvedForge,
|
|
242
|
+
options: ReportOptions,
|
|
243
|
+
): ForgeResult<number> {
|
|
244
|
+
if (options.pr) return success(options.pr);
|
|
245
|
+
const branch = options.branch ?? currentBranch(cwd);
|
|
246
|
+
if (!branch)
|
|
247
|
+
return failure("invalid_input", "HEAD is detached; pass --pr <n> or --branch <name>");
|
|
248
|
+
const localSha = resolveRef(cwd, `refs/heads/${branch}`);
|
|
249
|
+
const found = resolved.forge.findPr(branch, {
|
|
250
|
+
owner: resolved.headRepo.split("/")[0] ?? null,
|
|
251
|
+
projectId: resolved.headProjectId,
|
|
252
|
+
sha: localSha,
|
|
253
|
+
});
|
|
254
|
+
if (!found.ok) return found;
|
|
255
|
+
if (!found.data) {
|
|
256
|
+
const noun = resolved.forge.kind === "github" ? "pull request" : "merge request";
|
|
257
|
+
return failure(
|
|
258
|
+
"not_found",
|
|
259
|
+
`no ${noun} for branch ${branch} from ${resolved.headRepo} in ${resolved.forge.repo}`,
|
|
260
|
+
"workit git push && workit pr create --fill # or pass --pr <n>",
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return success(found.data.number);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function buildStatusDoc(
|
|
267
|
+
cwd: string,
|
|
268
|
+
resolved: ResolvedForge,
|
|
269
|
+
status: ForgePrStatus,
|
|
270
|
+
options: {
|
|
271
|
+
logLines: number;
|
|
272
|
+
behind: boolean;
|
|
273
|
+
behindTimeoutMs?: number;
|
|
274
|
+
identity?: PrStatusDoc["identity"];
|
|
275
|
+
},
|
|
276
|
+
): ForgeResult<PrStatusDoc> {
|
|
277
|
+
const { forge } = resolved;
|
|
278
|
+
const reruns = rerunCounts(cwd, { repo: forge.repo, pr: status.number, head: status.head.sha });
|
|
279
|
+
const gating = gatingChecks(status);
|
|
280
|
+
const missing = missingRequired(status);
|
|
281
|
+
const failing = status.checks.filter((check) => check.state === "failing");
|
|
282
|
+
const failingDocs = failing.map((check, index): FailingCheck => {
|
|
283
|
+
const doc: FailingCheck = {
|
|
284
|
+
name: check.name,
|
|
285
|
+
url: check.url,
|
|
286
|
+
runId: check.runId,
|
|
287
|
+
jobId: check.jobId,
|
|
288
|
+
conclusion: check.conclusion,
|
|
289
|
+
required: check.required,
|
|
290
|
+
logTail: [],
|
|
291
|
+
rerunsOnHead: reruns.get(check.name) ?? 0,
|
|
292
|
+
};
|
|
293
|
+
if (check.jobId !== null && options.logLines > 0 && index < MAX_LOG_TAILS) {
|
|
294
|
+
const tail = forge.jobLogTail({ jobId: check.jobId, scope: check.scope }, options.logLines);
|
|
295
|
+
if (tail.ok) doc.logTail = tail.data;
|
|
296
|
+
else doc.logError = tail.error;
|
|
297
|
+
}
|
|
298
|
+
return doc;
|
|
299
|
+
});
|
|
300
|
+
const local =
|
|
301
|
+
currentBranch(cwd) === status.head.branch
|
|
302
|
+
? headSha(cwd)
|
|
303
|
+
: resolveRef(cwd, `refs/heads/${status.head.branch}`);
|
|
304
|
+
let behindBase: BehindBase | null = null;
|
|
305
|
+
if (options.behind && status.state === "open") {
|
|
306
|
+
const counted = computeBehindBase(
|
|
307
|
+
cwd,
|
|
308
|
+
{ base: resolved.baseRemote, head: resolved.remote },
|
|
309
|
+
status.baseSha,
|
|
310
|
+
status.head.sha,
|
|
311
|
+
options.behindTimeoutMs === undefined ? {} : { timeoutMs: options.behindTimeoutMs },
|
|
312
|
+
);
|
|
313
|
+
if (!counted.ok) return counted;
|
|
314
|
+
behindBase = counted.data;
|
|
315
|
+
}
|
|
316
|
+
const state = checksState(gating.checks, missing);
|
|
317
|
+
const blockers = blockersOf({
|
|
318
|
+
forge: forge.kind,
|
|
319
|
+
state: status.state,
|
|
320
|
+
draft: status.draft,
|
|
321
|
+
conflicts: status.conflicts,
|
|
322
|
+
rebaseRequired: status.rebaseRequired,
|
|
323
|
+
mergeable: status.mergeable,
|
|
324
|
+
mergeState: status.mergeState,
|
|
325
|
+
inMergeQueue: status.inMergeQueue,
|
|
326
|
+
reviewDecision: status.reviewDecision,
|
|
327
|
+
threads: status.threads.length,
|
|
328
|
+
checks: state,
|
|
329
|
+
checksRequired: gating.protected,
|
|
330
|
+
});
|
|
331
|
+
return success({
|
|
332
|
+
forge: forge.kind,
|
|
333
|
+
repo: forge.repo,
|
|
334
|
+
headRepo: resolved.fork ? resolved.headRepo : null,
|
|
335
|
+
number: status.number,
|
|
336
|
+
url: status.url,
|
|
337
|
+
state: status.state,
|
|
338
|
+
draft: status.draft,
|
|
339
|
+
base: status.base,
|
|
340
|
+
head: {
|
|
341
|
+
branch: status.head.branch,
|
|
342
|
+
sha: status.head.sha,
|
|
343
|
+
localSha: local,
|
|
344
|
+
pushed: local === null ? null : local === status.head.sha,
|
|
345
|
+
},
|
|
346
|
+
mergeable: status.mergeable,
|
|
347
|
+
mergeState: status.mergeState,
|
|
348
|
+
conflicts: status.conflicts,
|
|
349
|
+
rebaseRequired: status.rebaseRequired,
|
|
350
|
+
inMergeQueue: status.inMergeQueue,
|
|
351
|
+
behindBase,
|
|
352
|
+
checks: {
|
|
353
|
+
state,
|
|
354
|
+
failing: failingDocs,
|
|
355
|
+
pending: status.checks
|
|
356
|
+
.filter((check) => check.state === "pending")
|
|
357
|
+
.map((check) => check.name),
|
|
358
|
+
passing: status.checks.filter((check) => check.state === "passing").length,
|
|
359
|
+
missingRequired: missing,
|
|
360
|
+
},
|
|
361
|
+
reviews: { decision: status.reviewDecision, unresolvedThreads: status.threads },
|
|
362
|
+
blockers: status.state === "open" ? blockers.map((blocker) => blocker.reason) : [],
|
|
363
|
+
identity: options.identity ?? null,
|
|
364
|
+
truncated: status.truncated,
|
|
365
|
+
next: blockers[0]?.next ?? "READY",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Select, read and document one PR/MR. */
|
|
370
|
+
export function prStatusReport(
|
|
371
|
+
cwd: string,
|
|
372
|
+
resolved: ResolvedForge,
|
|
373
|
+
options: ReportOptions & { identity?: PrStatusDoc["identity"] } = {},
|
|
374
|
+
): ForgeResult<{ doc: PrStatusDoc; raw: ForgePrStatus }> {
|
|
375
|
+
const number = selectPr(cwd, resolved, options);
|
|
376
|
+
if (!number.ok) return number;
|
|
377
|
+
const status = resolved.forge.prStatus(number.data);
|
|
378
|
+
if (!status.ok) return status;
|
|
379
|
+
const doc = buildStatusDoc(cwd, resolved, status.data, {
|
|
380
|
+
logLines: options.logLines ?? 60,
|
|
381
|
+
behind: options.behind ?? true,
|
|
382
|
+
identity: options.identity,
|
|
383
|
+
});
|
|
384
|
+
if (!doc.ok) return doc;
|
|
385
|
+
return success({ doc: doc.data, raw: status.data });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// ci wait
|
|
390
|
+
|
|
391
|
+
export type WaitState = "ready" | "waiting" | "failed" | "blocked";
|
|
392
|
+
|
|
393
|
+
export type WaitVerdict = { state: WaitState; reason: string; unblock?: string };
|
|
394
|
+
|
|
395
|
+
/** Checks never appeared: after this grace a PR known to need none reads ready. */
|
|
396
|
+
export const NO_CHECKS_GRACE_MS = 90_000;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* CI verdict for the PR head. Only gating checks count; a closed or merged
|
|
400
|
+
* PR is never "ready". No checks at all is ready only after the grace and
|
|
401
|
+
* only when the forge says nothing is required.
|
|
402
|
+
*/
|
|
403
|
+
export function waitVerdict(
|
|
404
|
+
doc: PrStatusDoc,
|
|
405
|
+
options: { head?: string | null; elapsedMs: number; noChecksGraceMs?: number },
|
|
406
|
+
): WaitVerdict {
|
|
407
|
+
if (doc.state !== "open")
|
|
408
|
+
return {
|
|
409
|
+
state: "blocked",
|
|
410
|
+
reason: `pr_${doc.state}`,
|
|
411
|
+
unblock: `the ${doc.forge === "github" ? "PR" : "MR"} is ${doc.state}; nothing to wait for`,
|
|
412
|
+
};
|
|
413
|
+
if (options.head && !doc.head.sha.startsWith(options.head))
|
|
414
|
+
return { state: "waiting", reason: "head_mismatch" };
|
|
415
|
+
const checks = doc.checks.state;
|
|
416
|
+
if (checks === "failing") return { state: "failed", reason: "checks_failing" };
|
|
417
|
+
if (checks === "pending") return { state: "waiting", reason: "checks_pending" };
|
|
418
|
+
if (checks === "passing") return { state: "ready", reason: "checks_passing" };
|
|
419
|
+
if (doc.conflicts)
|
|
420
|
+
return {
|
|
421
|
+
state: "blocked",
|
|
422
|
+
reason: "conflicts",
|
|
423
|
+
unblock: `rebase onto ${doc.base} and push; CI does not run on a conflicting PR`,
|
|
424
|
+
};
|
|
425
|
+
const ciRequired =
|
|
426
|
+
doc.checks.missingRequired === null ||
|
|
427
|
+
doc.mergeState === "ci_must_pass" ||
|
|
428
|
+
doc.mergeState === "ci_still_running";
|
|
429
|
+
if (ciRequired) return { state: "waiting", reason: "no_checks_yet" };
|
|
430
|
+
if (options.elapsedMs < (options.noChecksGraceMs ?? NO_CHECKS_GRACE_MS))
|
|
431
|
+
return { state: "waiting", reason: "no_checks_yet" };
|
|
432
|
+
return { state: "ready", reason: "no_checks" };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Poll delays: `interval`, growing 1.5x per poll up to max(interval, 2 min).
|
|
437
|
+
* Deterministic, so a wait's poll count is predictable from its timeout.
|
|
438
|
+
*/
|
|
439
|
+
export function pollDelay(intervalMs: number, poll: number): number {
|
|
440
|
+
const cap = Math.max(intervalMs, 120_000);
|
|
441
|
+
return Math.min(cap, Math.round(intervalMs * 1.5 ** poll));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// ci rerun
|
|
446
|
+
|
|
447
|
+
export type RerunPlan = {
|
|
448
|
+
checks: ForgeCheck[];
|
|
449
|
+
targets: RerunTarget[];
|
|
450
|
+
/** Selected checks the forge cannot rerun (external statuses). */
|
|
451
|
+
skipped: string[];
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
export function planRerun(
|
|
455
|
+
status: ForgePrStatus,
|
|
456
|
+
selection: { failed: boolean; names: readonly string[] },
|
|
457
|
+
): ForgeResult<RerunPlan> {
|
|
458
|
+
let checks: ForgeCheck[];
|
|
459
|
+
if (selection.names.length > 0) {
|
|
460
|
+
const unknown = selection.names.filter(
|
|
461
|
+
(name) => !status.checks.some((check) => check.name === name),
|
|
462
|
+
);
|
|
463
|
+
if (unknown.length)
|
|
464
|
+
return failure(
|
|
465
|
+
"invalid_input",
|
|
466
|
+
`unknown check ${unknown.map((name) => JSON.stringify(name)).join(", ")}; known: ${status.checks.map((check) => check.name).join(", ") || "none"}`,
|
|
467
|
+
);
|
|
468
|
+
checks = status.checks.filter((check) => selection.names.includes(check.name));
|
|
469
|
+
const running = checks.filter((check) => check.state === "pending");
|
|
470
|
+
if (running.length)
|
|
471
|
+
return failure(
|
|
472
|
+
"busy",
|
|
473
|
+
`still running: ${running.map((check) => check.name).join(", ")}`,
|
|
474
|
+
"workit ci wait",
|
|
475
|
+
);
|
|
476
|
+
} else {
|
|
477
|
+
checks = status.checks.filter((check) => check.state === "failing");
|
|
478
|
+
}
|
|
479
|
+
if (checks.length === 0)
|
|
480
|
+
return failure("not_found", `no failing checks on head ${status.head.sha.slice(0, 12)}`);
|
|
481
|
+
const rerunnable = checks.filter((check) => check.jobId !== null && check.runId !== null);
|
|
482
|
+
const skipped = checks.filter((check) => !rerunnable.includes(check)).map((check) => check.name);
|
|
483
|
+
const targets: RerunTarget[] = [];
|
|
484
|
+
if (selection.names.length === 0) {
|
|
485
|
+
// One "rerun failed jobs" per workflow run / pipeline. The forge refuses
|
|
486
|
+
// it while the run still has jobs in progress (GitHub: HTTP 403).
|
|
487
|
+
const busy = status.checks.filter(
|
|
488
|
+
(check) =>
|
|
489
|
+
check.state === "pending" &&
|
|
490
|
+
check.runId !== null &&
|
|
491
|
+
rerunnable.some((failing) => failing.runId === check.runId),
|
|
492
|
+
);
|
|
493
|
+
if (busy.length)
|
|
494
|
+
return failure(
|
|
495
|
+
"busy",
|
|
496
|
+
`the failed jobs' run is still in progress (${busy.map((check) => check.name).join(", ")})`,
|
|
497
|
+
"workit ci wait, then rerun",
|
|
498
|
+
);
|
|
499
|
+
const seen = new Set<number>();
|
|
500
|
+
for (const check of rerunnable)
|
|
501
|
+
if (!seen.has(check.runId!)) {
|
|
502
|
+
seen.add(check.runId!);
|
|
503
|
+
targets.push({ kind: "failed_in_run", runId: check.runId!, scope: check.scope });
|
|
504
|
+
}
|
|
505
|
+
} else {
|
|
506
|
+
for (const check of rerunnable)
|
|
507
|
+
targets.push({ kind: "job", jobId: check.jobId!, scope: check.scope });
|
|
508
|
+
}
|
|
509
|
+
if (targets.length === 0)
|
|
510
|
+
return failure(
|
|
511
|
+
"unavailable",
|
|
512
|
+
`${skipped.join(", ")} cannot be rerun from here (not a CI job); rerun it on the forge`,
|
|
513
|
+
);
|
|
514
|
+
return success({ checks: rerunnable, targets, skipped });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export type RerunOutcome = {
|
|
518
|
+
pr: number;
|
|
519
|
+
head: string;
|
|
520
|
+
reason: string;
|
|
521
|
+
forced: boolean;
|
|
522
|
+
rerun: Array<{ name: string; runId: number | null; jobId: number | null }>;
|
|
523
|
+
skipped: string[];
|
|
524
|
+
recorded: boolean;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Apply the rerun-once rule, trigger the reruns, and record one row per check
|
|
529
|
+
* whose rerun the forge accepted.
|
|
530
|
+
*/
|
|
531
|
+
export function executeRerun(
|
|
532
|
+
cwd: string,
|
|
533
|
+
resolved: ResolvedForge,
|
|
534
|
+
status: ForgePrStatus,
|
|
535
|
+
options: { failed: boolean; names: readonly string[]; reason: string; force: boolean },
|
|
536
|
+
): ForgeResult<RerunOutcome> {
|
|
537
|
+
if (status.state !== "open")
|
|
538
|
+
return failure("blocked", `${status.state} PR: CI cannot be rerun`, "nothing to do");
|
|
539
|
+
const plan = planRerun(status, options);
|
|
540
|
+
if (!plan.ok) return plan;
|
|
541
|
+
const locked = withRerunLock(cwd, () => rerunLocked(cwd, resolved, status, plan.data, options));
|
|
542
|
+
return (
|
|
543
|
+
locked ??
|
|
544
|
+
failure("busy", "another ci rerun is in progress for this repository", "retry in a moment")
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function rerunLocked(
|
|
549
|
+
cwd: string,
|
|
550
|
+
resolved: ResolvedForge,
|
|
551
|
+
status: ForgePrStatus,
|
|
552
|
+
plan: RerunPlan,
|
|
553
|
+
options: { failed: boolean; names: readonly string[]; reason: string; force: boolean },
|
|
554
|
+
): ForgeResult<RerunOutcome> {
|
|
555
|
+
const { forge } = resolved;
|
|
556
|
+
const counts = rerunCounts(cwd, { repo: forge.repo, pr: status.number, head: status.head.sha });
|
|
557
|
+
const repeated = plan.checks.filter((check) => (counts.get(check.name) ?? 0) > 0);
|
|
558
|
+
if (repeated.length && !options.force)
|
|
559
|
+
return failure(
|
|
560
|
+
"blocked",
|
|
561
|
+
`rerun_limit: ${repeated.map((check) => check.name).join(", ")} already rerun once on head ${status.head.sha.slice(0, 12)}`,
|
|
562
|
+
"treat it as a real failure and fix it; if it is a confirmed flake, rerun with --force",
|
|
563
|
+
);
|
|
564
|
+
const accepted: ForgeCheck[] = [];
|
|
565
|
+
for (const target of plan.targets) {
|
|
566
|
+
const done = forge.rerun(target);
|
|
567
|
+
if (!done.ok) {
|
|
568
|
+
recordAccepted(cwd, resolved, status, accepted, options);
|
|
569
|
+
return failure(
|
|
570
|
+
done.code,
|
|
571
|
+
accepted.length
|
|
572
|
+
? `${done.error} (already rerun: ${accepted.map((check) => check.name).join(", ")})`
|
|
573
|
+
: done.error,
|
|
574
|
+
done.unblock,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
accepted.push(
|
|
578
|
+
...plan.checks.filter((check) =>
|
|
579
|
+
target.kind === "failed_in_run"
|
|
580
|
+
? check.runId === target.runId
|
|
581
|
+
: check.jobId === target.jobId,
|
|
582
|
+
),
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
const recorded = recordAccepted(cwd, resolved, status, accepted, options);
|
|
586
|
+
return success({
|
|
587
|
+
pr: status.number,
|
|
588
|
+
head: status.head.sha,
|
|
589
|
+
reason: options.reason,
|
|
590
|
+
forced: options.force,
|
|
591
|
+
rerun: accepted.map((check) => ({ name: check.name, runId: check.runId, jobId: check.jobId })),
|
|
592
|
+
skipped: plan.skipped,
|
|
593
|
+
recorded,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function recordAccepted(
|
|
598
|
+
cwd: string,
|
|
599
|
+
resolved: ResolvedForge,
|
|
600
|
+
status: ForgePrStatus,
|
|
601
|
+
accepted: readonly ForgeCheck[],
|
|
602
|
+
options: { reason: string; force: boolean },
|
|
603
|
+
): boolean {
|
|
604
|
+
const at = new Date().toISOString();
|
|
605
|
+
const rows: RerunRow[] = accepted.map((check) => ({
|
|
606
|
+
v: 1,
|
|
607
|
+
at,
|
|
608
|
+
type: "ci.rerun",
|
|
609
|
+
forge: resolved.forge.kind,
|
|
610
|
+
repo: resolved.forge.repo,
|
|
611
|
+
pr: status.number,
|
|
612
|
+
head: status.head.sha,
|
|
613
|
+
check: check.name,
|
|
614
|
+
reason: options.reason,
|
|
615
|
+
forced: options.force,
|
|
616
|
+
}));
|
|
617
|
+
return recordReruns(cwd, rows);
|
|
618
|
+
}
|