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.
@@ -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. An issue closed by more than ten PRs is not a
52
- * dispatch problem, and the first OPEN one already answers the question.
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 in a `gh api graphql` reply, if any.
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. `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
- const nodes =
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 the worker-observed head. A missing rollup is pending rather
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: string): PrVerification {
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 (pr.headRefOid?.toLowerCase() !== expectedHead.toLowerCase()) {
408
+ if (expectedHead !== undefined && headSha.toLowerCase() !== expectedHead.toLowerCase()) {
287
409
  return {
288
410
  status: "failed",
289
- reason: `PR head changed: expected ${expectedHead}, found ${pr.headRefOid ?? "unknown"}`,
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
- // GraphQL wants the halves of `owner/repo` separately. Config validates
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 verifyPr(url: string, expectedHead: string): Promise<PrVerification | undefined> {
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([