omp-conductor 0.3.2 → 0.3.4
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 +52 -9
- package/package.json +1 -1
- package/src/briefs/orchestrator.md +18 -4
- package/src/daemon.ts +107 -1
- package/src/escalate.ts +14 -4
- package/src/tracker/github.ts +56 -2
- package/src/types.ts +24 -0
package/README.md
CHANGED
|
@@ -312,25 +312,32 @@ Per tick, for the daemon's project:
|
|
|
312
312
|
1. **Paused?** If the pause sentinel exists, the tick claims nothing and returns.
|
|
313
313
|
Pause is checked first, so `omp-conductor pause` takes effect on the next tick
|
|
314
314
|
without signalling the process.
|
|
315
|
-
2. **
|
|
316
|
-
|
|
315
|
+
2. **Settle the green PRs.** For every run in `pushed-green`, ask the tracker
|
|
316
|
+
what became of its PR. Merged → the row becomes `merged`; closed without
|
|
317
|
+
merging → `failed`, with the PR named in the row's `lastError`. Still open, or
|
|
318
|
+
an answer that could not be obtained at all → the row is left exactly as it
|
|
319
|
+
is. Bounded by the number of green PRs awaiting a merge, which is a handful by
|
|
320
|
+
construction. This runs above admission so a row settled here frees its issue
|
|
321
|
+
in the same tick. See [what settles a green PR](#what-settles-a-green-pr).
|
|
322
|
+
3. **List the queue.** Open issues in `tracker.repo` labelled `queueLabel`.
|
|
323
|
+
4. **Filter and route.** An issue is eligible only if it carries the queue label
|
|
317
324
|
and none of the three state labels (`inProgress`, `blocked`, `failed`). Eligible
|
|
318
325
|
issues are partitioned into routable and unroutable.
|
|
319
|
-
|
|
326
|
+
5. **Escalate the unroutable** at Tier 1, quoting the repo labels actually seen and
|
|
320
327
|
the configured repo names. These are never dispatched.
|
|
321
|
-
|
|
328
|
+
6. **Check spend.** If spend since local midnight has reached `dailySpendUsd`, the
|
|
322
329
|
daemon **pauses itself**, pages at Tier 2, and returns.
|
|
323
|
-
|
|
330
|
+
7. **Check capacity.** `maxConcurrentWorkers` minus *live* workers (runs in
|
|
324
331
|
`claimed` or `running`) gives the free slots. A green PR awaiting a human
|
|
325
332
|
merge occupies its issue but not a slot: its worker is finished, and counting
|
|
326
333
|
it would let two green PRs stop the fleet. If no slot is free, the tick logs
|
|
327
334
|
and returns.
|
|
328
|
-
|
|
335
|
+
8. **Admit issues** up to the free slots, skipping any issue that already has
|
|
329
336
|
an active run — including a green PR, so a second attempt can never land on a
|
|
330
337
|
live PR. An issue that has used `maxAttemptsPerIssue` escalates at Tier 1
|
|
331
338
|
instead of being admitted.
|
|
332
|
-
|
|
333
|
-
survived step
|
|
339
|
+
9. **Ask the tracker whether the work already exists.** For each candidate that
|
|
340
|
+
survived step 8 — so at most one API call per free slot, never one per queued
|
|
334
341
|
issue — the daemon asks whether an **open** PR already closes the issue. If one
|
|
335
342
|
does, the issue is skipped with its PR named in the log. Drafts count: a draft
|
|
336
343
|
PR's branch still holds the only copy of the work. This is the guard the store
|
|
@@ -340,7 +347,7 @@ Per tick, for the daemon's project:
|
|
|
340
347
|
retried next tick: the cost of holding is five minutes, the cost of admitting
|
|
341
348
|
on an unknown is a burned attempt and a duplicate PR. Only that candidate is
|
|
342
349
|
held, so a flaky API cannot stall the rest of the queue.
|
|
343
|
-
|
|
350
|
+
10. **Dispatch** the admitted issues concurrently.
|
|
344
351
|
|
|
345
352
|
Then, per admitted issue:
|
|
346
353
|
|
|
@@ -368,6 +375,9 @@ Then, per admitted issue:
|
|
|
368
375
|
| `failed` / `killed` | swapped to `agent:failed` | dirty tree committed to the branch, then **kept** as evidence | Tier 1 |
|
|
369
376
|
| unexpected error | swapped to `agent:failed` | same | Tier 1 |
|
|
370
377
|
|
|
378
|
+
`pushed-green` is not the end of the row: a later tick settles it once the PR
|
|
379
|
+
resolves. See [what settles a green PR](#what-settles-a-green-pr).
|
|
380
|
+
|
|
371
381
|
Label swaps add the new label before removing the old one: the reverse order
|
|
372
382
|
leaves a window in which the issue carries no state label at all, which is
|
|
373
383
|
exactly the shape eligibility reads as fresh work.
|
|
@@ -405,6 +415,34 @@ clean orphan has its label released so the next tick re-claims it. Orphaned
|
|
|
405
415
|
attempts still count toward `maxAttemptsPerIssue`, so a crash loop escalates
|
|
406
416
|
instead of redispatching forever.
|
|
407
417
|
|
|
418
|
+
### What settles a green PR
|
|
419
|
+
|
|
420
|
+
`pushed-green` means the worker finished, pushed, and watched the checks go green.
|
|
421
|
+
What happens next is a human's decision, taken minutes to days later and never
|
|
422
|
+
announced to the daemon — so every tick asks the tracker about every green PR it
|
|
423
|
+
is still holding, and settles the ones that resolved:
|
|
424
|
+
|
|
425
|
+
| PR | Row becomes | Why |
|
|
426
|
+
| --- | --- | --- |
|
|
427
|
+
| merged | `merged` | The work landed. This is the state `merged` was reserved for. |
|
|
428
|
+
| closed without merging | `failed`, with the PR in `lastError` | A human read the work and said no. Leaving it `pushed-green` strands the issue forever behind a PR nobody will merge, and calling it `merged` is a lie about code that is not on the base branch. `failed` is true, and it releases the issue so a re-queue can be attempted again. |
|
|
429
|
+
| still open | unchanged | The normal steady state. Its issue must stay occupied, or a second attempt lands on the live PR. |
|
|
430
|
+
| could not be determined | unchanged | A flaky network, a revoked token, a deleted PR. An unknown answer never settles a row; the next tick asks again for free. |
|
|
431
|
+
|
|
432
|
+
The attempt counter is untouched either way, because both were real attempts. Run
|
|
433
|
+
rows are the only thing that changes: a merge normally closes the issue, and a
|
|
434
|
+
human who closed a PR is already looking at it, so what an issue's labels should
|
|
435
|
+
say next is the orchestrator's drain-duty judgement — the same division of labour
|
|
436
|
+
as a restart, above. One unreachable PR costs its own row and nothing else; the
|
|
437
|
+
rest of the sweep still settles.
|
|
438
|
+
|
|
439
|
+
Until this existed, nothing ever revisited a `pushed-green` row: the startup
|
|
440
|
+
reconciler only settles rows that held a process, and `merged` went unwritten. On
|
|
441
|
+
2026-08-07 the reference fleet reported three active runs whose PRs were all
|
|
442
|
+
merged and whose issues were all closed, through two daemon restarts — and because
|
|
443
|
+
the active set *is* the busy set, those three issues were permanently unclaimable.
|
|
444
|
+
A status page that has stopped being evidence is worse than no status page.
|
|
445
|
+
|
|
408
446
|
### Branch names
|
|
409
447
|
|
|
410
448
|
`<type>/<slug>`, where the type is `fix` when any label's last segment (after `:`
|
|
@@ -870,6 +908,11 @@ Any other path or method returns `404`. Note that `ok` reports only that the
|
|
|
870
908
|
process is serving. It does not report that the fleet is doing work: read
|
|
871
909
|
`paused` to tell those apart.
|
|
872
910
|
|
|
911
|
+
`activeRuns` counts occupied issues — live workers plus green PRs still awaiting a
|
|
912
|
+
merge — and each of those is settled by the tick once its PR resolves, so the
|
|
913
|
+
number goes back down on its own. A count that only ever grows is the bug this
|
|
914
|
+
used to have: read [what settles a green PR](#what-settles-a-green-pr).
|
|
915
|
+
|
|
873
916
|
## What a worker may and may not do
|
|
874
917
|
|
|
875
918
|
Each worker gets one brief, one worktree, one branch, and no knowledge of the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
|
@@ -66,14 +66,28 @@ by design — it is the guard that stops the next tick double-dispatching — bu
|
|
|
66
66
|
nothing removes it, so a dead worker's issue sits "in progress" forever, occupying
|
|
67
67
|
a slot that no longer exists. Compare the in-progress labels against the active
|
|
68
68
|
runs `omp-conductor status` just showed you: **an in-progress issue with no
|
|
69
|
-
matching active run is
|
|
69
|
+
matching active run is unattended — nobody is working under that label, and only
|
|
70
|
+
you can say why.**
|
|
70
71
|
|
|
71
|
-
For
|
|
72
|
+
For each, inspect what is actually there before touching the label. Read
|
|
72
73
|
the issue itself (`gh issue view <n> --json labels` — the label-filtered *list*
|
|
73
74
|
reads GitHub's eventually-consistent search index and lags label writes in both
|
|
74
75
|
directions), then the worktree (`git status --porcelain`, `git log
|
|
75
|
-
origin/main..HEAD`) and any PR.
|
|
76
|
-
|
|
76
|
+
origin/main..HEAD`) and any PR. Not every one of these is a dead worker: the
|
|
77
|
+
conductor settles a green run's row once its PR resolves, so a resolved PR whose
|
|
78
|
+
issue nobody relabelled looks identical to an orphan from the outside. Six cases,
|
|
79
|
+
checked in this order:
|
|
80
|
+
|
|
81
|
+
- **A merged PR.** Nothing died — the work landed and the row already says
|
|
82
|
+
`merged`. Never release the label: the open-PR guard only objects to an *open*
|
|
83
|
+
PR, so a re-claim would re-implement a PR that is already on the base branch.
|
|
84
|
+
Close the issue if the merge satisfied it (a merge with no closing keyword
|
|
85
|
+
leaves it open), take the queue label off, and remove the in-progress label last.
|
|
86
|
+
- **A PR closed without merging.** A human read the work and said no; the row says
|
|
87
|
+
`failed`. Read the rejection before you touch anything — most of the time a
|
|
88
|
+
review comment is a spec change. Fold what it says into the issue, then release
|
|
89
|
+
the label so the next tick can attempt it again; the attempt counter still bounds
|
|
90
|
+
it. If the answer was "this should not be built", take it off the queue instead.
|
|
77
91
|
- **An open PR that is green.** That worker finished; it just never got to report.
|
|
78
92
|
This is the "already done" case above — handle it exactly the same way. Never
|
|
79
93
|
release-and-re-claim it — a fresh worker would duplicate a finished run.
|
package/src/daemon.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { makeTracker } from "./tracker/github.ts";
|
|
|
23
23
|
import type {
|
|
24
24
|
Caps,
|
|
25
25
|
Escalation,
|
|
26
|
+
PrState,
|
|
26
27
|
ProjectConfig,
|
|
27
28
|
ReadyIssue,
|
|
28
29
|
RepoTarget,
|
|
@@ -629,6 +630,102 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
629
630
|
}
|
|
630
631
|
}
|
|
631
632
|
|
|
633
|
+
// ------------------------------------------------------------------- settlement
|
|
634
|
+
|
|
635
|
+
/** What a resolved PR turns its `pushed-green` row into. */
|
|
636
|
+
export interface Settlement {
|
|
637
|
+
state: "merged" | "failed";
|
|
638
|
+
/** The log line after `#<n> settled: `, and — for a rejection — the row's own
|
|
639
|
+
* `lastError`, because a `failed` row whose worker succeeded has to say so. */
|
|
640
|
+
reason: string;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* What one `pushed-green` row becomes now its PR has an answer, or undefined to
|
|
645
|
+
* leave the row exactly as it is.
|
|
646
|
+
*
|
|
647
|
+
* A `pushed-green` row is the only one nothing ever revisited: the worker is
|
|
648
|
+
* finished, `reconcileOrphanedRuns` only settles rows that held a process, and
|
|
649
|
+
* `merged` went unwritten from day one. So they accumulated — three of them on
|
|
650
|
+
* the reference fleet on 2026-08-07, every PR merged and every issue closed,
|
|
651
|
+
* with `/healthz` still reporting three active runs and their issues
|
|
652
|
+
* permanently unclaimable, because the busy set *is* the active set (#18).
|
|
653
|
+
*
|
|
654
|
+
* The mapping, and why each answer is the only honest one:
|
|
655
|
+
*
|
|
656
|
+
* - `merged` — the work landed. That is what `merged` was reserved for.
|
|
657
|
+
* - `closed` — a human read the work and said no. Leaving it `pushed-green`
|
|
658
|
+
* strands the issue forever behind a PR nobody will ever merge, and calling it
|
|
659
|
+
* `merged` is simply a lie about work that does not exist on the base branch.
|
|
660
|
+
* `failed` is true — the attempt did not land — and it releases the busy guard,
|
|
661
|
+
* so an issue a human re-queues can be attempted again. The attempt counter is
|
|
662
|
+
* untouched either way: this row was a real attempt, and pretending otherwise
|
|
663
|
+
* would let a rejected issue cycle past `maxAttemptsPerIssue`.
|
|
664
|
+
* - `open`, and undefined — nothing changes. Undefined is "could not tell": a
|
|
665
|
+
* flaky network, a revoked token, a deleted PR. Settling on it would record a
|
|
666
|
+
* merge that never happened, and the next tick asks again for free. An
|
|
667
|
+
* ambiguous answer must never settle a row.
|
|
668
|
+
*/
|
|
669
|
+
export function settlementFor(pr: PrState | undefined, prUrl: string): Settlement | undefined {
|
|
670
|
+
if (pr === "merged") return { state: "merged", reason: `${prUrl} merged` };
|
|
671
|
+
if (pr === "closed") return { state: "failed", reason: `${prUrl} closed without merging` };
|
|
672
|
+
return undefined;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Asks the tracker about every `pushed-green` PR and settles the ones that
|
|
677
|
+
* resolved.
|
|
678
|
+
*
|
|
679
|
+
* Effects at the call site, decision in {@link settlementFor} — the same split
|
|
680
|
+
* as `checkIntegrity`/`watchOrchestrator`. Exported like `admitCandidates`
|
|
681
|
+
* rather than kept private, because half of what has to hold is about the sweep
|
|
682
|
+
* and not the mapping: that a row without a PR costs no API call, and that one
|
|
683
|
+
* unreachable PR does not stop the others from settling.
|
|
684
|
+
*
|
|
685
|
+
* Tracker labels are deliberately not touched, exactly as
|
|
686
|
+
* {@link reconcileOrphanedRuns} does not touch them. A merge normally closes the
|
|
687
|
+
* issue, and a human who closed a PR is already looking at it; deciding what an
|
|
688
|
+
* issue's labels should say next is the orchestrator's drain duty, which reads
|
|
689
|
+
* these very rows through `omp-conductor status`.
|
|
690
|
+
*/
|
|
691
|
+
export async function settlePushedGreen(
|
|
692
|
+
d: Pick<Deps, "project" | "tracker" | "store">,
|
|
693
|
+
): Promise<void> {
|
|
694
|
+
const { project, tracker, store } = d;
|
|
695
|
+
// Filtered from the active set rather than asked for with a new query: active
|
|
696
|
+
// is live workers plus these, so the list is bounded by the worker cap plus
|
|
697
|
+
// the number of PRs awaiting a merge — a handful, by construction. A fleet
|
|
698
|
+
// where that is not a handful has a merge problem, not a dispatch one.
|
|
699
|
+
const pending = store.activeRuns(project.name).filter((r) => r.state === "pushed-green");
|
|
700
|
+
|
|
701
|
+
for (const run of pending) {
|
|
702
|
+
// Nothing to ask about. A green push means a PR, so this row should not
|
|
703
|
+
// exist; if one ever does, it must not buy a `gh` call every five minutes
|
|
704
|
+
// forever to be told nothing.
|
|
705
|
+
if (run.prUrl === undefined) continue;
|
|
706
|
+
|
|
707
|
+
let pr: PrState | undefined;
|
|
708
|
+
try {
|
|
709
|
+
pr = await tracker.prState(run.prUrl);
|
|
710
|
+
} catch (err) {
|
|
711
|
+
// Per row, like admission's held candidate. The GitHub adapter already
|
|
712
|
+
// answers undefined instead of throwing, so this catch is the port's
|
|
713
|
+
// contract rather than that adapter's behaviour — and a tracker that does
|
|
714
|
+
// throw must cost its own row, not the whole sweep.
|
|
715
|
+
log(`#${run.issue} not settled: PR state lookup failed (${errText(err)}) — retrying next tick`);
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const settlement = settlementFor(pr, run.prUrl);
|
|
720
|
+
if (settlement === undefined) continue;
|
|
721
|
+
|
|
722
|
+
const patch: Partial<RunRecord> = { state: settlement.state, endedAt: Date.now() };
|
|
723
|
+
if (settlement.state === "failed") patch.lastError = settlement.reason;
|
|
724
|
+
store.updateRun(run.id, patch);
|
|
725
|
+
log(`#${run.issue} settled: ${settlement.reason}`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
632
729
|
// -------------------------------------------------------------------- admission
|
|
633
730
|
|
|
634
731
|
/** A candidate cleared for dispatch, with the attempt number it will run as. */
|
|
@@ -778,6 +875,13 @@ async function tick(d: Deps): Promise<void> {
|
|
|
778
875
|
return;
|
|
779
876
|
}
|
|
780
877
|
|
|
878
|
+
// Above admission on purpose: a row settled this tick frees its issue for
|
|
879
|
+
// this same tick, so a merge and a re-queue no longer cost five minutes each.
|
|
880
|
+
// Above the spend cap too, which returns early — settling is bookkeeping about
|
|
881
|
+
// work already paid for, and a fleet that halts itself is exactly when an
|
|
882
|
+
// operator reads `status` and needs it to be true.
|
|
883
|
+
await settlePushedGreen(d);
|
|
884
|
+
|
|
781
885
|
// route() filters the queue through isEligible() itself, so anything already
|
|
782
886
|
// carrying a state label is gone before it gets here.
|
|
783
887
|
const { routed, unroutable } = route(await d.tracker.listReady(), project);
|
|
@@ -985,7 +1089,9 @@ export function armConductor(): void {
|
|
|
985
1089
|
*
|
|
986
1090
|
* `pushed-green` rows are deliberately left alone: they hold no process — they
|
|
987
1091
|
* are finished work waiting on a human merge, and they must keep occupying the
|
|
988
|
-
* issue so a second attempt cannot land on a live PR.
|
|
1092
|
+
* issue so a second attempt cannot land on a live PR. What eventually settles
|
|
1093
|
+
* them is {@link settlePushedGreen}, on the tick, by asking the tracker what
|
|
1094
|
+
* became of the PR — the one question a restart cannot answer by inference.
|
|
989
1095
|
*/
|
|
990
1096
|
export function reconcileOrphanedRuns(store: Store, project: string): RunRecord[] {
|
|
991
1097
|
// Live runs only: `pushed-green` holds no process, so it cannot be orphaned by
|
package/src/escalate.ts
CHANGED
|
@@ -233,22 +233,32 @@ async function sendTelegram(token: string, chatId: string, text: string): Promis
|
|
|
233
233
|
throw new Error(`telegram sendMessage failed: ${redact(reason, token)}`);
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
|
|
236
|
+
// The raw body is what gets parsed; `diagnostic` is only ever for humans.
|
|
237
|
+
// These were one variable once, and the bug that produced was expensive: the
|
|
238
|
+
// 400-char cap meant for a log line was applied first, so `JSON.parse` was
|
|
239
|
+
// handed a truncated object and threw. Telegram echoes the whole message back
|
|
240
|
+
// inside `result.text`, so every page long enough to matter overflowed and was
|
|
241
|
+
// reported as rejected *after being delivered* — with the dedup marker only
|
|
242
|
+
// written on success, that also re-sent the same page every tick. Redaction
|
|
243
|
+
// stays out of the parse for the same reason: it rewrites the very bytes the
|
|
244
|
+
// decision is read from.
|
|
245
|
+
const raw = await res.text().catch(() => "");
|
|
246
|
+
const diagnostic = redact(raw, token).slice(0, 400);
|
|
237
247
|
if (!res.ok) {
|
|
238
|
-
throw new Error(`telegram sendMessage failed: HTTP ${res.status} ${
|
|
248
|
+
throw new Error(`telegram sendMessage failed: HTTP ${res.status} ${diagnostic}`);
|
|
239
249
|
}
|
|
240
250
|
|
|
241
251
|
// Telegram answers 200 with `{"ok":false}` for plenty of real failures
|
|
242
252
|
// (kicked from the chat, bad chat_id), so the status alone proves nothing.
|
|
243
253
|
let ok = false;
|
|
244
254
|
try {
|
|
245
|
-
const parsed: unknown = JSON.parse(
|
|
255
|
+
const parsed: unknown = JSON.parse(raw);
|
|
246
256
|
ok = typeof parsed === "object" && parsed !== null && "ok" in parsed && parsed.ok === true;
|
|
247
257
|
} catch {
|
|
248
258
|
ok = false;
|
|
249
259
|
}
|
|
250
260
|
if (!ok) {
|
|
251
|
-
throw new Error(`telegram sendMessage rejected: ${
|
|
261
|
+
throw new Error(`telegram sendMessage rejected: ${diagnostic}`);
|
|
252
262
|
}
|
|
253
263
|
}
|
|
254
264
|
|
package/src/tracker/github.ts
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* ~200-400ms) and failure classification by matching human-readable stderr
|
|
11
11
|
* instead of reading a status code. Upgrade path when either bites: replace the
|
|
12
12
|
* body of `gh()` with `fetch("https://api.github.com/...")` using a token from
|
|
13
|
-
* `gh auth token`; the
|
|
13
|
+
* `gh auth token`; the eight Tracker methods above it stay untouched.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type { ProjectConfig, ReadyIssue, Tracker } from "../types.ts";
|
|
16
|
+
import type { PrState, ProjectConfig, ReadyIssue, Tracker } from "../types.ts";
|
|
17
17
|
|
|
18
18
|
/** The subset of `gh issue list --json` output this adapter reads. Fields the
|
|
19
19
|
* API can return as null are typed as such so the mapping has to handle it. */
|
|
@@ -154,6 +154,41 @@ export function firstOpenCloser(raw: string): string | undefined {
|
|
|
154
154
|
return nodes.find((n) => n !== null && n.state === "OPEN")?.url;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* A pull request URL this adapter is willing to hand to `gh`.
|
|
159
|
+
*
|
|
160
|
+
* Load-bearing, not defensive: `gh pr view` also accepts a bare number or a
|
|
161
|
+
* *branch name*, and resolves those against whatever repository the current
|
|
162
|
+
* directory belongs to. So a `prUrl` that is not a URL would not fail — it would
|
|
163
|
+
* be answered, confidently, about some other repository's pull request. A
|
|
164
|
+
* settle sweep that acted on that answer would mark the wrong run merged.
|
|
165
|
+
*/
|
|
166
|
+
const PR_URL = /^https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+/;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* GitHub's PR state spelling mapped onto {@link PrState}.
|
|
170
|
+
*
|
|
171
|
+
* Split from the call so the mapping is pinned without a network round trip,
|
|
172
|
+
* and unrecognised text answers undefined rather than being coerced: this
|
|
173
|
+
* function's caller settles a run row on the answer, and every wrong answer
|
|
174
|
+
* here rewrites history for work that has no other record. `MERGED`, `CLOSED`
|
|
175
|
+
* and `OPEN` are the only three `gh pr view --json state` emits (verified on
|
|
176
|
+
* gh 2.97.0); anything else means the CLI changed under us, which is a reason
|
|
177
|
+
* to leave the row alone and let a human look.
|
|
178
|
+
*/
|
|
179
|
+
export function prStateFrom(raw: string): PrState | undefined {
|
|
180
|
+
switch (raw.trim()) {
|
|
181
|
+
case "MERGED":
|
|
182
|
+
return "merged";
|
|
183
|
+
case "CLOSED":
|
|
184
|
+
return "closed";
|
|
185
|
+
case "OPEN":
|
|
186
|
+
return "open";
|
|
187
|
+
default:
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
157
192
|
export function makeTracker(p: ProjectConfig): Tracker {
|
|
158
193
|
const repo = p.tracker.repo;
|
|
159
194
|
|
|
@@ -248,5 +283,24 @@ export function makeTracker(p: ProjectConfig): Tracker {
|
|
|
248
283
|
|
|
249
284
|
return firstOpenCloser(raw);
|
|
250
285
|
},
|
|
286
|
+
|
|
287
|
+
async prState(url: string): Promise<PrState | undefined> {
|
|
288
|
+
// No `--repo`: a full URL is self-locating, and verified so on gh 2.97.0
|
|
289
|
+
// from a directory that is not a git repository at all — which is exactly
|
|
290
|
+
// where the daemon runs (its state directory), while the PR lives in one
|
|
291
|
+
// of the routed repos. Deriving `--repo` from the URL would only re-state
|
|
292
|
+
// what the URL already says.
|
|
293
|
+
if (!PR_URL.test(url)) return undefined;
|
|
294
|
+
try {
|
|
295
|
+
return prStateFrom(await gh(["pr", "view", url, "--json", "state", "--jq", ".state"]));
|
|
296
|
+
} catch {
|
|
297
|
+
// Never throws, per the port's contract. A deleted PR, a revoked token
|
|
298
|
+
// and a flaky network all mean "could not tell", and the caller's whole
|
|
299
|
+
// job is to leave the row alone on that — so classifying them here would
|
|
300
|
+
// buy nothing but a way to get the classification wrong. The next tick
|
|
301
|
+
// asks again for free.
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
},
|
|
251
305
|
};
|
|
252
306
|
}
|
package/src/types.ts
CHANGED
|
@@ -180,6 +180,15 @@ export interface ReadyIssue {
|
|
|
180
180
|
updatedAt: string;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* How a pull request ended, in tracker-agnostic terms. Lowercase because the
|
|
185
|
+
* loop's vocabulary is lowercase; mapping GitHub's `MERGED`/`CLOSED`/`OPEN` onto
|
|
186
|
+
* it is the adapter's job.
|
|
187
|
+
*
|
|
188
|
+
* `closed` is closed *without* merging — a human looked at the work and said no.
|
|
189
|
+
*/
|
|
190
|
+
export type PrState = "merged" | "closed" | "open";
|
|
191
|
+
|
|
183
192
|
/**
|
|
184
193
|
* Deliberately narrow so a Gitea or local-file tracker can drop in later.
|
|
185
194
|
* Nothing here is GitHub-shaped; the GitHub adapter owns `gh` entirely.
|
|
@@ -205,6 +214,19 @@ export interface Tracker {
|
|
|
205
214
|
* only party that remembers across all of those.
|
|
206
215
|
*/
|
|
207
216
|
openCloserFor(issue: number): Promise<string | undefined>;
|
|
217
|
+
/**
|
|
218
|
+
* The state of one specific pull request, or undefined when this adapter
|
|
219
|
+
* could not tell — a network failure, a deleted PR, a URL it cannot parse.
|
|
220
|
+
* Undefined never means "no".
|
|
221
|
+
*
|
|
222
|
+
* Deliberately separate from {@link Tracker.openCloserFor}, which answers a
|
|
223
|
+
* different question: that one asks "should this candidate be admitted", and
|
|
224
|
+
* answers undefined for merged, closed-unmerged, no PR at all and a deleted
|
|
225
|
+
* PR alike, because admission treats all four the same. This one asks "did
|
|
226
|
+
* *this* run's PR land", where those answers are opposites. Collapsing them
|
|
227
|
+
* is how a PR a human rejected gets recorded as merged.
|
|
228
|
+
*/
|
|
229
|
+
prState(url: string): Promise<PrState | undefined>;
|
|
208
230
|
}
|
|
209
231
|
|
|
210
232
|
/**
|
|
@@ -216,6 +238,8 @@ export type RunState =
|
|
|
216
238
|
| "claimed"
|
|
217
239
|
| "running"
|
|
218
240
|
| "pushed-green"
|
|
241
|
+
/** Its PR landed. Written by the tick's settle sweep, never by a worker: only
|
|
242
|
+
* the tracker knows, and it knows minutes to days after the run ended. */
|
|
219
243
|
| "merged"
|
|
220
244
|
| "blocked"
|
|
221
245
|
| "failed"
|