@joshuafolkken/kit 1.957.0 → 1.972.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/.claude/skills/workflow-commands/background-commands.md +3 -0
- package/.claude/skills/workflow-commands/backlogrun-child.md +75 -120
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +78 -115
- package/.claude/skills/workflow-commands/backlogrun-progress.md +82 -146
- package/.claude/skills/workflow-commands/backlogrun-steps.md +173 -229
- package/.claude/skills/workflow-commands/backlogrun.md +3 -1
- package/.claude/skills/workflow-commands/followup.md +3 -0
- package/.claude/skills/workflow-commands/fullrun.md +3 -4
- package/.claude/skills/workflow-commands/observation-filing.md +31 -25
- package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
- package/.github/workflows/pr-classification.yml +12 -1
- package/CLAUDE.md +3 -3
- package/README.md +3 -3
- package/dist/CLAUDE.md +3 -3
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
- package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNWIOJFU.js} +29 -16
- package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
- package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
- package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
- package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
- package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
- package/dist/hooks/{chunk-IJPBTDTB.js → chunk-WWTMHHZU.js} +59 -18
- package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
- package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.js} +4 -4
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +206 -176
- package/dist/josh.js +8 -4
- package/package.json +3 -3
- package/prompts/collaboration-workflow/wip-cap.md +1 -1
- package/scripts/backlog/backlog-arrival.ts +11 -1
- package/scripts/backlog/backlog-budget.ts +25 -8
- package/scripts/backlog/backlog-next.ts +45 -2
- package/scripts/backlog/backlog-offer.ts +1 -0
- package/scripts/backlog/backlog-plan-cli.ts +66 -27
- package/scripts/backlog/backlog-plan.ts +30 -2
- package/scripts/backlog/backlog-stalled.ts +9 -0
- package/scripts/backlog/backlog-waves.ts +169 -0
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/document/entry-read-budget.ts +7 -5
- package/scripts/epic/epic-lane-offer.ts +69 -4
- package/scripts/epic/epic-next.ts +2 -2
- package/scripts/epic/epic-solo.ts +147 -0
- package/scripts/epic/epic-triage.ts +47 -0
- package/scripts/gate/hook-gate-reuse.ts +7 -8
- package/scripts/git/git-closes-keyword.ts +15 -0
- package/scripts/git/git-followup-flush.ts +5 -6
- package/scripts/git/git-followup-issue-close.ts +75 -0
- package/scripts/git/git-pr-followup-wrapup.ts +36 -6
- package/scripts/git/git-pr-followup.ts +9 -16
- package/scripts/git/git-staging.ts +33 -20
- package/scripts/git/issue-labels.ts +23 -0
- package/scripts/hooks/stop-guard.ts +1 -0
- package/scripts/init/init-install.ts +64 -0
- package/scripts/init/init.ts +38 -11
- package/scripts/issue/issue-closing-pr.ts +93 -0
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
- package/scripts/josh/josh-commands-project.ts +5 -1
- package/scripts/josh/josh-commands-workflow.ts +3 -3
- package/scripts/observations/observation-ledger-prepare.ts +31 -0
- package/scripts/observations/observation-ledger.ts +5 -5
- package/scripts/observations/observations-flush.ts +11 -30
- package/scripts/rules/implementation-cut.ts +49 -16
- package/scripts/rules/lane-background.ts +25 -5
- package/scripts/rules/stop-rules.ts +16 -4
- package/scripts/run/run-carry-cli.ts +21 -0
- package/scripts/run/run-cut-cli.ts +92 -110
- package/scripts/run/run-cut-report.ts +167 -0
- package/scripts/run/run-cut.ts +23 -7
- package/scripts/run/run-entry-cli.ts +36 -2
- package/scripts/run/run-headless.ts +1 -0
- package/scripts/run/run-hold-cli.ts +36 -15
- package/scripts/run/run-hold.ts +15 -4
- package/scripts/run/run-merge-cli.ts +28 -14
- package/scripts/run/run-merge-steps.ts +38 -2
- package/scripts/run/run-merge.ts +41 -3
- package/scripts/run/run-prep-cli.ts +8 -2
- package/scripts/run/run-prep-locate.ts +165 -0
- package/scripts/run/run-prep.ts +12 -1
- package/scripts/run/run-step-cli.ts +16 -0
- package/scripts/run/run-step.ts +28 -4
- package/scripts/run/run-tail-cli.ts +4 -2
- package/scripts/run/run-wake-work.ts +4 -0
- package/scripts/safe-chain/ci-installer-pin.ts +8 -4
- package/scripts/time-runtime/time-background.ts +11 -0
- package/scripts/time-runtime/time-transcript-line.ts +4 -0
- package/templates/pnpm-workspace.static.yaml +2 -0
|
@@ -16,7 +16,7 @@ import { epic_lane_offer, type LaneOffer, type LaneRequest } from './epic-lane-o
|
|
|
16
16
|
import { epic_next_read, type EpicRead, type SnapshotReads } from './epic-next-read'
|
|
17
17
|
import { epic_next_views, type EpicView } from './epic-next-views'
|
|
18
18
|
import { epic_outside_blocker } from './epic-outside-blocker'
|
|
19
|
-
import { epic_report, type EpicNextResult
|
|
19
|
+
import { epic_report, type EpicNextResult } from './epic-report'
|
|
20
20
|
|
|
21
21
|
// `josh epic:next <E…>` — which of the named epics' children can be started right now, bundled per
|
|
22
22
|
// repository, and what the rest are waiting on (joshuafolkken/kit#860).
|
|
@@ -210,7 +210,7 @@ function refuse(reason: string): number {
|
|
|
210
210
|
// The verdict as it applies to *this* repository. `run` never reaches a caller here: it means some
|
|
211
211
|
// other repository has work, which for this session is something to wait on rather than a state its
|
|
212
212
|
// loop has a branch for. The whole-run timeout is what bounds that wait.
|
|
213
|
-
function repo_verdict(verdict:
|
|
213
|
+
function repo_verdict(verdict: LaneOffer['verdict']): LaneOffer['verdict'] {
|
|
214
214
|
return verdict === 'run' ? 'wait' : verdict
|
|
215
215
|
}
|
|
216
216
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { has_any_label, has_label_name, RUN_SOLO_LABEL } from '#scripts/git/issue-labels'
|
|
2
|
+
import { issue_citation } from '#scripts/rules/issue-citation'
|
|
3
|
+
import { epic_busy, type BusyRead } from './epic-busy'
|
|
4
|
+
import type { EpicChild } from './epic-graph'
|
|
5
|
+
import { epic_report, type EpicNextResult } from './epic-report'
|
|
6
|
+
|
|
7
|
+
// The `run:solo` gate on what a run is offered (joshuafolkken/kit#2776).
|
|
8
|
+
//
|
|
9
|
+
// `wip-cap.md`'s run-alone section says an issue whose defect reaches the verification path runs
|
|
10
|
+
// alone, and until this gate that was a judgement made at dispatch — the offer handed such an issue
|
|
11
|
+
// out beside others and `backlog:drive` launched every number it was given. The label records the
|
|
12
|
+
// judgement once; this applies it to every ask, so it holds without an agent in the loop.
|
|
13
|
+
//
|
|
14
|
+
// **Both offer paths call `select`**: `backlog:next` (the pool drain, through `gate`) and
|
|
15
|
+
// `epic:next --lanes` (a named epic's children, `epic-lane-offer.ts`). One rule, so the two cannot
|
|
16
|
+
// disagree about what may start beside what.
|
|
17
|
+
//
|
|
18
|
+
// Three rules, all against the repository's `in-progress` holders (`epic_busy.read_repository`):
|
|
19
|
+
// a running `run:solo` issue lets nothing new start; a `run:solo` candidate starts only into an idle
|
|
20
|
+
// repository and then alone; and nothing ranked below a waiting `run:solo` candidate is offered past
|
|
21
|
+
// it, so it is not starved by the lanes it is waiting on. A listing that could not be read is not an
|
|
22
|
+
// idle repository — the same fail-safe answer `epic_busy` gives.
|
|
23
|
+
//
|
|
24
|
+
// **An idle repository takes its first `run:solo` candidate ahead of the ranking**
|
|
25
|
+
// (joshuafolkken/kit#2778). An issue carries the label because its defect reaches the verification
|
|
26
|
+
// path, and `wip-cap.md` resumes a batch only once that has merged — so an ordinary candidate ranked
|
|
27
|
+
// ahead of it would spend a whole wave running alone, then hold the fix back behind its own merge.
|
|
28
|
+
// While lanes are running the ranking stands: nothing overtakes a waiting `run:solo` candidate, and
|
|
29
|
+
// the ones ahead of it keep their lanes.
|
|
30
|
+
|
|
31
|
+
const SOLO_LABELS: ReadonlySet<string> = new Set([RUN_SOLO_LABEL])
|
|
32
|
+
|
|
33
|
+
// Which of the ordered candidates may start, which are held back, and why when a reason is worth
|
|
34
|
+
// saying. `offered` keeps the candidates' own order.
|
|
35
|
+
interface SoloSelection {
|
|
36
|
+
offered: ReadonlyArray<EpicChild>
|
|
37
|
+
withheld: ReadonlyArray<EpicChild>
|
|
38
|
+
notice?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SoloGate {
|
|
42
|
+
result: EpicNextResult
|
|
43
|
+
notice?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function is_solo(child: EpicChild): boolean {
|
|
47
|
+
return has_label_name(child.labels, RUN_SOLO_LABEL)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function solo_holder(read: BusyRead): number | undefined {
|
|
51
|
+
if (read.kind !== 'busy') return undefined
|
|
52
|
+
|
|
53
|
+
return read.issues.find((issue) => has_any_label(issue.labels, SOLO_LABELS))?.number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function held_message(holder: number, repo: string): string {
|
|
57
|
+
return issue_citation.linkify(
|
|
58
|
+
`#${String(holder)} carries \`${RUN_SOLO_LABEL}\` and is running in ${repo}; nothing else starts until it merges or is parked.`,
|
|
59
|
+
repo,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function waiting_message(solo: number, repo: string): string {
|
|
64
|
+
return issue_citation.linkify(
|
|
65
|
+
`#${String(solo)} carries \`${RUN_SOLO_LABEL}\` and waits for ${repo}'s running lanes to finish; nothing ranked below it is offered past it.`,
|
|
66
|
+
repo,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function first_of(children: ReadonlyArray<EpicChild>, keep: number): SoloSelection {
|
|
71
|
+
return { offered: children.slice(0, keep), withheld: children.slice(keep) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function none_of(children: ReadonlyArray<EpicChild>, notice: string): SoloSelection {
|
|
75
|
+
return { offered: [], withheld: [...children], notice }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The ranking with the first `run:solo` candidate moved to the head — but only into an idle
|
|
79
|
+
// repository, the one place it may start. Anywhere else the ranking is returned as it was.
|
|
80
|
+
function prefer(children: ReadonlyArray<EpicChild>, read: BusyRead): ReadonlyArray<EpicChild> {
|
|
81
|
+
const solo = children.find((child) => is_solo(child))
|
|
82
|
+
|
|
83
|
+
if (solo === undefined || read.kind !== 'idle') return children
|
|
84
|
+
|
|
85
|
+
return [solo, ...children.filter((child) => child !== solo)]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Where the first `run:solo` candidate sits decides what starts: everything when there is none, the
|
|
89
|
+
// candidates ahead of it when lanes are running, and it alone into an idle repository.
|
|
90
|
+
function select_candidates(
|
|
91
|
+
children: ReadonlyArray<EpicChild>,
|
|
92
|
+
read: BusyRead,
|
|
93
|
+
repo: string,
|
|
94
|
+
): SoloSelection {
|
|
95
|
+
const ordered = prefer(children, read)
|
|
96
|
+
const index = ordered.findIndex((child) => is_solo(child))
|
|
97
|
+
const solo = ordered[index]
|
|
98
|
+
|
|
99
|
+
if (solo === undefined) return first_of(ordered, ordered.length)
|
|
100
|
+
if (index > 0) return first_of(ordered, index)
|
|
101
|
+
if (read.kind === 'idle') return first_of(ordered, 1)
|
|
102
|
+
|
|
103
|
+
return none_of(ordered, waiting_message(solo.number, repo))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// An unreadable or truncated listing keeps nothing: without it a running `run:solo` issue cannot be
|
|
107
|
+
// ruled out.
|
|
108
|
+
function select(children: ReadonlyArray<EpicChild>, read: BusyRead, repo: string): SoloSelection {
|
|
109
|
+
if (read.kind === 'unreadable' || read.kind === 'truncated') {
|
|
110
|
+
return none_of(children, epic_busy.busy_reason(read, repo, 0))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const holder = solo_holder(read)
|
|
114
|
+
|
|
115
|
+
if (holder !== undefined) return none_of(children, held_message(holder, repo))
|
|
116
|
+
|
|
117
|
+
return select_candidates(children, read, repo)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The offered candidates of `repo` stay; the withheld ones move to `waiting`, which is where the
|
|
121
|
+
// report already lists children that time alone will release.
|
|
122
|
+
function withhold(result: EpicNextResult, repo: string, selection: SoloSelection): EpicNextResult {
|
|
123
|
+
return {
|
|
124
|
+
...result,
|
|
125
|
+
candidates: result.candidates.map((bundle) =>
|
|
126
|
+
bundle.repo === repo ? { ...bundle, children: selection.offered } : bundle,
|
|
127
|
+
),
|
|
128
|
+
waiting: [...result.waiting, ...selection.withheld],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Only a `run` answer offers anything, so every other verdict passes through untouched.
|
|
133
|
+
function gate(result: EpicNextResult, read: BusyRead, repo: string): SoloGate {
|
|
134
|
+
if (result.verdict !== 'run') return { result }
|
|
135
|
+
|
|
136
|
+
const selection = select(epic_report.candidates_for_repo(result, repo), read, repo)
|
|
137
|
+
const gated = withhold(result, repo, selection)
|
|
138
|
+
|
|
139
|
+
return selection.notice === undefined
|
|
140
|
+
? { result: gated }
|
|
141
|
+
: { result: gated, notice: selection.notice }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const epic_solo = { gate, is_solo, prefer, select }
|
|
145
|
+
|
|
146
|
+
export type { SoloGate, SoloSelection }
|
|
147
|
+
export { epic_solo }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { has_label_name, RUN_LANE_LABEL, RUN_SOLO_LABEL } from '#scripts/git/issue-labels'
|
|
2
|
+
import { issue_citation } from '#scripts/rules/issue-citation'
|
|
3
|
+
import type { EpicChild } from './epic-graph'
|
|
4
|
+
|
|
5
|
+
// The triage gate on what a run is offered (joshuafolkken/kit#2779).
|
|
6
|
+
//
|
|
7
|
+
// `run:solo` (joshuafolkken/kit#2776) records that an issue runs alone, but its absence said nothing:
|
|
8
|
+
// "judged safe beside others" and "never judged" looked the same, and an issue opted in mid-run reached
|
|
9
|
+
// `backlog:drive` without any agent reading it. `run:lane` records the other answer, so an issue with
|
|
10
|
+
// neither label is one nobody has judged — and such an issue may be one that must run alone.
|
|
11
|
+
//
|
|
12
|
+
// **One untriaged candidate withholds every candidate**, not just itself: whether the others may start
|
|
13
|
+
// depends on whether it is `run:solo`, which is exactly what is not known yet. The answer is the
|
|
14
|
+
// `triage` verdict, which `backlog:drive` hands back to the parent rather than acting on.
|
|
15
|
+
//
|
|
16
|
+
// **Both offer paths call `untriaged`**: `backlog:next` (the pool drain) and `epic:next --lanes`
|
|
17
|
+
// (a named epic's children, `epic-lane-offer.ts`) — the same pairing `epic_solo` keeps.
|
|
18
|
+
|
|
19
|
+
type TriageVerdict = 'triage'
|
|
20
|
+
|
|
21
|
+
const TRIAGE_VERDICT: TriageVerdict = 'triage'
|
|
22
|
+
|
|
23
|
+
function is_triaged(child: EpicChild): boolean {
|
|
24
|
+
const { labels } = child
|
|
25
|
+
|
|
26
|
+
return has_label_name(labels, RUN_SOLO_LABEL) || has_label_name(labels, RUN_LANE_LABEL)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The candidates nobody has judged, in the order they were given.
|
|
30
|
+
function untriaged(children: ReadonlyArray<EpicChild>): ReadonlyArray<EpicChild> {
|
|
31
|
+
return children.filter((child) => !is_triaged(child))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Names every untriaged number, so the parent reading standard error knows which issues to judge.
|
|
35
|
+
function message(children: ReadonlyArray<EpicChild>, repo: string): string {
|
|
36
|
+
const numbers = children.map((child) => `#${String(child.number)}`).join(', ')
|
|
37
|
+
|
|
38
|
+
return issue_citation.linkify(
|
|
39
|
+
`Untriaged: ${numbers} carry neither \`${RUN_SOLO_LABEL}\` nor \`${RUN_LANE_LABEL}\` in ${repo}, so nothing is offered until each is judged. Read each one, record its blocked-by order and one of the two labels with a comment saying why, then ask again.`,
|
|
40
|
+
repo,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const epic_triage = { TRIAGE_VERDICT, is_triaged, message, untriaged }
|
|
45
|
+
|
|
46
|
+
export type { TriageVerdict }
|
|
47
|
+
export { epic_triage }
|
|
@@ -53,14 +53,13 @@ function is_force_requested(force_environment: string): boolean {
|
|
|
53
53
|
// nothing" for "git could not be asked". Every predicate below treats the two differently.
|
|
54
54
|
//
|
|
55
55
|
// **The observation ledger is dropped from the reading, and it is the one line that may be**
|
|
56
|
-
// (joshuafolkken/kit#1756).
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// ledger says, and the ledger's own content is checked by the CI of the pull request the flush opens.
|
|
56
|
+
// (joshuafolkken/kit#1756). Lines are appended throughout a run — a review's record lands after its
|
|
57
|
+
// gate — and every condition below reads a non-empty status as "this operation carries a tree no
|
|
58
|
+
// check has read". Left in, one ledger line would send every commit and every push in the primary
|
|
59
|
+
// checkout back to the full gate, which is the reuse this module exists to grant. **Dropping it is
|
|
60
|
+
// sound because the ledger is no code any check runs**: `pnpm josh git` stages it with the run's
|
|
61
|
+
// commit only after its grammar has been parsed (joshuafolkken/kit#2763), and the rest of its content
|
|
62
|
+
// is checked by the CI of the pull request that carries it.
|
|
64
63
|
async function read_status_lines(): Promise<ReadonlyArray<string> | undefined> {
|
|
65
64
|
try {
|
|
66
65
|
const status = await git_command.status()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Captures the number, so the one pattern answers every question asked of a PR body: whether the
|
|
2
|
+
// linked issue will auto-close, which issue that is when the invocation did not say
|
|
3
|
+
// (joshuafolkken/kit#1539), and whether a merged pull request closes a child a `backlogrun` judged
|
|
4
|
+
// unfinished (joshuafolkken/kit#2769). A second pattern would be two readings of "closes #N" to disagree.
|
|
5
|
+
const CLOSES_PATTERN = /closes\s+#(\d+)/iu
|
|
6
|
+
|
|
7
|
+
function parse_closes_issue_number(body: string | undefined): string | undefined {
|
|
8
|
+
if (body === undefined) return undefined
|
|
9
|
+
|
|
10
|
+
return CLOSES_PATTERN.exec(body)?.[1]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const git_closes_keyword = { parse_closes_issue_number }
|
|
14
|
+
|
|
15
|
+
export { git_closes_keyword }
|
|
@@ -5,12 +5,11 @@ import { git_command } from './git-command'
|
|
|
5
5
|
import { git_followup_cleanup } from './git-followup-cleanup'
|
|
6
6
|
import { main_sync } from './main-sync'
|
|
7
7
|
|
|
8
|
-
// The
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// `pnpm josh followup`, the one step that knows the merge has landed.
|
|
8
|
+
// The residual commit path for the observation ledger (joshuafolkken/kit#1810). A run's appended lines
|
|
9
|
+
// ride its own commit (joshuafolkken/kit#2763), so after the merge the ledger is usually clean and this
|
|
10
|
+
// short-circuits; what it still catches is a line appended after the commit — a second review round's
|
|
11
|
+
// record — which would otherwise stay in the working tree until a person remembered to flush it. This
|
|
12
|
+
// wires the flush into `pnpm josh followup`, the one step that knows the merge has landed.
|
|
14
13
|
//
|
|
15
14
|
// **It runs before the working-tree hold is released** (`scripts-ai/git-followup-finish.ts`, whose
|
|
16
15
|
// `finish()` runs after `git_pr_followup.run()` returns), because a flush switches branches, and a
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { poll, type PollOptions } from '#scripts/lib/poll'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { git_epic_parse } from './git-epic-parse'
|
|
4
|
+
import { git_gh_command } from './git-gh-command'
|
|
5
|
+
import { parse_json_object_safe } from './parse-json-array'
|
|
6
|
+
|
|
7
|
+
// **A merged pull request whose `closes #N` GitHub did not apply** (joshuafolkken/kit#2770). From
|
|
8
|
+
// 2026-09-30 06:34Z every merge `followup` made left its issue open — seven in a row, where every
|
|
9
|
+
// earlier one was closed by the pull request within a second — although the merge request, the
|
|
10
|
+
// merging account and the `closes #N` first line were all the same as before. The run then reported
|
|
11
|
+
// success, and a `backlogrun` read the still-open child as a failure and stopped.
|
|
12
|
+
//
|
|
13
|
+
// **The issue's state is what is checked, not `closingIssuesReferences`.** That field is GraphQL-only
|
|
14
|
+
// (a cloud session is answered 403 there, joshuafolkken/kit#1022) and it is not the fact that failed:
|
|
15
|
+
// PR #2768 answered it empty at the time and names #2761 now, while #2761 was never closed by it. The
|
|
16
|
+
// observable defect is an issue left open, so that is what is read, and what is repaired.
|
|
17
|
+
//
|
|
18
|
+
// **A short wait first**, because GitHub applies the keyword asynchronously — the closes it did apply
|
|
19
|
+
// landed within a second of the merge. Closing on the first read would take the close away from the
|
|
20
|
+
// pull request on every run that merely raced it.
|
|
21
|
+
const CLOSE_WAIT: PollOptions = { attempts: 3, interval_ms: 2000 }
|
|
22
|
+
|
|
23
|
+
const CLOSE_RECOVERY = 'gh api -X PATCH repos/{owner}/{repo}/issues/<N> -f state=closed'
|
|
24
|
+
|
|
25
|
+
const state_read_schema = z.object({ state: z.string().optional() })
|
|
26
|
+
|
|
27
|
+
async function is_issue_closed(issue_number: string): Promise<boolean> {
|
|
28
|
+
const raw = await git_gh_command.issue_get_state_and_relations(issue_number)
|
|
29
|
+
const parsed = raw === undefined ? undefined : parse_json_object_safe(raw, state_read_schema)
|
|
30
|
+
|
|
31
|
+
return git_epic_parse.is_state_closed(parsed?.state)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const FOLLOWUP_CLOSER = 'pnpm josh followup'
|
|
35
|
+
|
|
36
|
+
// English, like every other string a script posts: the comment says why the close came from this
|
|
37
|
+
// command rather than from the pull request, so a reader of the issue is not left guessing. `closer`
|
|
38
|
+
// names the command that closed it — `followup` at the merge, or `run:merge` when a `backlogrun`
|
|
39
|
+
// finds the child still open afterwards (joshuafolkken/kit#2769).
|
|
40
|
+
function build_close_comment(pr_url: string | undefined, closer: string = FOLLOWUP_CLOSER): string {
|
|
41
|
+
return (
|
|
42
|
+
`Closed by \`${closer}\`: ${pr_url ?? 'the merged pull request'} merged, ` +
|
|
43
|
+
'but GitHub did not apply its ' +
|
|
44
|
+
'`closes #N` keyword (joshuafolkken/kit#2770).'
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// **Throws when the issue is still open**, so the guard around it reports the failure with the
|
|
49
|
+
// command that finishes it by hand — an issue left open after a merge is the one thing this step
|
|
50
|
+
// exists to stop going unnoticed.
|
|
51
|
+
async function ensure_issue_closed(
|
|
52
|
+
input: { issue_number: string | undefined; pr_url: string | undefined; closer?: string },
|
|
53
|
+
wait: PollOptions = CLOSE_WAIT,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const { issue_number } = input
|
|
56
|
+
const is_done =
|
|
57
|
+
issue_number === undefined ||
|
|
58
|
+
(await poll.poll_until(async () => await is_issue_closed(issue_number), wait))
|
|
59
|
+
if (is_done) return
|
|
60
|
+
|
|
61
|
+
console.warn(`⚠️ Issue #${issue_number} is still open after the merge — closing it.`)
|
|
62
|
+
const comment = build_close_comment(input.pr_url, input.closer)
|
|
63
|
+
|
|
64
|
+
if (!(await git_gh_command.issue_close(issue_number, comment))) {
|
|
65
|
+
throw new Error(`gh api could not close issue #${issue_number}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const git_followup_issue_close = {
|
|
70
|
+
ensure_issue_closed,
|
|
71
|
+
build_close_comment,
|
|
72
|
+
CLOSE_RECOVERY,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { git_followup_issue_close }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { git_epic_close } from './git-epic-close'
|
|
2
2
|
import { git_followup_cleanup } from './git-followup-cleanup'
|
|
3
3
|
import { git_followup_flush } from './git-followup-flush'
|
|
4
|
+
import { git_followup_issue_close } from './git-followup-issue-close'
|
|
4
5
|
import { git_followup_label } from './git-followup-label'
|
|
5
6
|
import { git_followup_stages, type StageLog } from './git-followup-stages'
|
|
6
7
|
import { git_gh_command } from './git-gh-command'
|
|
@@ -25,6 +26,9 @@ const IN_PROGRESS_RECOVERY = 'gh api -X DELETE repos/{owner}/{repo}/issues/<N>/l
|
|
|
25
26
|
interface WrapupInput {
|
|
26
27
|
branch_name: string
|
|
27
28
|
issue_number: string | undefined
|
|
29
|
+
// The number the pull request body's `closes #N` names — the only issue the merge claimed to
|
|
30
|
+
// close, so the only one the merged-issue close may write to (joshuafolkken/kit#2770).
|
|
31
|
+
closes_number: string | undefined
|
|
28
32
|
notify_config: GitNotifyConfig | undefined
|
|
29
33
|
pr_url: string | undefined
|
|
30
34
|
should_merge: boolean
|
|
@@ -164,9 +168,30 @@ async function in_progress_step(input: WrapupInput): Promise<void> {
|
|
|
164
168
|
})
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
// **The merged issue's close, confirmed rather than assumed** (joshuafolkken/kit#2770): GitHub stopped
|
|
172
|
+
// applying `closes #N` on some merges, and nothing noticed. The epic auto-close does not wait on it —
|
|
173
|
+
// it already excludes the merged issue from its reads — so it runs beside the other steps. It acts on
|
|
174
|
+
// the body's `closes #N` rather than the run's issue number: a pull request that closes nothing, or
|
|
175
|
+
// another issue, never closes the one the invocation named.
|
|
176
|
+
async function issue_close_step(input: WrapupInput): Promise<void> {
|
|
177
|
+
if (!input.should_merge) return
|
|
178
|
+
|
|
179
|
+
await git_followup_cleanup.run_guarded_step(input.should_merge, {
|
|
180
|
+
label: 'The merged issue close',
|
|
181
|
+
recovery: git_followup_issue_close.CLOSE_RECOVERY,
|
|
182
|
+
run: async () => {
|
|
183
|
+
await git_followup_issue_close.ensure_issue_closed({
|
|
184
|
+
issue_number: input.closes_number,
|
|
185
|
+
pr_url: input.pr_url,
|
|
186
|
+
})
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
167
191
|
// **The steps after the merge are issued together** (joshuafolkken/kit#1446). The completion
|
|
168
|
-
// comment writes to the issue or the pull request, the auto-close reads the open epics,
|
|
169
|
-
//
|
|
192
|
+
// comment writes to the issue or the pull request, the auto-close reads the open epics, the label
|
|
193
|
+
// removal reads and writes the issue's labels, and the merged-issue close reads and may close the
|
|
194
|
+
// issue; none needs another's answer, and measured
|
|
170
195
|
// serially the first two were 1.9 s and 3.1 s of `followup`'s own clock. Past the merge none can
|
|
171
196
|
// reject another — `run_guarded_step` reports the failure and answers instead — so they settle
|
|
172
197
|
// independently and the stage costs the longest of them rather than their sum.
|
|
@@ -178,9 +203,9 @@ async function in_progress_step(input: WrapupInput): Promise<void> {
|
|
|
178
203
|
// (`git-followup-cleanup.ts`), which is easier to read as one sequence than as a settled set.
|
|
179
204
|
//
|
|
180
205
|
// **What the overlap costs, stated rather than glossed**: mutating requests now go out at once
|
|
181
|
-
// against one repository — a comment write, an auto-close that comments on and closes epics,
|
|
182
|
-
//
|
|
183
|
-
// of them are guarded, so the worst case is a cleanup reported as unfinished rather than a failed
|
|
206
|
+
// against one repository — a comment write, an auto-close that comments on and closes epics, a label
|
|
207
|
+
// delete, and the merged issue's close when GitHub did not apply it — which is what GitHub's
|
|
208
|
+
// secondary rate limit guidance is about. Past the merge all of them are guarded, so the worst case is a cleanup reported as unfinished rather than a failed
|
|
184
209
|
// run, and this is a handful of requests rather than a fan-out. **Their console output can
|
|
185
210
|
// interleave too**: the guard's warning and its recovery line are printed as they happen, so an
|
|
186
211
|
// auto-close progress line can land between them. The recovery line names its own command and stands
|
|
@@ -194,7 +219,12 @@ async function run_tail_steps(input: WrapupInput): Promise<void> {
|
|
|
194
219
|
return
|
|
195
220
|
}
|
|
196
221
|
|
|
197
|
-
await Promise.all([
|
|
222
|
+
await Promise.all([
|
|
223
|
+
notify_step(input),
|
|
224
|
+
epic_close_step(input),
|
|
225
|
+
in_progress_step(input),
|
|
226
|
+
issue_close_step(input),
|
|
227
|
+
])
|
|
198
228
|
}
|
|
199
229
|
|
|
200
230
|
async function run_wrapup(input: WrapupInput, log: StageLog): Promise<void> {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { git_closes_keyword } from './git-closes-keyword'
|
|
1
2
|
import { git_followup_pending } from './git-followup-pending'
|
|
2
3
|
import { git_followup_stages, type StageLog } from './git-followup-stages'
|
|
3
4
|
import { git_gh_command } from './git-gh-command'
|
|
@@ -15,10 +16,6 @@ import { telegram_notify, type TelegramSendInput, type TelegramTaskType } from '
|
|
|
15
16
|
|
|
16
17
|
const { STAGE, lap } = git_followup_stages
|
|
17
18
|
|
|
18
|
-
// Captures the number, so the one pattern answers both questions the run asks of a PR body: whether
|
|
19
|
-
// the linked issue will auto-close, and — since joshuafolkken/kit#1539 — which issue that is when the
|
|
20
|
-
// invocation did not say. A second pattern here would be two readings of "closes #N" to disagree.
|
|
21
|
-
const CLOSES_PATTERN = /closes\s+#(\d+)/iu
|
|
22
19
|
// Named so a test can pin the note without restating it (joshuafolkken/kit#999).
|
|
23
20
|
const WATCH_FAILED_NOTE = 'pr checks --watch failed; falling through to polling'
|
|
24
21
|
const REPO_NAME_SEPARATOR = '/'
|
|
@@ -69,11 +66,7 @@ interface FollowupInput {
|
|
|
69
66
|
should_merge: boolean
|
|
70
67
|
}
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
if (body === undefined) return undefined
|
|
74
|
-
|
|
75
|
-
return CLOSES_PATTERN.exec(body)?.[1]
|
|
76
|
-
}
|
|
69
|
+
const { parse_closes_issue_number } = git_closes_keyword
|
|
77
70
|
|
|
78
71
|
function has_closes_keyword(body: string | undefined): boolean {
|
|
79
72
|
return parse_closes_issue_number(body) !== undefined
|
|
@@ -234,6 +227,7 @@ async function read_issue_title(
|
|
|
234
227
|
|
|
235
228
|
interface RunContext {
|
|
236
229
|
issue_number: string | undefined
|
|
230
|
+
closes_number: string | undefined
|
|
237
231
|
context: TelegramContext
|
|
238
232
|
}
|
|
239
233
|
|
|
@@ -256,19 +250,17 @@ async function read_run_context(input: {
|
|
|
256
250
|
issue_number: string | undefined
|
|
257
251
|
}): Promise<RunContext> {
|
|
258
252
|
const closes = warn_if_missing_closes(input.branch_name)
|
|
259
|
-
const owner = git_gh_command.repo_get_name_with_owner()
|
|
260
|
-
const url = git_gh_command.pr_get_url(input.branch_name)
|
|
261
|
-
const title = read_issue_title(input.issue_number, closes)
|
|
262
253
|
const [closes_number, name_with_owner, pr_url, issue_title] = await Promise.all([
|
|
263
254
|
closes,
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
255
|
+
git_gh_command.repo_get_name_with_owner(),
|
|
256
|
+
git_gh_command.pr_get_url(input.branch_name),
|
|
257
|
+
read_issue_title(input.issue_number, closes),
|
|
267
258
|
])
|
|
268
259
|
const issue_number = input.issue_number ?? closes_number
|
|
269
260
|
|
|
270
261
|
return {
|
|
271
262
|
issue_number,
|
|
263
|
+
closes_number,
|
|
272
264
|
context: {
|
|
273
265
|
repo_name: parse_repo_name(name_with_owner),
|
|
274
266
|
issue_title,
|
|
@@ -373,7 +365,7 @@ async function run_review_checks(
|
|
|
373
365
|
// the epic close all name the same issue — and so the caller's own tail can record a run whose number
|
|
374
366
|
// only the pull request knew.
|
|
375
367
|
async function run_stages(input: FollowupInput, log: StageLog): Promise<string | undefined> {
|
|
376
|
-
const { issue_number, context } = await read_run_context(input)
|
|
368
|
+
const { issue_number, closes_number, context } = await read_run_context(input)
|
|
377
369
|
|
|
378
370
|
lap(log, STAGE.closes_and_context)
|
|
379
371
|
const checks = await run_review_checks(input, context, log)
|
|
@@ -385,6 +377,7 @@ async function run_stages(input: FollowupInput, log: StageLog): Promise<string |
|
|
|
385
377
|
{
|
|
386
378
|
branch_name: input.branch_name,
|
|
387
379
|
issue_number,
|
|
380
|
+
closes_number,
|
|
388
381
|
notify_config: input.notify_config,
|
|
389
382
|
pr_url: context.pr_url,
|
|
390
383
|
should_merge: input.should_merge,
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
observation_ledger,
|
|
3
3
|
OBSERVATION_LEDGER_PATHS,
|
|
4
4
|
} from '#scripts/observations/observation-ledger'
|
|
5
|
+
import { observation_ledger_prepare } from '#scripts/observations/observation-ledger-prepare'
|
|
5
6
|
import { git_command } from './git-command'
|
|
6
7
|
import { git_prompt } from './git-prompt'
|
|
7
8
|
import { git_status } from './git-status'
|
|
@@ -67,18 +68,26 @@ async function stage_untracked_files(files: ReadonlyArray<string>): Promise<void
|
|
|
67
68
|
for (const file of files) console.info(` + ${file}`)
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
// **The observation ledger
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
71
|
+
// **The observation ledger rides the run's own commit** (joshuafolkken/kit#2763). It used to be
|
|
72
|
+
// excluded here (joshuafolkken/kit#1756) and committed afterwards by `pnpm josh observations:flush` as
|
|
73
|
+
// a pull request of its own — a second branch, CI wait and merge behind every run that appended a
|
|
74
|
+
// line, 144 of them in one month. Staged with the run instead, the lines are reviewed and merged with
|
|
75
|
+
// the pull request whose run recorded them, while CI for that pull request is the only wait.
|
|
76
|
+
//
|
|
77
|
+
// **A line that breaks the grammar keeps the old exclusion** (joshuafolkken/kit#2123): the commit
|
|
78
|
+
// goes ahead without the ledger, and `pnpm josh observations:flush` — which refuses the same line and
|
|
79
|
+
// names it — is where it is repaired. The migration is run first, as the flush runs it, so a line
|
|
80
|
+
// still on the old path (joshuafolkken/kit#2724) is committed at the new one. A lane has no ledger
|
|
81
|
+
// line of its own to carry: its writers append to the primary checkout's.
|
|
82
|
+
async function ledger_exclusions(): Promise<ReadonlyArray<string>> {
|
|
83
|
+
const broken = await observation_ledger_prepare.prepare(await git_command.repository_root())
|
|
84
|
+
|
|
85
|
+
return broken.length > 0 ? OBSERVATION_LEDGER_PATHS : []
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A migration claim is a copy mid-move, never the ledger itself, so it is never staged.
|
|
89
|
+
function is_stageable(file_path: string, excluded: ReadonlyArray<string>): boolean {
|
|
90
|
+
return !observation_ledger.is_migration_claim(file_path) && !excluded.includes(file_path)
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
// **Said out loud, because the alternative is an unexplained failure.** With the ledger the only
|
|
@@ -89,27 +98,31 @@ function is_stageable(file_path: string): boolean {
|
|
|
89
98
|
// **The paths are parsed rather than matched as substrings**: `docs/maintainers/observations.md.bak` contains the
|
|
90
99
|
// ledger's path, and a hint naming a file the exclusion never touched is a hint that teaches the
|
|
91
100
|
// reader to ignore it.
|
|
92
|
-
function report_excluded_paths(status_output: string): void {
|
|
93
|
-
const
|
|
101
|
+
function report_excluded_paths(status_output: string, excluded: ReadonlyArray<string>): void {
|
|
102
|
+
const left_out = status_output
|
|
94
103
|
.split('\n')
|
|
95
104
|
.filter((line) => line.trim().length > 0)
|
|
96
105
|
.map((line) => observation_ledger.status_path(line))
|
|
97
|
-
.filter((file_path) =>
|
|
106
|
+
.filter((file_path) => excluded.includes(file_path))
|
|
98
107
|
|
|
99
|
-
for (const file_path of
|
|
100
|
-
console.info(
|
|
108
|
+
for (const file_path of left_out) {
|
|
109
|
+
console.info(
|
|
110
|
+
`💡 ${file_path} is not staged: a ledger line breaks the grammar; \`pnpm josh observations:flush\` names it.`,
|
|
111
|
+
)
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
114
|
|
|
115
|
+
// The status is read after the migration, so an old-path ledger it moved is not staged by its old name.
|
|
104
116
|
async function stage_tracked_files(): Promise<void> {
|
|
117
|
+
const excluded = await ledger_exclusions()
|
|
105
118
|
const status_output = await git_command.status()
|
|
106
119
|
const untracked = git_status
|
|
107
120
|
.list_untracked_files(status_output)
|
|
108
|
-
.filter((file_path) => is_stageable(file_path))
|
|
121
|
+
.filter((file_path) => is_stageable(file_path, excluded))
|
|
109
122
|
|
|
110
|
-
await git_command.add_tracked(
|
|
123
|
+
await git_command.add_tracked(excluded)
|
|
111
124
|
console.info('💡 Auto-staged tracked modified files (git add -u).')
|
|
112
|
-
report_excluded_paths(status_output)
|
|
125
|
+
report_excluded_paths(status_output, excluded)
|
|
113
126
|
await stage_untracked_files(untracked)
|
|
114
127
|
}
|
|
115
128
|
|
|
@@ -10,6 +10,15 @@ const IN_PROGRESS_LABEL = 'in-progress'
|
|
|
10
10
|
// it: a parked child is why a run reports "nothing left that time will fix" rather than waiting
|
|
11
11
|
// forever (joshuafolkken/kit#860).
|
|
12
12
|
const NEEDS_DECISION_LABEL = 'needs-decision'
|
|
13
|
+
// Marks an issue whose defect reaches the verification path, so it runs with nothing beside it
|
|
14
|
+
// (joshuafolkken/kit#2776). A run applies it from the verification-path enumeration in `wip-cap.md`;
|
|
15
|
+
// `backlog:next` enforces it (`backlog-solo.ts`), so the rule no longer rests on a judgement at
|
|
16
|
+
// dispatch time.
|
|
17
|
+
const RUN_SOLO_LABEL = 'run:solo'
|
|
18
|
+
// The other half of the same judgement: an issue a run read and found safe to run beside others
|
|
19
|
+
// (joshuafolkken/kit#2779). Without it an issue lacking `run:solo` could mean "judged parallel" or
|
|
20
|
+
// "never judged", and `backlog:next` withholds the second (`epic-triage.ts`).
|
|
21
|
+
const RUN_LANE_LABEL = 'run:lane'
|
|
13
22
|
// Opts one issue outside any epic into unattended execution (joshuafolkken/kit#906). **Only a
|
|
14
23
|
// person applies it.** `epicrun #<E>` approves the merges inside `#<E>`; this label is the only way
|
|
15
24
|
// a person extends that approval past the epic's edge, so a label an AI could apply to itself would
|
|
@@ -191,6 +200,16 @@ const WORKFLOW_LABELS: ReadonlyArray<{
|
|
|
191
200
|
description: 'Tracks a batch of child issues from one split',
|
|
192
201
|
},
|
|
193
202
|
{ name: IN_PROGRESS_LABEL, color: '0075ca', description: 'Work is actively in progress' },
|
|
203
|
+
{
|
|
204
|
+
name: RUN_SOLO_LABEL,
|
|
205
|
+
color: 'b60205',
|
|
206
|
+
description: 'Runs alone in a backlogrun: nothing starts beside it',
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: RUN_LANE_LABEL,
|
|
210
|
+
color: '0e8a16',
|
|
211
|
+
description: 'Judged safe to run beside others in a backlogrun',
|
|
212
|
+
},
|
|
194
213
|
...FILING_ROUTE_LABELS,
|
|
195
214
|
...DEPTH_LABELS,
|
|
196
215
|
]
|
|
@@ -269,6 +288,8 @@ const ALL_LABELS: ReadonlySet<string> = new Set([
|
|
|
269
288
|
INTERRUPT_ROUTE_LABEL,
|
|
270
289
|
REVIEW_CAP_ROUTE_LABEL,
|
|
271
290
|
REVIEW_ROUND2_SKIPPED_LABEL,
|
|
291
|
+
RUN_LANE_LABEL,
|
|
292
|
+
RUN_SOLO_LABEL,
|
|
272
293
|
SPLIT_ROUTE_LABEL,
|
|
273
294
|
TIER_A_ROUTE_LABEL,
|
|
274
295
|
DEPTH_0_LABEL,
|
|
@@ -301,6 +322,8 @@ export {
|
|
|
301
322
|
NOT_DIRECTLY_RUNNABLE_LABELS,
|
|
302
323
|
REVIEW_CAP_ROUTE_LABEL,
|
|
303
324
|
REVIEW_ROUND2_SKIPPED_LABEL,
|
|
325
|
+
RUN_LANE_LABEL,
|
|
326
|
+
RUN_SOLO_LABEL,
|
|
304
327
|
SPLIT_ROUTE_LABEL,
|
|
305
328
|
TIER_A_ROUTE_LABEL,
|
|
306
329
|
WORKFLOW_LABELS,
|