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.
@@ -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.
210
+ * The first OPEN closer, for admission's any-open guard.
191
211
  *
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.
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
- 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,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 the worker-observed head. A missing rollup is pending rather
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: string): PrVerification {
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 (pr.headRefOid?.toLowerCase() !== expectedHead.toLowerCase()) {
386
+ if (expectedHead !== undefined && headSha.toLowerCase() !== expectedHead.toLowerCase()) {
287
387
  return {
288
388
  status: "failed",
289
- reason: `PR head changed: expected ${expectedHead}, found ${pr.headRefOid ?? "unknown"}`,
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
- // 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);
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 verifyPr(url: string, expectedHead: string): Promise<PrVerification | undefined> {
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([