@joshuafolkken/kit 1.953.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 +5 -3
- package/dist/CLAUDE.md +3 -3
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-OM5VDM6F.js → chunk-2K52BRWV.js} +2 -2
- package/dist/hooks/{chunk-Z5TVZ4AP.js → chunk-BNWIOJFU.js} +29 -16
- package/dist/hooks/{chunk-NKVLBB65.js → chunk-BVMSPG3M.js} +3 -3
- package/dist/hooks/{chunk-IA7WC4NS.js → chunk-CRGJTD6E.js} +1 -1
- package/dist/hooks/{chunk-M4U4YNRE.js → chunk-F6OM6SIG.js} +3 -3
- package/dist/hooks/{chunk-O3PEDF3M.js → chunk-KQG4776P.js} +1 -1
- package/dist/hooks/{chunk-V7RH56HU.js → chunk-NXL3SBXY.js} +2 -2
- package/dist/hooks/{chunk-452OKRQX.js → chunk-WWTMHHZU.js} +120 -18
- package/dist/hooks/{chunk-EYFJ74MJ.js → chunk-ZPLXLJ7O.js} +21 -7
- package/dist/hooks/{chunk-IBFC25IX.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 +14 -6
- 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-drive-cli.ts +5 -1
- package/scripts/backlog/backlog-drive-retrospective.ts +20 -0
- package/scripts/backlog/backlog-drive.ts +7 -3
- 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 +42 -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/init/project-profile.ts +9 -5
- package/scripts/init/start-plan.ts +145 -0
- package/scripts/init/start-prompt.ts +55 -0
- package/scripts/init/start-steps.ts +167 -0
- package/scripts/init/start.ts +92 -16
- package/scripts/issue/issue-closing-pr.ts +93 -0
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +4 -2
- package/scripts/josh/josh-commands-project.ts +11 -3
- 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-retrospective.ts +34 -0
- package/scripts/run/run-step-cli.ts +19 -5
- package/scripts/run/run-step.ts +31 -19
- 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
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { BusyRead } from '#scripts/epic/epic-busy'
|
|
2
|
+
import { epic_classify } from '#scripts/epic/epic-classify'
|
|
3
|
+
import { epic_cross_repo } from '#scripts/epic/epic-cross-repo'
|
|
4
|
+
import { epic_graph, type EpicChild } from '#scripts/epic/epic-graph'
|
|
5
|
+
import { epic_outside_blocker } from '#scripts/epic/epic-outside-blocker'
|
|
6
|
+
import { epic_report, type EpicNextResult } from '#scripts/epic/epic-report'
|
|
7
|
+
import { epic_solo } from '#scripts/epic/epic-solo'
|
|
8
|
+
import { has_label_name, IN_PROGRESS_LABEL, NEEDS_DECISION_LABEL } from '#scripts/git/issue-labels'
|
|
9
|
+
import { backlog_plan, type PlanContext } from './backlog-plan'
|
|
10
|
+
|
|
11
|
+
// The order a `backlogrun` takes, wave by wave, before it starts (joshuafolkken/kit#2778).
|
|
12
|
+
//
|
|
13
|
+
// `backlog:next` answers one ask — what may start now — and `backlog:plan` shows the pool that ask is
|
|
14
|
+
// made from, so the order past the first answer was left for a person to work out by hand. This plays
|
|
15
|
+
// the asks forward under one assumption: **every issue of a wave merges before the next wave starts**.
|
|
16
|
+
//
|
|
17
|
+
// **No rule is written twice.** The first wave is `epic_solo.select` over the very candidates
|
|
18
|
+
// `backlog:next` resolves, asked of an idle repository — so it is what `backlog:next` prints when
|
|
19
|
+
// nothing is running. Each later wave marks the earlier waves closed and re-classifies the same pool
|
|
20
|
+
// through `epic_classify.classify_children` with the resolver `epic:next` sorted it with the first
|
|
21
|
+
// time — `epic_cross_repo.resolve_cross_repo`, so a blocker in another repository still waits for its
|
|
22
|
+
// release rather than counting as done on close — then puts the runnable set through
|
|
23
|
+
// `epic_solo.select` again.
|
|
24
|
+
|
|
25
|
+
const IDLE: BusyRead = { kind: 'idle' }
|
|
26
|
+
const CLOSED = 'CLOSED'
|
|
27
|
+
|
|
28
|
+
const HEADING_ASSUMPTION =
|
|
29
|
+
'Assumes every issue in a wave merges before the next wave starts. Issues a run already has are left out, and only issues of this repository are planned.'
|
|
30
|
+
const UNREACHED_HEADING = 'Not reached — each names what it is waiting on:'
|
|
31
|
+
const PARALLEL_NOTE = '(parallel)'
|
|
32
|
+
const NEEDS_DECISION_NOTE = `waiting on a person (\`${NEEDS_DECISION_LABEL}\`)`
|
|
33
|
+
const NOT_REACHED_NOTE = 'not reached by the waves above'
|
|
34
|
+
const WAVE_GAP_WIDTH = 2
|
|
35
|
+
const PARALLEL_GAP_WIDTH = 3
|
|
36
|
+
const WAVE_GAP = ' '.repeat(WAVE_GAP_WIDTH)
|
|
37
|
+
const PARALLEL_GAP = ' '.repeat(PARALLEL_GAP_WIDTH)
|
|
38
|
+
|
|
39
|
+
interface WavePlan {
|
|
40
|
+
waves: ReadonlyArray<ReadonlyArray<EpicChild>>
|
|
41
|
+
unreached: ReadonlyArray<EpicChild>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function is_running(child: EpicChild): boolean {
|
|
45
|
+
return has_label_name(child.labels, IN_PROGRESS_LABEL)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Every child the classification placed, in the order it placed them: the ranked candidates first,
|
|
49
|
+
// then the waiting ones, then the ones waiting on a person.
|
|
50
|
+
function everything_in(result: EpicNextResult): ReadonlyArray<EpicChild> {
|
|
51
|
+
const candidates = result.candidates.flatMap((bundle) => bundle.children)
|
|
52
|
+
|
|
53
|
+
return [...candidates, ...result.waiting, ...result.blocked_on_people]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function as_closed(child: EpicChild, done: ReadonlySet<string>): EpicChild {
|
|
57
|
+
return done.has(epic_graph.key_of(child)) ? { ...child, state: CLOSED } : child
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The pool a wave is chosen from. A running issue is left out of it but kept in `running`, so a child
|
|
61
|
+
// behind it waits on it rather than being handed to a person.
|
|
62
|
+
interface WavePool {
|
|
63
|
+
children: ReadonlyArray<EpicChild>
|
|
64
|
+
running: ReadonlySet<string>
|
|
65
|
+
repo: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pool_of(result: EpicNextResult, repo: string): WavePool {
|
|
69
|
+
const all = everything_in(result)
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
children: all.filter((child) => !is_running(child)),
|
|
73
|
+
running: epic_outside_blocker.running_keys(all),
|
|
74
|
+
repo,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function offer(runnable: ReadonlyArray<EpicChild>, repo: string): ReadonlyArray<EpicChild> {
|
|
79
|
+
return epic_solo.select(runnable, IDLE, repo).offered
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function first_wave(result: EpicNextResult, repo: string): ReadonlyArray<EpicChild> {
|
|
83
|
+
return offer(epic_report.candidates_for_repo(result, repo), repo)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function next_wave(pool: WavePool, done: ReadonlySet<string>): ReadonlyArray<EpicChild> {
|
|
87
|
+
const children = pool.children.map((child) => as_closed(child, done))
|
|
88
|
+
const { runnable } = epic_classify.classify_children(
|
|
89
|
+
children,
|
|
90
|
+
epic_cross_repo.resolve_cross_repo,
|
|
91
|
+
pool.running,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return offer(
|
|
95
|
+
runnable.filter((child) => child.repo === pool.repo),
|
|
96
|
+
pool.repo,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Each wave is closed before the next is asked for; a wave that offers nothing ends the plan, which
|
|
101
|
+
// the pool's size bounds because every wave closes at least one child.
|
|
102
|
+
function build(result: EpicNextResult, repo: string): WavePlan {
|
|
103
|
+
const pool = pool_of(result, repo)
|
|
104
|
+
const done = new Set<string>()
|
|
105
|
+
const waves: Array<ReadonlyArray<EpicChild>> = []
|
|
106
|
+
|
|
107
|
+
for (let wave = first_wave(result, repo); wave.length > 0; wave = next_wave(pool, done)) {
|
|
108
|
+
waves.push(wave)
|
|
109
|
+
for (const child of wave) done.add(epic_graph.key_of(child))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const unreached = everything_in(result).filter(
|
|
113
|
+
(child) => child.repo === repo && !done.has(epic_graph.key_of(child)),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return { waves, unreached }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The plan's own "waiting on" wording is reused, so an unreached issue names its blocker the way the
|
|
120
|
+
// plan's waiting section does. Its "past the offer" fallback means nothing across waves, so it reads
|
|
121
|
+
// as not reached instead.
|
|
122
|
+
function unreached_note(child: EpicChild, context: PlanContext): string {
|
|
123
|
+
if (has_label_name(child.labels, NEEDS_DECISION_LABEL)) return NEEDS_DECISION_NOTE
|
|
124
|
+
const note = backlog_plan.waiting_note(child, context)
|
|
125
|
+
|
|
126
|
+
return note === backlog_plan.PAST_OFFER_NOTE ? NOT_REACHED_NOTE : note
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function wave_line(wave: ReadonlyArray<EpicChild>, index: number, repo: string): string {
|
|
130
|
+
const references = wave.map((child) => backlog_plan.marked_reference(child, repo)).join(' ')
|
|
131
|
+
const suffix = wave.length > 1 ? `${PARALLEL_GAP}${PARALLEL_NOTE}` : ''
|
|
132
|
+
|
|
133
|
+
return `Wave ${String(index + 1)}${WAVE_GAP}${references}${suffix}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function unreached_lines(plan: WavePlan, context: PlanContext): Array<string> {
|
|
137
|
+
return plan.unreached.map((child) =>
|
|
138
|
+
backlog_plan.row_of(child, context, unreached_note(child, context)),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function format_waves(result: EpicNextResult, context: PlanContext): string {
|
|
143
|
+
if (result.verdict === 'error') return backlog_plan.format_unusable(result)
|
|
144
|
+
|
|
145
|
+
const plan = build(result, context.repo)
|
|
146
|
+
const waves = plan.waves.map((wave, index) => wave_line(wave, index, context.repo))
|
|
147
|
+
|
|
148
|
+
return [
|
|
149
|
+
`Backlog waves — ${context.repo}`,
|
|
150
|
+
HEADING_ASSUMPTION,
|
|
151
|
+
'',
|
|
152
|
+
...(waves.length === 0 ? [backlog_plan.NOTHING] : waves),
|
|
153
|
+
'',
|
|
154
|
+
backlog_plan.section(UNREACHED_HEADING, unreached_lines(plan, context)),
|
|
155
|
+
].join('\n')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const backlog_waves = {
|
|
159
|
+
HEADING_ASSUMPTION,
|
|
160
|
+
NEEDS_DECISION_NOTE,
|
|
161
|
+
NOT_REACHED_NOTE,
|
|
162
|
+
PARALLEL_NOTE,
|
|
163
|
+
UNREACHED_HEADING,
|
|
164
|
+
build,
|
|
165
|
+
format_waves,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export { backlog_waves }
|
|
169
|
+
export type { WavePlan }
|
|
@@ -82,7 +82,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
|
|
|
82
82
|
{ path: '.claude/skills/workflow-commands/rule-residency.md', bytes: 24_576 },
|
|
83
83
|
{ path: '.claude/skills/workflow-commands/target-repository.md', bytes: 8192 },
|
|
84
84
|
{ path: '.claude/skills/workflow-commands/working-tree-hold.md', bytes: 8192 },
|
|
85
|
-
{ path: 'docs/josh-commands.md', bytes:
|
|
85
|
+
{ path: 'docs/josh-commands.md', bytes: 172_032 },
|
|
86
86
|
{ path: 'prompts/agent-rules.md', bytes: 4096 },
|
|
87
87
|
{ path: 'prompts/coding-standards.md', bytes: 16_384 },
|
|
88
88
|
{ path: 'prompts/collaboration-workflow.md', bytes: 8192 },
|
|
@@ -76,11 +76,13 @@ const ENTRY_READ_BUDGET: ReadonlyArray<EntryBudget> = [
|
|
|
76
76
|
// joshuafolkken/kit#2353 wired the watcher-guard note into `backlogrun-progress.md` (read by the three
|
|
77
77
|
// planning entries): kickoff has no headroom, so the addition was offset by tightening that file's
|
|
78
78
|
// heartbeat prose, keeping every entry within its existing block.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
{ entry: '
|
|
82
|
-
{ entry: '
|
|
83
|
-
{ entry:
|
|
79
|
+
// joshuafolkken/kit#2762 moved the rationale of five point-of-use documents to
|
|
80
|
+
// `docs/maintainers/*-rationale.md`, and the ratchet holds the reduction.
|
|
81
|
+
{ entry: 'kickoff', bytes: 229_376 },
|
|
82
|
+
{ entry: 'fullrun', bytes: 229_376 },
|
|
83
|
+
{ entry: 'halfrun', bytes: 229_376 },
|
|
84
|
+
{ entry: 'backlogrun', bytes: 233_472 },
|
|
85
|
+
{ entry: LANE_CHILD, bytes: 81_920 },
|
|
84
86
|
]
|
|
85
87
|
|
|
86
88
|
function byte_size(root: string, relative_path: string): number {
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
} from './epic-candidate-confirm'
|
|
8
8
|
import { epic_graph, type EpicChild } from './epic-graph'
|
|
9
9
|
import type { EpicVerdict } from './epic-report'
|
|
10
|
+
import { epic_solo } from './epic-solo'
|
|
11
|
+
import { epic_triage, type TriageVerdict } from './epic-triage'
|
|
10
12
|
|
|
11
13
|
// Which children a repository has room to start right now (joshuafolkken/kit#1491).
|
|
12
14
|
//
|
|
@@ -50,7 +52,7 @@ interface LaneRequest {
|
|
|
50
52
|
// output carries issue numbers or a verdict and nothing else.
|
|
51
53
|
interface LaneOffer {
|
|
52
54
|
children: ReadonlyArray<EpicChild>
|
|
53
|
-
verdict: EpicVerdict
|
|
55
|
+
verdict: EpicVerdict | TriageVerdict
|
|
54
56
|
notice: string
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -165,14 +167,75 @@ async function collect(
|
|
|
165
167
|
return { children, verdict: children.length > NO_LANES ? RUN_VERDICT : verdict }
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
// The `run:solo` gate `backlog:next` applies, applied to a named epic's lanes too
|
|
171
|
+
// (joshuafolkken/kit#2776): the confirmed children are cut where `epic_solo.select` says, so a
|
|
172
|
+
// `run:solo` child never opens a lane beside another one on either path.
|
|
173
|
+
function solo_offer(
|
|
174
|
+
answer: { children: ReadonlyArray<EpicChild>; verdict: EpicVerdict },
|
|
175
|
+
read: BusyRead,
|
|
176
|
+
request: LaneRequest,
|
|
177
|
+
): LaneOffer {
|
|
178
|
+
const { offered, notice } = epic_solo.select(answer.children, read, request.repo)
|
|
179
|
+
|
|
180
|
+
if (offered.length === NO_LANES && answer.children.length > NO_LANES) {
|
|
181
|
+
return { children: [], verdict: WAIT_VERDICT, notice: notice ?? '' }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { ...answer, children: offered, notice: offered_notice(offered, read, request) }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The confirmation walk stops at the free-lane count, so a `run:solo` candidate ranked past it — or
|
|
188
|
+
// in a pool past it — would never reach `solo_offer` to be preferred. Into an idle repository the
|
|
189
|
+
// first pool holding one is asked first, with it at its head through the same `epic_solo.prefer` the
|
|
190
|
+
// offer applies (joshuafolkken/kit#2778). The candidate stays in its own pool, because a pool carries
|
|
191
|
+
// the graph it is confirmed against.
|
|
192
|
+
function solo_first(pools: ReadonlyArray<RepoPool>, read: BusyRead): ReadonlyArray<RepoPool> {
|
|
193
|
+
if (read.kind !== 'idle') return pools
|
|
194
|
+
|
|
195
|
+
const index = pools.findIndex((pool) => pool.candidates.some((child) => epic_solo.is_solo(child)))
|
|
196
|
+
const pool = pools[index]
|
|
197
|
+
|
|
198
|
+
if (pool === undefined) return pools
|
|
199
|
+
|
|
200
|
+
const lifted = { ...pool, candidates: epic_solo.prefer(pool.candidates, read) }
|
|
201
|
+
|
|
202
|
+
return [lifted, ...pools.filter((other) => other !== pool)]
|
|
203
|
+
}
|
|
204
|
+
|
|
168
205
|
// The repository is asked how full it is **before** any candidate is confirmed, for the reason
|
|
169
206
|
// joshuafolkken/kit#1121 records: a repository with no free lane is handed nothing, so the relations
|
|
170
207
|
// request that would confirm a candidate there buys an answer nobody reads — and a polling `epicrun`
|
|
171
208
|
// would pay it every round.
|
|
209
|
+
//
|
|
210
|
+
// **Triage is asked before even that** (joshuafolkken/kit#2779): an untriaged candidate withholds every
|
|
211
|
+
// candidate whatever the occupancy, and it needs only the labels already read. Only a `--lanes` ask
|
|
212
|
+
// is gated — it is the one that opens children beside each other; the single-child form starts one.
|
|
213
|
+
function triage_offer(pools: ReadonlyArray<RepoPool>, request: LaneRequest): LaneOffer | undefined {
|
|
214
|
+
if (!request.is_all_lanes) return undefined
|
|
215
|
+
|
|
216
|
+
const untriaged = epic_triage.untriaged(
|
|
217
|
+
dedupe_pools(pools.map((pool) => candidates_in(pool, request.repo))).flatMap(
|
|
218
|
+
(pool) => pool.candidates,
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if (untriaged.length === NO_LANES) return undefined
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
children: [],
|
|
226
|
+
verdict: epic_triage.TRIAGE_VERDICT,
|
|
227
|
+
notice: epic_triage.message(untriaged, request.repo),
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
172
231
|
async function offer_for_repo(
|
|
173
232
|
pools: ReadonlyArray<RepoPool>,
|
|
174
233
|
request: LaneRequest,
|
|
175
234
|
): Promise<LaneOffer> {
|
|
235
|
+
const triage = triage_offer(pools, request)
|
|
236
|
+
|
|
237
|
+
if (triage !== undefined) return triage
|
|
238
|
+
|
|
176
239
|
const read = await epic_busy.read_repository(request.repo)
|
|
177
240
|
const free = free_lanes(read, request.limit)
|
|
178
241
|
|
|
@@ -184,10 +247,12 @@ async function offer_for_repo(
|
|
|
184
247
|
}
|
|
185
248
|
}
|
|
186
249
|
|
|
187
|
-
const for_repo =
|
|
188
|
-
|
|
250
|
+
const for_repo = solo_first(
|
|
251
|
+
dedupe_pools(pools.map((pool) => candidates_in(pool, request.repo))),
|
|
252
|
+
read,
|
|
253
|
+
)
|
|
189
254
|
|
|
190
|
-
return
|
|
255
|
+
return solo_offer(await collect(for_repo, wanted_of(request, free)), read, request)
|
|
191
256
|
}
|
|
192
257
|
|
|
193
258
|
const epic_lane_offer = {
|
|
@@ -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 }
|