omp-conductor 0.12.0 → 0.14.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/README.md +253 -84
- package/package.json +1 -1
- package/src/availability.ts +165 -0
- package/src/board.ts +1 -1
- package/src/briefs/orchestrator.md +95 -28
- package/src/briefs/policy.md +44 -32
- package/src/cli.ts +226 -37
- package/src/config.ts +123 -7
- package/src/daemon.ts +1018 -145
- package/src/diff-flags.ts +77 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +46 -19
- package/src/failure-class.ts +7 -5
- package/src/fleet.ts +39 -3
- package/src/omp.ts +8 -4
- package/src/orchestrator-tick.ts +471 -39
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +166 -14
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +202 -5
- package/src/session-host.ts +4 -3
- package/src/setup.ts +197 -35
- package/src/store.ts +785 -112
- package/src/tracker/github.ts +299 -56
- package/src/types.ts +289 -37
- package/src/verbs/actions.ts +245 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +94 -13
- package/src/worker.ts +51 -11
- package/src/worktree.ts +5 -0
package/src/tracker/github.ts
CHANGED
|
@@ -25,6 +25,8 @@ import { credentialedEnv } from "../gitops.ts";
|
|
|
25
25
|
import { parsePrDiff } from "../diff-flags.ts";
|
|
26
26
|
import type {
|
|
27
27
|
IssueState,
|
|
28
|
+
IssueSnapshot,
|
|
29
|
+
MergedPrInfo,
|
|
28
30
|
OpenCloser,
|
|
29
31
|
PrDiff,
|
|
30
32
|
PrState,
|
|
@@ -32,6 +34,7 @@ import type {
|
|
|
32
34
|
ProjectConfig,
|
|
33
35
|
ReadyIssue,
|
|
34
36
|
Tracker,
|
|
37
|
+
WorkflowRun,
|
|
35
38
|
} from "../types.ts";
|
|
36
39
|
|
|
37
40
|
/**
|
|
@@ -48,8 +51,8 @@ import type {
|
|
|
48
51
|
* MERGED reference with the argument both true and false. The state is filtered
|
|
49
52
|
* in this consumer instead.
|
|
50
53
|
*
|
|
51
|
-
* ponytail: one page of ten.
|
|
52
|
-
*
|
|
54
|
+
* ponytail: one page of ten. Admission stops at the first OPEN closer; salvage
|
|
55
|
+
* recovery searches that page for its exact branch and repository identity.
|
|
53
56
|
*/
|
|
54
57
|
const CLOSERS_QUERY = `query($owner:String!,$repo:String!,$n:Int!){
|
|
55
58
|
repository(owner:$owner,name:$repo){
|
|
@@ -75,7 +78,7 @@ interface ClosersResponse {
|
|
|
75
78
|
issue?: {
|
|
76
79
|
closedByPullRequestsReferences?: {
|
|
77
80
|
nodes?:
|
|
78
|
-
| ({ state: string; isDraft: boolean; url: string; headRefName?: string } | null)[]
|
|
81
|
+
| ({ state: string; isDraft: boolean; url: string; headRefName?: string; repository?: { nameWithOwner?: string } | null } | null)[]
|
|
79
82
|
| null;
|
|
80
83
|
} | null;
|
|
81
84
|
} | null;
|
|
@@ -186,33 +189,32 @@ function isLabelNoop(err: unknown, op: "add" | "remove"): boolean {
|
|
|
186
189
|
);
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
/** Every OPEN closer in a `gh api graphql` reply. */
|
|
193
|
+
export function openClosers(raw: string): OpenCloser[] {
|
|
194
|
+
const nodes =
|
|
195
|
+
(JSON.parse(raw) as ClosersResponse).data?.repository?.issue?.closedByPullRequestsReferences
|
|
196
|
+
?.nodes ?? [];
|
|
197
|
+
const closers: OpenCloser[] = [];
|
|
198
|
+
for (const node of nodes) {
|
|
199
|
+
if (node === null || node.state !== "OPEN") continue;
|
|
200
|
+
closers.push({
|
|
201
|
+
url: node.url,
|
|
202
|
+
headRefName: node.headRefName ?? "",
|
|
203
|
+
repo: node.repository?.nameWithOwner ?? "",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return closers;
|
|
207
|
+
}
|
|
208
|
+
|
|
189
209
|
/**
|
|
190
|
-
* The first OPEN closer
|
|
191
|
-
*
|
|
192
|
-
* Split from the call so the state filter — the only real logic in this file —
|
|
193
|
-
* is pinned against recorded payloads instead of a live repo.
|
|
210
|
+
* The first OPEN closer, for admission's any-open guard.
|
|
194
211
|
*
|
|
195
|
-
* A draft counts
|
|
196
|
-
* it
|
|
197
|
-
*
|
|
198
|
-
* a second worker at it duplicates that work exactly as much as a ready PR
|
|
199
|
-
* would, so OPEN is the whole test.
|
|
200
|
-
*
|
|
201
|
-
* `headRefName` is selected because admission needs to recognise a PR opened on
|
|
202
|
-
* a run's retained branch *after* that run ended — veltro#324 on 2026-08-09,
|
|
203
|
-
* killed at the turns cap before its worker opened a PR, so the row kept the
|
|
204
|
-
* branch and `prUrl` stayed NULL (#50). A node without it is not a crash and
|
|
205
|
-
* not a hold: an empty string simply never equals a stored branch, so such a
|
|
206
|
-
* reply degrades to the URL-equality identity that shipped before.
|
|
212
|
+
* A draft counts: it is pushed work, and dispatching another worker duplicates
|
|
213
|
+
* it. Missing branch or repository identity still guards admission; recovery
|
|
214
|
+
* reads {@link openClosers} and refuses to adopt such a node.
|
|
207
215
|
*/
|
|
208
216
|
export function firstOpenCloser(raw: string): OpenCloser | undefined {
|
|
209
|
-
|
|
210
|
-
(JSON.parse(raw) as ClosersResponse).data?.repository?.issue?.closedByPullRequestsReferences
|
|
211
|
-
?.nodes ?? [];
|
|
212
|
-
const open = nodes.find((n) => n !== null && n.state === "OPEN");
|
|
213
|
-
return open === undefined || open === null
|
|
214
|
-
? undefined
|
|
215
|
-
: { url: open.url, headRefName: open.headRefName ?? "" };
|
|
217
|
+
return openClosers(raw)[0];
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
/**
|
|
@@ -242,6 +244,122 @@ export function prUrlParts(
|
|
|
242
244
|
return { owner: match[1], repo: match[2], number: Number.parseInt(match[3], 10) };
|
|
243
245
|
}
|
|
244
246
|
|
|
247
|
+
/** Parse the merge identity emitted by `gh pr view --json mergeCommit,baseRefName`. */
|
|
248
|
+
export function mergedPrInfoFrom(raw: string): MergedPrInfo | undefined {
|
|
249
|
+
let parsed: unknown;
|
|
250
|
+
try {
|
|
251
|
+
parsed = JSON.parse(raw) as unknown;
|
|
252
|
+
} catch {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
if (
|
|
256
|
+
parsed === null ||
|
|
257
|
+
typeof parsed !== "object" ||
|
|
258
|
+
!("mergeCommit" in parsed) ||
|
|
259
|
+
!("baseRefName" in parsed)
|
|
260
|
+
) {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
const commit = parsed.mergeCommit;
|
|
264
|
+
const mergeSha =
|
|
265
|
+
commit !== null && typeof commit === "object" && "oid" in commit ? commit.oid : undefined;
|
|
266
|
+
const baseRef = parsed.baseRefName;
|
|
267
|
+
return typeof mergeSha === "string" &&
|
|
268
|
+
/^[0-9a-f]{40,64}$/i.test(mergeSha) &&
|
|
269
|
+
typeof baseRef === "string" &&
|
|
270
|
+
baseRef.length > 0
|
|
271
|
+
? { mergeSha, baseRef }
|
|
272
|
+
: undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Parse the commit identity emitted by GitHub's branch REST endpoint. */
|
|
276
|
+
export function branchHeadFrom(raw: string): string | undefined {
|
|
277
|
+
let parsed: unknown;
|
|
278
|
+
try {
|
|
279
|
+
parsed = JSON.parse(raw) as unknown;
|
|
280
|
+
} catch {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
if (
|
|
284
|
+
parsed === null ||
|
|
285
|
+
typeof parsed !== "object" ||
|
|
286
|
+
!("commit" in parsed) ||
|
|
287
|
+
parsed.commit === null ||
|
|
288
|
+
typeof parsed.commit !== "object" ||
|
|
289
|
+
!("sha" in parsed.commit)
|
|
290
|
+
) {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
const sha = parsed.commit.sha;
|
|
294
|
+
return typeof sha === "string" && /^[0-9a-f]{40,64}$/i.test(sha) ? sha : undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Parse an Actions workflow-run list, skipping malformed entries without
|
|
298
|
+
* turning a valid empty list into an unreadable response. */
|
|
299
|
+
export function workflowRunsFrom(raw: string): WorkflowRun[] | undefined {
|
|
300
|
+
let parsed: unknown;
|
|
301
|
+
try {
|
|
302
|
+
parsed = JSON.parse(raw) as unknown;
|
|
303
|
+
} catch {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
if (parsed === null || typeof parsed !== "object" || !("workflow_runs" in parsed)) {
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
const entries = parsed.workflow_runs;
|
|
310
|
+
if (!Array.isArray(entries)) return undefined;
|
|
311
|
+
const runs: WorkflowRun[] = [];
|
|
312
|
+
for (const entry of entries) {
|
|
313
|
+
if (
|
|
314
|
+
entry === null ||
|
|
315
|
+
typeof entry !== "object" ||
|
|
316
|
+
!("id" in entry) ||
|
|
317
|
+
!("workflow_id" in entry) ||
|
|
318
|
+
!("name" in entry) ||
|
|
319
|
+
!("status" in entry) ||
|
|
320
|
+
!("html_url" in entry) ||
|
|
321
|
+
!("created_at" in entry)
|
|
322
|
+
) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const id = entry.id;
|
|
326
|
+
const workflowId = entry.workflow_id;
|
|
327
|
+
const name = entry.name;
|
|
328
|
+
const status = entry.status;
|
|
329
|
+
const conclusion = "conclusion" in entry ? entry.conclusion : undefined;
|
|
330
|
+
const url = entry.html_url;
|
|
331
|
+
const createdAt = entry.created_at;
|
|
332
|
+
if (
|
|
333
|
+
typeof id !== "number" ||
|
|
334
|
+
!Number.isSafeInteger(id) ||
|
|
335
|
+
id <= 0 ||
|
|
336
|
+
typeof workflowId !== "number" ||
|
|
337
|
+
!Number.isSafeInteger(workflowId) ||
|
|
338
|
+
workflowId <= 0 ||
|
|
339
|
+
typeof name !== "string" ||
|
|
340
|
+
name.length === 0 ||
|
|
341
|
+
typeof status !== "string" ||
|
|
342
|
+
status.length === 0 ||
|
|
343
|
+
typeof url !== "string" ||
|
|
344
|
+
url.length === 0 ||
|
|
345
|
+
typeof createdAt !== "string" ||
|
|
346
|
+
!Number.isFinite(Date.parse(createdAt))
|
|
347
|
+
) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
runs.push({
|
|
351
|
+
id,
|
|
352
|
+
workflowId,
|
|
353
|
+
name,
|
|
354
|
+
status,
|
|
355
|
+
...(typeof conclusion === "string" && conclusion.length > 0 ? { conclusion } : {}),
|
|
356
|
+
url,
|
|
357
|
+
createdAt,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return runs;
|
|
361
|
+
}
|
|
362
|
+
|
|
245
363
|
/**
|
|
246
364
|
* How much of a pull request's diff the settlement audit will hold in memory.
|
|
247
365
|
*
|
|
@@ -272,33 +390,39 @@ function checkVerdict(check: GhCheck): CheckVerdict {
|
|
|
272
390
|
|
|
273
391
|
/**
|
|
274
392
|
* Verify one `gh pr view --json state,isDraft,headRefOid,statusCheckRollup`
|
|
275
|
-
* payload against
|
|
276
|
-
* than green because GitHub may not have created the checks yet.
|
|
393
|
+
* payload, optionally against a caller-observed head. A missing rollup is
|
|
394
|
+
* pending rather than green because GitHub may not have created the checks yet.
|
|
277
395
|
*/
|
|
278
|
-
export function prVerificationFrom(raw: string, expectedHead
|
|
396
|
+
export function prVerificationFrom(raw: string, expectedHead?: string): PrVerification {
|
|
279
397
|
const pr = JSON.parse(raw) as GhPrVerification;
|
|
398
|
+
if (typeof pr.headRefOid !== "string" || pr.headRefOid === "") {
|
|
399
|
+
throw new Error("PR head is unavailable");
|
|
400
|
+
}
|
|
401
|
+
const headSha = pr.headRefOid;
|
|
280
402
|
if (pr.state !== "OPEN") {
|
|
281
|
-
return { status: "failed", reason: `PR is ${pr.state ?? "unknown"}, expected OPEN
|
|
403
|
+
return { status: "failed", reason: `PR is ${pr.state ?? "unknown"}, expected OPEN`, headSha };
|
|
282
404
|
}
|
|
283
405
|
if (pr.isDraft !== false) {
|
|
284
|
-
return { status: "failed", reason: "PR is draft or draft state is unknown" };
|
|
406
|
+
return { status: "failed", reason: "PR is draft or draft state is unknown", headSha };
|
|
285
407
|
}
|
|
286
|
-
if (
|
|
408
|
+
if (expectedHead !== undefined && headSha.toLowerCase() !== expectedHead.toLowerCase()) {
|
|
287
409
|
return {
|
|
288
410
|
status: "failed",
|
|
289
|
-
reason: `PR head changed: expected ${expectedHead}, found ${
|
|
411
|
+
reason: `PR head changed: expected ${expectedHead}, found ${headSha}`,
|
|
412
|
+
headSha,
|
|
290
413
|
};
|
|
291
414
|
}
|
|
292
415
|
|
|
293
416
|
const checks = pr.statusCheckRollup ?? [];
|
|
294
417
|
if (checks.length === 0) {
|
|
295
|
-
return { status: "pending", reason: "GitHub has not reported any checks yet" };
|
|
418
|
+
return { status: "pending", reason: "GitHub has not reported any checks yet", headSha };
|
|
296
419
|
}
|
|
297
420
|
const failed = checks.filter((check) => checkVerdict(check) === "failed");
|
|
298
421
|
if (failed.length > 0) {
|
|
299
422
|
return {
|
|
300
423
|
status: "failed",
|
|
301
424
|
reason: `Checks failed: ${failed.map((check) => `${checkName(check)} (${check.conclusion ?? check.state ?? "unknown"})`).join(", ")}`,
|
|
425
|
+
headSha,
|
|
302
426
|
};
|
|
303
427
|
}
|
|
304
428
|
const pending = checks.filter((check) => checkVerdict(check) === "pending");
|
|
@@ -306,9 +430,10 @@ export function prVerificationFrom(raw: string, expectedHead: string): PrVerific
|
|
|
306
430
|
return {
|
|
307
431
|
status: "pending",
|
|
308
432
|
reason: `Checks pending: ${pending.map(checkName).join(", ")}`,
|
|
433
|
+
headSha,
|
|
309
434
|
};
|
|
310
435
|
}
|
|
311
|
-
return { status: "green", reason: `${checks.length} checks succeeded or were skipped
|
|
436
|
+
return { status: "green", reason: `${checks.length} checks succeeded or were skipped`, headSha };
|
|
312
437
|
}
|
|
313
438
|
|
|
314
439
|
function failedCheck(raw: string): GhCheck | undefined {
|
|
@@ -380,6 +505,25 @@ export function issueStateFrom(raw: string): IssueState | undefined {
|
|
|
380
505
|
}
|
|
381
506
|
}
|
|
382
507
|
|
|
508
|
+
/** Direct issue admission facts parsed fail-closed from a REST projection. */
|
|
509
|
+
export function issueSnapshotFrom(raw: string): IssueSnapshot | undefined {
|
|
510
|
+
let parsed: unknown;
|
|
511
|
+
try {
|
|
512
|
+
parsed = JSON.parse(raw) as unknown;
|
|
513
|
+
} catch {
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
517
|
+
const row = parsed as { readonly [key: string]: unknown };
|
|
518
|
+
const rawState = row["state"];
|
|
519
|
+
const labels = row["labels"];
|
|
520
|
+
if (typeof rawState !== "string" || !Array.isArray(labels) || !labels.every((label) => typeof label === "string")) {
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
const state = issueStateFrom(rawState.toUpperCase());
|
|
524
|
+
return state === undefined ? undefined : { state, labels };
|
|
525
|
+
}
|
|
526
|
+
|
|
383
527
|
/**
|
|
384
528
|
* GitHub's REST pull-request shape (`{state, merged_at}`) mapped onto
|
|
385
529
|
* {@link PrState}. `merged_at` is what distinguishes a merged PR from a
|
|
@@ -868,6 +1012,43 @@ export function makeTracker(
|
|
|
868
1012
|
}
|
|
869
1013
|
};
|
|
870
1014
|
|
|
1015
|
+
const readOpenClosers = async (issue: number): Promise<OpenCloser[]> => {
|
|
1016
|
+
// GraphQL wants the halves of `owner/repo` separately. Config validates
|
|
1017
|
+
// that spelling, so an empty half means a hand-edited config: `gh` then
|
|
1018
|
+
// errors and the caller holds the candidate rather than guessing.
|
|
1019
|
+
const [owner = "", name = ""] = repo.split("/");
|
|
1020
|
+
const raw = await runGh([
|
|
1021
|
+
"api",
|
|
1022
|
+
"graphql",
|
|
1023
|
+
"-f",
|
|
1024
|
+
`query=${CLOSERS_QUERY}`,
|
|
1025
|
+
"-F",
|
|
1026
|
+
`owner=${owner}`,
|
|
1027
|
+
"-F",
|
|
1028
|
+
`repo=${name}`,
|
|
1029
|
+
// -F, not -f: the query declares $n as Int! and a string would be a
|
|
1030
|
+
// type error rather than a coerced number.
|
|
1031
|
+
"-F",
|
|
1032
|
+
`n=${issue}`,
|
|
1033
|
+
]);
|
|
1034
|
+
return openClosers(raw);
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
const readIssueSnapshot = async (issue: number): Promise<IssueSnapshot | undefined> => {
|
|
1038
|
+
try {
|
|
1039
|
+
return issueSnapshotFrom(
|
|
1040
|
+
await runGh([
|
|
1041
|
+
"api",
|
|
1042
|
+
`repos/${repo}/issues/${issue}`,
|
|
1043
|
+
"--jq",
|
|
1044
|
+
"{state: .state, labels: [.labels[].name]}",
|
|
1045
|
+
]),
|
|
1046
|
+
);
|
|
1047
|
+
} catch {
|
|
1048
|
+
return undefined;
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
|
|
871
1052
|
// Named rather than returned inline, so `rerunFailedChecks` can reuse
|
|
872
1053
|
// `checkConclusions` instead of re-implementing the same `gh` call.
|
|
873
1054
|
const tracker: Tracker = {
|
|
@@ -972,28 +1153,11 @@ export function makeTracker(
|
|
|
972
1153
|
},
|
|
973
1154
|
|
|
974
1155
|
async openCloserFor(issue: number): Promise<OpenCloser | undefined> {
|
|
975
|
-
|
|
976
|
-
// that spelling, so an empty half means a hand-edited config: `gh` then
|
|
977
|
-
// errors and the caller holds the candidate rather than guessing.
|
|
978
|
-
const [owner = "", name = ""] = repo.split("/");
|
|
979
|
-
const raw = await runGh([
|
|
980
|
-
"api",
|
|
981
|
-
"graphql",
|
|
982
|
-
"-f",
|
|
983
|
-
`query=${CLOSERS_QUERY}`,
|
|
984
|
-
"-F",
|
|
985
|
-
`owner=${owner}`,
|
|
986
|
-
"-F",
|
|
987
|
-
`repo=${name}`,
|
|
988
|
-
// -F, not -f: the query declares $n as Int! and a string would be a
|
|
989
|
-
// type error rather than a coerced number.
|
|
990
|
-
"-F",
|
|
991
|
-
`n=${issue}`,
|
|
992
|
-
]);
|
|
993
|
-
|
|
994
|
-
return firstOpenCloser(raw);
|
|
1156
|
+
return (await readOpenClosers(issue))[0];
|
|
995
1157
|
},
|
|
996
1158
|
|
|
1159
|
+
openClosersFor: readOpenClosers,
|
|
1160
|
+
|
|
997
1161
|
async issueState(issue: number): Promise<IssueState | undefined> {
|
|
998
1162
|
try {
|
|
999
1163
|
// REST answers lowercase `open`/`closed`; the shared parser expects the
|
|
@@ -1006,6 +1170,8 @@ export function makeTracker(
|
|
|
1006
1170
|
}
|
|
1007
1171
|
},
|
|
1008
1172
|
|
|
1173
|
+
issueSnapshot: readIssueSnapshot,
|
|
1174
|
+
|
|
1009
1175
|
async prState(url: string): Promise<PrState | undefined> {
|
|
1010
1176
|
// No `--repo`: the full URL names the repository, and the daemon runs
|
|
1011
1177
|
// from its own state directory rather than a checkout — so the REST path
|
|
@@ -1032,7 +1198,84 @@ export function makeTracker(
|
|
|
1032
1198
|
}
|
|
1033
1199
|
},
|
|
1034
1200
|
|
|
1035
|
-
async
|
|
1201
|
+
async mergedPrInfo(url: string): Promise<MergedPrInfo | undefined> {
|
|
1202
|
+
if (!PR_URL.test(url)) return undefined;
|
|
1203
|
+
try {
|
|
1204
|
+
return mergedPrInfoFrom(
|
|
1205
|
+
await runGh(["pr", "view", url, "--json", "mergeCommit,baseRefName"]),
|
|
1206
|
+
);
|
|
1207
|
+
} catch {
|
|
1208
|
+
return undefined;
|
|
1209
|
+
}
|
|
1210
|
+
},
|
|
1211
|
+
|
|
1212
|
+
async branchHead(repo: string, branch: string): Promise<string | undefined> {
|
|
1213
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || branch.length === 0) return undefined;
|
|
1214
|
+
try {
|
|
1215
|
+
return branchHeadFrom(
|
|
1216
|
+
await runGh(["api", `repos/${repo}/branches/${encodeURIComponent(branch)}`]),
|
|
1217
|
+
);
|
|
1218
|
+
} catch {
|
|
1219
|
+
return undefined;
|
|
1220
|
+
}
|
|
1221
|
+
},
|
|
1222
|
+
|
|
1223
|
+
async workflowRunsAt(
|
|
1224
|
+
repo: string,
|
|
1225
|
+
sha: string,
|
|
1226
|
+
opts?: { event?: string; branch?: string },
|
|
1227
|
+
): Promise<WorkflowRun[] | undefined> {
|
|
1228
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || !/^[0-9a-f]{40,64}$/i.test(sha)) {
|
|
1229
|
+
return undefined;
|
|
1230
|
+
}
|
|
1231
|
+
const query = [`head_sha=${encodeURIComponent(sha)}`];
|
|
1232
|
+
if (opts?.event !== undefined) query.push(`event=${encodeURIComponent(opts.event)}`);
|
|
1233
|
+
if (opts?.branch !== undefined) query.push(`branch=${encodeURIComponent(opts.branch)}`);
|
|
1234
|
+
query.push("per_page=50");
|
|
1235
|
+
try {
|
|
1236
|
+
return workflowRunsFrom(
|
|
1237
|
+
await runGh(["api", `repos/${repo}/actions/runs?${query.join("&")}`]),
|
|
1238
|
+
);
|
|
1239
|
+
} catch {
|
|
1240
|
+
return undefined;
|
|
1241
|
+
}
|
|
1242
|
+
},
|
|
1243
|
+
|
|
1244
|
+
async previousWorkflowRun(
|
|
1245
|
+
repo: string,
|
|
1246
|
+
workflowId: number,
|
|
1247
|
+
branch: string,
|
|
1248
|
+
before: string,
|
|
1249
|
+
): Promise<WorkflowRun | null | undefined> {
|
|
1250
|
+
if (
|
|
1251
|
+
!/^[^/\s]+\/[^/\s]+$/.test(repo) ||
|
|
1252
|
+
!Number.isSafeInteger(workflowId) ||
|
|
1253
|
+
workflowId <= 0 ||
|
|
1254
|
+
branch.length === 0 ||
|
|
1255
|
+
!Number.isFinite(Date.parse(before))
|
|
1256
|
+
) {
|
|
1257
|
+
return undefined;
|
|
1258
|
+
}
|
|
1259
|
+
try {
|
|
1260
|
+
const runs = workflowRunsFrom(
|
|
1261
|
+
await runGh([
|
|
1262
|
+
"api",
|
|
1263
|
+
`repos/${repo}/actions/workflows/${workflowId}/runs?branch=${encodeURIComponent(branch)}&created=${encodeURIComponent(`<${before}`)}&per_page=2`,
|
|
1264
|
+
]),
|
|
1265
|
+
);
|
|
1266
|
+
if (runs === undefined) return undefined;
|
|
1267
|
+
const beforeMs = Date.parse(before);
|
|
1268
|
+
return (
|
|
1269
|
+
runs
|
|
1270
|
+
.filter((run) => Date.parse(run.createdAt) < beforeMs)
|
|
1271
|
+
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0] ?? null
|
|
1272
|
+
);
|
|
1273
|
+
} catch {
|
|
1274
|
+
return undefined;
|
|
1275
|
+
}
|
|
1276
|
+
},
|
|
1277
|
+
|
|
1278
|
+
async verifyPr(url: string, expectedHead?: string): Promise<PrVerification | undefined> {
|
|
1036
1279
|
if (!PR_URL.test(url)) return undefined;
|
|
1037
1280
|
try {
|
|
1038
1281
|
const raw = await runGh([
|