omp-conductor 0.12.0 → 0.13.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 +40 -19
- package/package.json +1 -1
- package/src/board.ts +1 -1
- package/src/briefs/orchestrator.md +32 -2
- package/src/cli.ts +104 -23
- package/src/config.ts +10 -2
- package/src/daemon.ts +818 -131
- package/src/diff-flags.ts +4 -0
- package/src/escalate.ts +5 -5
- package/src/failure-class.ts +7 -5
- package/src/fleet.ts +7 -2
- package/src/omp.ts +8 -4
- package/src/orchestrator-tick.ts +35 -20
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +28 -2
- package/src/release-policy.ts +66 -6
- package/src/session-host.ts +4 -3
- package/src/setup.ts +4 -2
- package/src/store.ts +190 -29
- package/src/tracker/github.ts +261 -56
- package/src/types.ts +141 -28
- package/src/verbs/actions.ts +127 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +95 -13
- package/src/worker.ts +33 -5
- 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
|
|
210
|
+
* The first OPEN closer, for admission's any-open guard.
|
|
191
211
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* A draft counts. `isDraft` is selected because the API offers it, not because
|
|
196
|
-
* it changes the answer: draft means "not ready to review", not "not pushed",
|
|
197
|
-
* and the branch behind a draft still holds the only copy of the work. Sending
|
|
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,100 @@ 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 an Actions workflow-run list, skipping malformed entries without
|
|
276
|
+
* turning a valid empty list into an unreadable response. */
|
|
277
|
+
export function workflowRunsFrom(raw: string): WorkflowRun[] | undefined {
|
|
278
|
+
let parsed: unknown;
|
|
279
|
+
try {
|
|
280
|
+
parsed = JSON.parse(raw) as unknown;
|
|
281
|
+
} catch {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
if (parsed === null || typeof parsed !== "object" || !("workflow_runs" in parsed)) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
const entries = parsed.workflow_runs;
|
|
288
|
+
if (!Array.isArray(entries)) return undefined;
|
|
289
|
+
const runs: WorkflowRun[] = [];
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
if (
|
|
292
|
+
entry === null ||
|
|
293
|
+
typeof entry !== "object" ||
|
|
294
|
+
!("id" in entry) ||
|
|
295
|
+
!("workflow_id" in entry) ||
|
|
296
|
+
!("name" in entry) ||
|
|
297
|
+
!("status" in entry) ||
|
|
298
|
+
!("html_url" in entry) ||
|
|
299
|
+
!("created_at" in entry)
|
|
300
|
+
) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const id = entry.id;
|
|
304
|
+
const workflowId = entry.workflow_id;
|
|
305
|
+
const name = entry.name;
|
|
306
|
+
const status = entry.status;
|
|
307
|
+
const conclusion = "conclusion" in entry ? entry.conclusion : undefined;
|
|
308
|
+
const url = entry.html_url;
|
|
309
|
+
const createdAt = entry.created_at;
|
|
310
|
+
if (
|
|
311
|
+
typeof id !== "number" ||
|
|
312
|
+
!Number.isSafeInteger(id) ||
|
|
313
|
+
id <= 0 ||
|
|
314
|
+
typeof workflowId !== "number" ||
|
|
315
|
+
!Number.isSafeInteger(workflowId) ||
|
|
316
|
+
workflowId <= 0 ||
|
|
317
|
+
typeof name !== "string" ||
|
|
318
|
+
name.length === 0 ||
|
|
319
|
+
typeof status !== "string" ||
|
|
320
|
+
status.length === 0 ||
|
|
321
|
+
typeof url !== "string" ||
|
|
322
|
+
url.length === 0 ||
|
|
323
|
+
typeof createdAt !== "string" ||
|
|
324
|
+
!Number.isFinite(Date.parse(createdAt))
|
|
325
|
+
) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
runs.push({
|
|
329
|
+
id,
|
|
330
|
+
workflowId,
|
|
331
|
+
name,
|
|
332
|
+
status,
|
|
333
|
+
...(typeof conclusion === "string" && conclusion.length > 0 ? { conclusion } : {}),
|
|
334
|
+
url,
|
|
335
|
+
createdAt,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return runs;
|
|
339
|
+
}
|
|
340
|
+
|
|
245
341
|
/**
|
|
246
342
|
* How much of a pull request's diff the settlement audit will hold in memory.
|
|
247
343
|
*
|
|
@@ -272,33 +368,39 @@ function checkVerdict(check: GhCheck): CheckVerdict {
|
|
|
272
368
|
|
|
273
369
|
/**
|
|
274
370
|
* 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.
|
|
371
|
+
* payload, optionally against a caller-observed head. A missing rollup is
|
|
372
|
+
* pending rather than green because GitHub may not have created the checks yet.
|
|
277
373
|
*/
|
|
278
|
-
export function prVerificationFrom(raw: string, expectedHead
|
|
374
|
+
export function prVerificationFrom(raw: string, expectedHead?: string): PrVerification {
|
|
279
375
|
const pr = JSON.parse(raw) as GhPrVerification;
|
|
376
|
+
if (typeof pr.headRefOid !== "string" || pr.headRefOid === "") {
|
|
377
|
+
throw new Error("PR head is unavailable");
|
|
378
|
+
}
|
|
379
|
+
const headSha = pr.headRefOid;
|
|
280
380
|
if (pr.state !== "OPEN") {
|
|
281
|
-
return { status: "failed", reason: `PR is ${pr.state ?? "unknown"}, expected OPEN
|
|
381
|
+
return { status: "failed", reason: `PR is ${pr.state ?? "unknown"}, expected OPEN`, headSha };
|
|
282
382
|
}
|
|
283
383
|
if (pr.isDraft !== false) {
|
|
284
|
-
return { status: "failed", reason: "PR is draft or draft state is unknown" };
|
|
384
|
+
return { status: "failed", reason: "PR is draft or draft state is unknown", headSha };
|
|
285
385
|
}
|
|
286
|
-
if (
|
|
386
|
+
if (expectedHead !== undefined && headSha.toLowerCase() !== expectedHead.toLowerCase()) {
|
|
287
387
|
return {
|
|
288
388
|
status: "failed",
|
|
289
|
-
reason: `PR head changed: expected ${expectedHead}, found ${
|
|
389
|
+
reason: `PR head changed: expected ${expectedHead}, found ${headSha}`,
|
|
390
|
+
headSha,
|
|
290
391
|
};
|
|
291
392
|
}
|
|
292
393
|
|
|
293
394
|
const checks = pr.statusCheckRollup ?? [];
|
|
294
395
|
if (checks.length === 0) {
|
|
295
|
-
return { status: "pending", reason: "GitHub has not reported any checks yet" };
|
|
396
|
+
return { status: "pending", reason: "GitHub has not reported any checks yet", headSha };
|
|
296
397
|
}
|
|
297
398
|
const failed = checks.filter((check) => checkVerdict(check) === "failed");
|
|
298
399
|
if (failed.length > 0) {
|
|
299
400
|
return {
|
|
300
401
|
status: "failed",
|
|
301
402
|
reason: `Checks failed: ${failed.map((check) => `${checkName(check)} (${check.conclusion ?? check.state ?? "unknown"})`).join(", ")}`,
|
|
403
|
+
headSha,
|
|
302
404
|
};
|
|
303
405
|
}
|
|
304
406
|
const pending = checks.filter((check) => checkVerdict(check) === "pending");
|
|
@@ -306,9 +408,10 @@ export function prVerificationFrom(raw: string, expectedHead: string): PrVerific
|
|
|
306
408
|
return {
|
|
307
409
|
status: "pending",
|
|
308
410
|
reason: `Checks pending: ${pending.map(checkName).join(", ")}`,
|
|
411
|
+
headSha,
|
|
309
412
|
};
|
|
310
413
|
}
|
|
311
|
-
return { status: "green", reason: `${checks.length} checks succeeded or were skipped
|
|
414
|
+
return { status: "green", reason: `${checks.length} checks succeeded or were skipped`, headSha };
|
|
312
415
|
}
|
|
313
416
|
|
|
314
417
|
function failedCheck(raw: string): GhCheck | undefined {
|
|
@@ -380,6 +483,25 @@ export function issueStateFrom(raw: string): IssueState | undefined {
|
|
|
380
483
|
}
|
|
381
484
|
}
|
|
382
485
|
|
|
486
|
+
/** Direct issue admission facts parsed fail-closed from a REST projection. */
|
|
487
|
+
export function issueSnapshotFrom(raw: string): IssueSnapshot | undefined {
|
|
488
|
+
let parsed: unknown;
|
|
489
|
+
try {
|
|
490
|
+
parsed = JSON.parse(raw) as unknown;
|
|
491
|
+
} catch {
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
495
|
+
const row = parsed as { readonly [key: string]: unknown };
|
|
496
|
+
const rawState = row["state"];
|
|
497
|
+
const labels = row["labels"];
|
|
498
|
+
if (typeof rawState !== "string" || !Array.isArray(labels) || !labels.every((label) => typeof label === "string")) {
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
const state = issueStateFrom(rawState.toUpperCase());
|
|
502
|
+
return state === undefined ? undefined : { state, labels };
|
|
503
|
+
}
|
|
504
|
+
|
|
383
505
|
/**
|
|
384
506
|
* GitHub's REST pull-request shape (`{state, merged_at}`) mapped onto
|
|
385
507
|
* {@link PrState}. `merged_at` is what distinguishes a merged PR from a
|
|
@@ -868,6 +990,43 @@ export function makeTracker(
|
|
|
868
990
|
}
|
|
869
991
|
};
|
|
870
992
|
|
|
993
|
+
const readOpenClosers = async (issue: number): Promise<OpenCloser[]> => {
|
|
994
|
+
// GraphQL wants the halves of `owner/repo` separately. Config validates
|
|
995
|
+
// that spelling, so an empty half means a hand-edited config: `gh` then
|
|
996
|
+
// errors and the caller holds the candidate rather than guessing.
|
|
997
|
+
const [owner = "", name = ""] = repo.split("/");
|
|
998
|
+
const raw = await runGh([
|
|
999
|
+
"api",
|
|
1000
|
+
"graphql",
|
|
1001
|
+
"-f",
|
|
1002
|
+
`query=${CLOSERS_QUERY}`,
|
|
1003
|
+
"-F",
|
|
1004
|
+
`owner=${owner}`,
|
|
1005
|
+
"-F",
|
|
1006
|
+
`repo=${name}`,
|
|
1007
|
+
// -F, not -f: the query declares $n as Int! and a string would be a
|
|
1008
|
+
// type error rather than a coerced number.
|
|
1009
|
+
"-F",
|
|
1010
|
+
`n=${issue}`,
|
|
1011
|
+
]);
|
|
1012
|
+
return openClosers(raw);
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const readIssueSnapshot = async (issue: number): Promise<IssueSnapshot | undefined> => {
|
|
1016
|
+
try {
|
|
1017
|
+
return issueSnapshotFrom(
|
|
1018
|
+
await runGh([
|
|
1019
|
+
"api",
|
|
1020
|
+
`repos/${repo}/issues/${issue}`,
|
|
1021
|
+
"--jq",
|
|
1022
|
+
"{state: .state, labels: [.labels[].name]}",
|
|
1023
|
+
]),
|
|
1024
|
+
);
|
|
1025
|
+
} catch {
|
|
1026
|
+
return undefined;
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
|
|
871
1030
|
// Named rather than returned inline, so `rerunFailedChecks` can reuse
|
|
872
1031
|
// `checkConclusions` instead of re-implementing the same `gh` call.
|
|
873
1032
|
const tracker: Tracker = {
|
|
@@ -972,28 +1131,11 @@ export function makeTracker(
|
|
|
972
1131
|
},
|
|
973
1132
|
|
|
974
1133
|
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);
|
|
1134
|
+
return (await readOpenClosers(issue))[0];
|
|
995
1135
|
},
|
|
996
1136
|
|
|
1137
|
+
openClosersFor: readOpenClosers,
|
|
1138
|
+
|
|
997
1139
|
async issueState(issue: number): Promise<IssueState | undefined> {
|
|
998
1140
|
try {
|
|
999
1141
|
// REST answers lowercase `open`/`closed`; the shared parser expects the
|
|
@@ -1006,6 +1148,8 @@ export function makeTracker(
|
|
|
1006
1148
|
}
|
|
1007
1149
|
},
|
|
1008
1150
|
|
|
1151
|
+
issueSnapshot: readIssueSnapshot,
|
|
1152
|
+
|
|
1009
1153
|
async prState(url: string): Promise<PrState | undefined> {
|
|
1010
1154
|
// No `--repo`: the full URL names the repository, and the daemon runs
|
|
1011
1155
|
// from its own state directory rather than a checkout — so the REST path
|
|
@@ -1032,7 +1176,68 @@ export function makeTracker(
|
|
|
1032
1176
|
}
|
|
1033
1177
|
},
|
|
1034
1178
|
|
|
1035
|
-
async
|
|
1179
|
+
async mergedPrInfo(url: string): Promise<MergedPrInfo | undefined> {
|
|
1180
|
+
if (!PR_URL.test(url)) return undefined;
|
|
1181
|
+
try {
|
|
1182
|
+
return mergedPrInfoFrom(
|
|
1183
|
+
await runGh(["pr", "view", url, "--json", "mergeCommit,baseRefName"]),
|
|
1184
|
+
);
|
|
1185
|
+
} catch {
|
|
1186
|
+
return undefined;
|
|
1187
|
+
}
|
|
1188
|
+
},
|
|
1189
|
+
|
|
1190
|
+
async workflowRunsAt(repo: string, sha: string): Promise<WorkflowRun[] | undefined> {
|
|
1191
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || !/^[0-9a-f]{40,64}$/i.test(sha)) {
|
|
1192
|
+
return undefined;
|
|
1193
|
+
}
|
|
1194
|
+
try {
|
|
1195
|
+
return workflowRunsFrom(
|
|
1196
|
+
await runGh([
|
|
1197
|
+
"api",
|
|
1198
|
+
`repos/${repo}/actions/runs?head_sha=${encodeURIComponent(sha)}&per_page=50`,
|
|
1199
|
+
]),
|
|
1200
|
+
);
|
|
1201
|
+
} catch {
|
|
1202
|
+
return undefined;
|
|
1203
|
+
}
|
|
1204
|
+
},
|
|
1205
|
+
|
|
1206
|
+
async previousWorkflowRun(
|
|
1207
|
+
repo: string,
|
|
1208
|
+
workflowId: number,
|
|
1209
|
+
branch: string,
|
|
1210
|
+
before: string,
|
|
1211
|
+
): Promise<WorkflowRun | null | undefined> {
|
|
1212
|
+
if (
|
|
1213
|
+
!/^[^/\s]+\/[^/\s]+$/.test(repo) ||
|
|
1214
|
+
!Number.isSafeInteger(workflowId) ||
|
|
1215
|
+
workflowId <= 0 ||
|
|
1216
|
+
branch.length === 0 ||
|
|
1217
|
+
!Number.isFinite(Date.parse(before))
|
|
1218
|
+
) {
|
|
1219
|
+
return undefined;
|
|
1220
|
+
}
|
|
1221
|
+
try {
|
|
1222
|
+
const runs = workflowRunsFrom(
|
|
1223
|
+
await runGh([
|
|
1224
|
+
"api",
|
|
1225
|
+
`repos/${repo}/actions/workflows/${workflowId}/runs?branch=${encodeURIComponent(branch)}&created=${encodeURIComponent(`<${before}`)}&per_page=2`,
|
|
1226
|
+
]),
|
|
1227
|
+
);
|
|
1228
|
+
if (runs === undefined) return undefined;
|
|
1229
|
+
const beforeMs = Date.parse(before);
|
|
1230
|
+
return (
|
|
1231
|
+
runs
|
|
1232
|
+
.filter((run) => Date.parse(run.createdAt) < beforeMs)
|
|
1233
|
+
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0] ?? null
|
|
1234
|
+
);
|
|
1235
|
+
} catch {
|
|
1236
|
+
return undefined;
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
|
|
1240
|
+
async verifyPr(url: string, expectedHead?: string): Promise<PrVerification | undefined> {
|
|
1036
1241
|
if (!PR_URL.test(url)) return undefined;
|
|
1037
1242
|
try {
|
|
1038
1243
|
const raw = await runGh([
|