@joshuafolkken/kit 1.1229.0 → 1.1240.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.
Files changed (67) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
  2. package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
  3. package/.claude/skills/workflow-commands/followup.md +5 -0
  4. package/.claude/skills/workflow-commands/kickoff.md +1 -0
  5. package/.claude/skills/workflow-commands/latest-gate.md +1 -1
  6. package/.github/workflows/publish.yml +2 -0
  7. package/README.md +30 -31
  8. package/dist/hooks/batch-guard.js +4 -4
  9. package/dist/hooks/{chunk-JUDKDCDP.js → chunk-4TZVB44A.js} +8 -8
  10. package/dist/hooks/{chunk-RNWHN5BP.js → chunk-F3ELQ2AF.js} +3 -3
  11. package/dist/hooks/{chunk-VC3PZPZO.js → chunk-GPTFMK5E.js} +1 -1
  12. package/dist/hooks/{chunk-FIVDHQGT.js → chunk-KHGB5S3Y.js} +1 -1
  13. package/dist/hooks/{chunk-NV2JRZZO.js → chunk-KHX4U3BG.js} +17 -2
  14. package/dist/hooks/{chunk-F56MFS4B.js → chunk-KRBERBBI.js} +3 -3
  15. package/dist/hooks/{chunk-TQNPDZNH.js → chunk-QVSWU24O.js} +4 -4
  16. package/dist/hooks/{chunk-55HHG5PX.js → chunk-WK2XSHSI.js} +284 -63
  17. package/dist/hooks/{chunk-5VMKCCC4.js → chunk-XZYKEM6J.js} +2 -2
  18. package/dist/hooks/{chunk-6HJJXRBX.js → chunk-ZQGD4GBY.js} +1 -1
  19. package/dist/hooks/codex-hook-adapter.js +10 -10
  20. package/dist/hooks/duplicate-read-guard.js +5 -5
  21. package/dist/hooks/format-edited.js +2 -2
  22. package/dist/hooks/inputs.json +172 -170
  23. package/dist/hooks/investigation-guard.js +5 -5
  24. package/dist/hooks/pretool-guard.js +9 -9
  25. package/dist/hooks/stop-guard.js +3 -17
  26. package/dist/josh.js +279 -63
  27. package/package.json +1 -1
  28. package/prompts/collaboration-workflow/issue-template.md +1 -0
  29. package/scripts/gate/core-admission.ts +162 -0
  30. package/scripts/gate/core-budget.ts +121 -67
  31. package/scripts/gate/gate-plan.ts +35 -2
  32. package/scripts/gate/gate-report.ts +28 -9
  33. package/scripts/gate/machine-capacity.ts +175 -0
  34. package/scripts/gate/verification-gate.ts +7 -2
  35. package/scripts/init/init.ts +5 -1
  36. package/scripts/init/package-manager-pin.ts +35 -0
  37. package/scripts/issue/issue-auto-ok.ts +31 -6
  38. package/scripts/issue/issue-file-cli.ts +31 -8
  39. package/scripts/issue/issue-file.ts +5 -0
  40. package/scripts/issue/issue-labels.ts +13 -0
  41. package/scripts/issue/issue-release-cli.ts +155 -0
  42. package/scripts/issue/issue-release.ts +58 -0
  43. package/scripts/josh/josh-command-types.ts +3 -0
  44. package/scripts/josh/josh-commands-development.ts +8 -0
  45. package/scripts/josh/josh-commands-issue.ts +8 -1
  46. package/scripts/josh/josh-commands-lane.ts +2 -1
  47. package/scripts/josh/josh-commands-maintenance.ts +2 -2
  48. package/scripts/josh/josh-logic.ts +4 -3
  49. package/scripts/lane/lane-cli.ts +9 -1
  50. package/scripts/lane/lane-leftover.ts +43 -29
  51. package/scripts/lane/lane-open.ts +5 -5
  52. package/scripts/lane/lane-paths.ts +13 -0
  53. package/scripts/lane/lane-registry.ts +11 -0
  54. package/scripts/lane/lane-stray.ts +140 -0
  55. package/scripts/release/github-release-environment.ts +1 -0
  56. package/scripts/release/github-release-runs.ts +115 -0
  57. package/scripts/release/github-release.ts +13 -67
  58. package/scripts/run/cut/run-cut-cli.ts +16 -3
  59. package/scripts/run/event/run-event-render.ts +1 -0
  60. package/scripts/run/event/run-event-stream.ts +4 -0
  61. package/scripts/run/run-step.ts +5 -2
  62. package/scripts/run/wake/run-wake-cli.ts +2 -3
  63. package/scripts/run/wake/run-wake-describe.ts +56 -5
  64. package/scripts/run/wake/run-wake-loop.ts +5 -13
  65. package/scripts/run/wake/run-wake.ts +15 -64
  66. package/scripts/version/latest-corepack.ts +109 -78
  67. package/templates/workflows/github-release.yml +4 -0
@@ -0,0 +1,175 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { availableParallelism, cpus, totalmem } from 'node:os'
3
+ import { execa } from 'execa'
4
+
5
+ // What the machine has free right now, read for the core budget's admission (joshuafolkken/kit#3371).
6
+ //
7
+ // **The budget used to be the core count, and that count is not what is free.** The ledger in
8
+ // `core-budget.ts` admits against `availableParallelism()`, so the load it never sees — Claude sessions,
9
+ // node processes that do not pass through `josh`, the window server — ran on top of a budget already
10
+ // spent. On 2026-10-07, three lanes on the 11-core / 18 GB machine reached a load average of 16.8 with
11
+ // 31% of the CPU in the kernel, compressing and swapping 4.4 GB of memory the ledger never counted.
12
+ //
13
+ // **This is a closed loop, not the open-loop formula joshuafolkken/kit#1637 rejected.** That proposal
14
+ // derived a share from the core count up front; this reads what is busy and free at the moment of
15
+ // admission and subtracts only the part the ledger does not already account for.
16
+ //
17
+ // **The busy cores are the CPU-time difference over a short window, not the load average.** The load
18
+ // average trails by a minute, so a gate started just after another one finished would see the finished
19
+ // one's load as external and wait out the decay. `os.cpus()` is one read with no process spawned.
20
+ //
21
+ // **A reading that fails answers undefined, and undefined is the old behavior.** A CI runner or a
22
+ // container — where the host's CPU times are not this process's quota — budgets by the core count and
23
+ // admits without looking at memory, exactly as before.
24
+
25
+ // The window the busy cores are measured over: long enough that one scheduler tick does not dominate it,
26
+ // short enough that a solo admission is not visibly delayed.
27
+ const SAMPLE_WINDOW_MS = 250
28
+ // **The background a developer machine always carries, which the gate's weights were measured beside.**
29
+ // On 2026-10-07 the 11-core machine idled at about 1.5 cores outside `josh` — Claude sessions 0.55, the
30
+ // window server 0.47, a browser 0.5. The solo gate's weights sum to exactly the core count and were
31
+ // measured with that load present, so only what exceeds it is load the budget must give way to;
32
+ // counting the baseline would defer a solo gate's unit suite on every run.
33
+ const BASELINE_CORES = 2
34
+ const BYTES_PER_MB = 1_048_576
35
+ const KB_PER_MB = 1024
36
+ const PERCENT = 100
37
+ const MEMINFO_PATH = '/proc/meminfo'
38
+ const LINUX_AVAILABLE = /^MemAvailable:\s+(?<kb>\d+) kB/mu
39
+ // The kernel's own memory-pressure figure: the percentage of memory still available, compressible and
40
+ // purgeable pages included — what `os.freemem()` leaves out on macOS, where it reads near zero on a
41
+ // machine with gigabytes to spare.
42
+ const PRESSURE_ARGUMENTS = ['-n', 'kern.memorystatus_level']
43
+ const PRESSURE_LEVEL = /^\s*(?<level>\d+)\s*$/u
44
+
45
+ interface MachineReading {
46
+ busy_cores: number | undefined
47
+ available_mb: number | undefined
48
+ }
49
+
50
+ // What the ledger's admitted reservations declare — the part of the machine's load already accounted for.
51
+ interface LedgerLoad {
52
+ cores: number
53
+ memory_mb: number
54
+ }
55
+
56
+ interface MachineBudget {
57
+ cores: number
58
+ memory_mb: number
59
+ }
60
+
61
+ interface CpuTimes {
62
+ busy: number
63
+ total: number
64
+ }
65
+
66
+ function cpu_times(): CpuTimes {
67
+ let busy = 0
68
+ let idle = 0
69
+
70
+ for (const { times } of cpus()) {
71
+ busy += times.user + times.nice + times.sys + times.irq
72
+ idle += times.idle
73
+ }
74
+
75
+ return { busy, total: busy + idle }
76
+ }
77
+
78
+ function busy_cores_between(before: CpuTimes, after: CpuTimes, cores: number): number | undefined {
79
+ const total = after.total - before.total
80
+
81
+ if (total <= 0) return undefined
82
+
83
+ return ((after.busy - before.busy) / total) * cores
84
+ }
85
+
86
+ // `kern.memorystatus_level` → `58`: the share of physical memory still available, as a percentage.
87
+ function parse_darwin_pressure(output: string, total_bytes: number): number | undefined {
88
+ const level = PRESSURE_LEVEL.exec(output)?.groups?.['level']
89
+
90
+ if (level === undefined) return undefined
91
+
92
+ return (Number(level) / PERCENT) * (total_bytes / BYTES_PER_MB)
93
+ }
94
+
95
+ function parse_linux_available(meminfo: string): number | undefined {
96
+ const kb = LINUX_AVAILABLE.exec(meminfo)?.groups?.['kb']
97
+
98
+ return kb === undefined ? undefined : Number(kb) / KB_PER_MB
99
+ }
100
+
101
+ async function read_available_mb(
102
+ platform: NodeJS.Platform = process.platform,
103
+ ): Promise<number | undefined> {
104
+ try {
105
+ if (platform === 'darwin') {
106
+ const { stdout } = await execa('sysctl', PRESSURE_ARGUMENTS)
107
+
108
+ return parse_darwin_pressure(stdout, totalmem())
109
+ }
110
+
111
+ return platform === 'linux'
112
+ ? parse_linux_available(await readFile(MEMINFO_PATH, 'utf8'))
113
+ : undefined
114
+ } catch {
115
+ return undefined
116
+ }
117
+ }
118
+
119
+ // A CPU quota narrower than the host makes the host-wide CPU times the wrong machine, so the busy cores
120
+ // are read only where the quota and the host agree.
121
+ function is_unquoted(): boolean {
122
+ return cpus().length > 0 && cpus().length === availableParallelism()
123
+ }
124
+
125
+ async function default_sleep(ms: number): Promise<void> {
126
+ await new Promise<void>((resolve) => {
127
+ setTimeout(resolve, ms)
128
+ })
129
+ }
130
+
131
+ async function read_machine(
132
+ sleep: (ms: number) => Promise<void> = default_sleep,
133
+ ): Promise<MachineReading> {
134
+ const before = cpu_times()
135
+ const [available_mb] = await Promise.all([read_available_mb(), sleep(SAMPLE_WINDOW_MS)])
136
+ const busy_cores = is_unquoted()
137
+ ? busy_cores_between(before, cpu_times(), cpus().length)
138
+ : undefined
139
+
140
+ return { busy_cores, available_mb }
141
+ }
142
+
143
+ // The busy cores the ledger does not account for, beyond the baseline the weights were measured beside,
144
+ // in whole cores — the budget is in whole cores, and a fraction of a core is not a core to give up.
145
+ function external_cores(busy_cores: number | undefined, ledger_cores: number): number {
146
+ if (busy_cores === undefined) return 0
147
+
148
+ return Math.floor(Math.max(0, busy_cores - ledger_cores - BASELINE_CORES))
149
+ }
150
+
151
+ // **The budget the ledger admits against: what is free plus what the ledger itself already holds.**
152
+ // The admitted reservations' load is inside the reading, and the admission walk counts their declared
153
+ // weight again, so it is added back — what is subtracted is only the load from outside the ledger. The
154
+ // caller passes every admitted claim's cores but only the memory of claims past
155
+ // `core_admission.RAMP_UP_MS`, whose tools have had time to allocate.
156
+ // Memory that could not be read is no limit at all, the behavior before memory was read.
157
+ function machine_budget(cores: number, reading: MachineReading, ledger: LedgerLoad): MachineBudget {
158
+ return {
159
+ cores: cores - external_cores(reading.busy_cores, ledger.cores),
160
+ memory_mb:
161
+ reading.available_mb === undefined ? Infinity : reading.available_mb + ledger.memory_mb,
162
+ }
163
+ }
164
+
165
+ const machine_capacity = {
166
+ BASELINE_CORES,
167
+ busy_cores_between,
168
+ machine_budget,
169
+ parse_darwin_pressure,
170
+ parse_linux_available,
171
+ read_machine,
172
+ }
173
+
174
+ export type { LedgerLoad, MachineBudget, MachineReading }
175
+ export { machine_capacity }
@@ -177,6 +177,7 @@ function default_budget(): BudgetContext {
177
177
  interface ReservedStep {
178
178
  step: GateStep
179
179
  weight: number
180
+ memory_mb: number
180
181
  }
181
182
 
182
183
  async function build_reserved_steps(
@@ -188,6 +189,7 @@ async function build_reserved_steps(
188
189
  plan.checks.map(async (check) => ({
189
190
  step: await build_gate_step(check, start_directory, plan),
190
191
  weight: gate_plan.check_weight(check, plan, available_cores),
192
+ memory_mb: gate_plan.check_memory(check, plan, available_cores),
191
193
  })),
192
194
  )
193
195
  }
@@ -205,8 +207,11 @@ async function run_reserved_step(
205
207
 
206
208
  return await core_budget.with_core_reservation(
207
209
  reserved.weight,
208
- async () => await run_gate_step(reserved.step),
209
- { budget: budget.available_cores },
210
+ async (admission) => ({
211
+ ...(await run_gate_step(reserved.step)),
212
+ budget_note: core_budget.describe_overflow(admission),
213
+ }),
214
+ { budget: budget.available_cores, memory_mb: reserved.memory_mb },
210
215
  )
211
216
  }
212
217
 
@@ -25,6 +25,7 @@ import { init_logic } from './init-logic'
25
25
  import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
26
26
  import { kit_development_versions } from './kit-development-versions'
27
27
  import { kit_setup_state } from './kit-setup-state'
28
+ import { package_manager_pin } from './package-manager-pin'
28
29
  import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
29
30
 
30
31
  const PACKAGE_JSON = 'package.json'
@@ -52,7 +53,10 @@ function resolve_kit_package_manager(manifest: unknown): string | undefined {
52
53
  }
53
54
 
54
55
  function get_kit_package_manager(): string | undefined {
55
- return resolve_kit_package_manager(init_actions.read_package_json(PACKAGE_JSON))
56
+ return package_manager_pin.choose(
57
+ resolve_kit_package_manager(init_actions.read_package_json(PACKAGE_JSON)),
58
+ process.env['npm_config_user_agent'],
59
+ )
56
60
  }
57
61
 
58
62
  function get_kit_development_engines(): Record<string, unknown> {
@@ -0,0 +1,35 @@
1
+ import { package_manager_version } from '#scripts/version/package-manager-version'
2
+ import semver from 'semver'
3
+
4
+ // pnpm sets `npm_config_user_agent` to `pnpm/<version> npm/? node/<version> <platform> <arch>` for
5
+ // every script and `pnpm exec` it runs, so the pnpm that invoked `josh init` is readable from it.
6
+ const PNPM_USER_AGENT_REGEX = /^pnpm\/(\S+)/u
7
+
8
+ function running_pnpm_version(user_agent = ''): string | undefined {
9
+ const version = PNPM_USER_AGENT_REGEX.exec(user_agent)?.[1]
10
+
11
+ return version === undefined ? undefined : (semver.valid(version) ?? undefined)
12
+ }
13
+
14
+ function is_older_than(kit_pin: string, version: string): boolean {
15
+ const kit_version = semver.valid(package_manager_version.extract_pnpm_pin(kit_pin) ?? '')
16
+
17
+ return kit_version !== null && semver.lt(kit_version, version)
18
+ }
19
+
20
+ // Never pin a project below the pnpm installing it: the lockfile that pnpm just wrote can name a
21
+ // version an older pnpm fails to resolve, so `pnpm install` under kit's older pin breaks the
22
+ // first `josh init` (joshuafolkken/kit#3367). The kit pin keeps its integrity suffix when it wins.
23
+ function choose(kit_pin: string | undefined, user_agent: string | undefined): string | undefined {
24
+ const running = running_pnpm_version(user_agent)
25
+ if (kit_pin === undefined || running === undefined) return kit_pin
26
+
27
+ return is_older_than(kit_pin, running) ? `pnpm@${running}` : kit_pin
28
+ }
29
+
30
+ const package_manager_pin = {
31
+ running_pnpm_version,
32
+ choose,
33
+ }
34
+
35
+ export { package_manager_pin }
@@ -2,7 +2,7 @@ import { git_branch } from '#scripts/git/git-branch'
2
2
  import { git_command } from '#scripts/git/git-command'
3
3
  import { run_carry } from '#scripts/run/carry/run-carry'
4
4
  import { issue_file } from './issue-file'
5
- import { AUTO_OK_LABEL, has_label_name } from './issue-labels'
5
+ import { AUTO_OK_LABEL, has_label_name, RELEASE_LABEL } from './issue-labels'
6
6
  import { issue_state_cli } from './issue-state-cli'
7
7
 
8
8
  // Whether `josh issue:file` applies `auto-ok` to the Issue it files (joshuafolkken/kit#3213). Work
@@ -12,9 +12,13 @@ import { issue_state_cli } from './issue-state-cli'
12
12
  // default. `--no-auto-ok` is the one exception, declared for an Issue that needs a person's judgement
13
13
  // (a Tier B toss-up or a Tier C action). A filing to another repository is never opted in: that
14
14
  // repository's own `backlogrun` would implement and merge it though nobody opted that repository in.
15
+ // A `release` Issue is never opted in either (joshuafolkken/kit#3360): a release is Tier C, so only a
16
+ // person applies `auto-ok` to it, whatever the carry record or the branch's Issue says.
15
17
 
16
18
  interface AutoOkSignals {
17
19
  is_opted_out: boolean
20
+ // The filing carries the `release` label.
21
+ is_release: boolean
18
22
  is_carried: boolean
19
23
  // The labels of the Issue the current branch names; `undefined` when the branch names none or the
20
24
  // Issue could not be read.
@@ -31,6 +35,10 @@ const CROSS_REPOSITORY: AutoOkDecision = {
31
35
  is_applied: false,
32
36
  reason: 'the Issue is filed to another repository',
33
37
  }
38
+ const RELEASE: AutoOkDecision = {
39
+ is_applied: false,
40
+ reason: `a ${RELEASE_LABEL} Issue is opted in by a person only`,
41
+ }
34
42
  const CARRIED: AutoOkDecision = { is_applied: true, reason: 'a backlogrun carry record is live' }
35
43
  const BRANCH_OPTED_IN: AutoOkDecision = {
36
44
  is_applied: true,
@@ -41,9 +49,22 @@ const NOT_OPTED_IN: AutoOkDecision = {
41
49
  reason: 'no backlogrun carry record is live and the branch names no auto-ok Issue',
42
50
  }
43
51
 
44
- // The opt-out wins over every signal, then the carry record, then the branch's Issue.
45
- function decide(signals: AutoOkSignals): AutoOkDecision {
52
+ type Withholding = Pick<AutoOkSignals, 'is_opted_out' | 'is_release'>
53
+
54
+ // The two signals that withhold `auto-ok` before any other is read, or `undefined` when neither holds.
55
+ function withheld(signals: Withholding): AutoOkDecision | undefined {
46
56
  if (signals.is_opted_out) return OPTED_OUT
57
+ if (signals.is_release) return RELEASE
58
+
59
+ return undefined
60
+ }
61
+
62
+ // The opt-out and the `release` label win over every signal, then the carry record, then the branch's
63
+ // Issue.
64
+ function decide(signals: AutoOkSignals): AutoOkDecision {
65
+ const decision = withheld(signals)
66
+
67
+ if (decision !== undefined) return decision
47
68
  if (signals.is_carried) return CARRIED
48
69
  if (has_label_name(signals.branch_labels ?? [], AUTO_OK_LABEL)) return BRANCH_OPTED_IN
49
70
 
@@ -76,17 +97,21 @@ async function branch_labels(current: string): Promise<ReadonlyArray<string> | u
76
97
  }
77
98
 
78
99
  // Each read is skipped once an earlier signal has already decided the answer; a cross-repository
79
- // filing is refused before any signal is read.
100
+ // filing is refused before any signal is read. `labels` are the ones the filing declares.
80
101
  async function resolve(
81
102
  is_opted_out: boolean,
82
103
  target: string,
83
104
  current: string,
105
+ labels: ReadonlyArray<string> = [],
84
106
  ): Promise<AutoOkDecision> {
85
- if (is_opted_out) return OPTED_OUT
107
+ const withholding = { is_opted_out, is_release: has_label_name(labels, RELEASE_LABEL) }
108
+ const decision = withheld(withholding)
109
+
110
+ if (decision !== undefined) return decision
86
111
  if (!issue_file.is_same_repository(target, current)) return CROSS_REPOSITORY
87
112
  if (await is_carried()) return CARRIED
88
113
 
89
- return decide({ is_opted_out, is_carried: false, branch_labels: await branch_labels(current) })
114
+ return decide({ ...withholding, is_carried: false, branch_labels: await branch_labels(current) })
90
115
  }
91
116
 
92
117
  const issue_auto_ok = { decide, line_of, resolve }
@@ -14,23 +14,24 @@ import { repository_labels } from '#scripts/repo/repository-labels'
14
14
  import { issue_auto_ok } from './issue-auto-ok'
15
15
  import { issue_file, type FileArguments } from './issue-file'
16
16
  import { issue_lint_cli } from './issue-lint-cli'
17
+ import { issue_release_cli } from './issue-release-cli'
17
18
  import { issue_scout_cli } from './issue-scout-cli'
18
19
  import { issue_wip } from './issue-wip'
19
20
 
20
21
  // `josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>]…
21
- // [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok]` — file an Issue with every
22
- // filing step run in order (joshuafolkken/kit#2808): the third-party refusal, the body lint, the
23
- // `## Origin` check for another repository, the `auto-ok` decision (joshuafolkken/kit#3213) with the
24
- // run label it owes (joshuafolkken/kit#3313), the WIP cap count (joshuafolkken/kit#3181), the duplicate
25
- // scout, the missing workflow labels created (joshuafolkken/kit#3176), the create call carrying every
26
- // label, and `epic:bundle` after it.
22
+ // [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--release]` — file an Issue
23
+ // with every filing step run in order (joshuafolkken/kit#2808): the third-party refusal, the body
24
+ // lint, the `## Origin` check for another repository, the `auto-ok` decision (joshuafolkken/kit#3213)
25
+ // with the run label it owes (joshuafolkken/kit#3313), the WIP cap count (joshuafolkken/kit#3181), the
26
+ // duplicate scout, the missing workflow labels created (joshuafolkken/kit#3176), the create call
27
+ // carrying every label, the release link on `--release` (joshuafolkken/kit#3360), and `epic:bundle`.
27
28
  // A direct `gh api …/issues` filing is refused by the `direct-filing` delivered rule and pointed here.
28
29
 
29
30
  const SUCCESS_EXIT_CODE = 0
30
31
  const FAILURE_EXIT_CODE = 1
31
32
  const ARGV_OFFSET = 2
32
33
  const USAGE =
33
- 'Usage: josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <tier-a|split|interrupt|review-cap>] [--label <name>]… [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok]'
34
+ 'Usage: josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <tier-a|split|interrupt|review-cap>] [--label <name>]… [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--release]'
34
35
  const UNKNOWN_REPO_MESSAGE =
35
36
  'Could not read this repository from `git remote`, so the filing has no repository to compare against — check `gh auth status`.'
36
37
  const THIRD_PARTY_MESSAGE =
@@ -142,6 +143,7 @@ async function labels_of(filing: Filing): Promise<ReadonlyArray<string> | undefi
142
143
  filing.args.is_auto_ok_opted_out,
143
144
  filing.target,
144
145
  filing.current,
146
+ filing.args.labels,
145
147
  )
146
148
 
147
149
  console.info(issue_auto_ok.line_of(auto_ok))
@@ -177,10 +179,30 @@ async function create(filing: Filing, labels: ReadonlyArray<string>): Promise<st
177
179
  }
178
180
  }
179
181
 
182
+ function issue_number_of(url: string): number {
183
+ return Number(github_issue_url.parse(url)?.issue_number)
184
+ }
185
+
186
+ // `--release`: the filed Issue blocks the target's release Issue (joshuafolkken/kit#3360). Reported,
187
+ // never failed, for the reason `place` gives. The re-run hint names the target through `GH_REPO`:
188
+ // `issue:release` links in the repository `gh` resolves, which is not the target on a `--repo` filing.
189
+ async function link_release(url: string, filing: Filing): Promise<void> {
190
+ if (!filing.args.is_release) return
191
+ const issue_number = issue_number_of(url)
192
+ const is_linked =
193
+ Number.isSafeInteger(issue_number) &&
194
+ (await issue_release_cli.link(issue_number, filing.target))
195
+
196
+ if (is_linked) return
197
+ console.error(
198
+ `⚠ the release link did not complete for ${url} — run \`${GH_REPO_VARIABLE}=${filing.target} pnpm josh issue:release <N>\`.`,
199
+ )
200
+ }
201
+
180
202
  // The Issue exists once `create` returns, so a failed placement is reported rather than failed: a
181
203
  // non-zero exit would read as "not filed" and invite the second filing the scout exists to stop.
182
204
  async function place(url: string, target: string): Promise<void> {
183
- const issue_number = Number(github_issue_url.parse(url)?.issue_number)
205
+ const issue_number = issue_number_of(url)
184
206
  const exit_code = Number.isSafeInteger(issue_number)
185
207
  ? await epic_bundle_cli.report_for(issue_number, target, FRESH_ISSUE_POLL)
186
208
  : FAILURE_EXIT_CODE
@@ -204,6 +226,7 @@ async function send(filing: Filing, labels: ReadonlyArray<string>): Promise<numb
204
226
 
205
227
  if (url === undefined) return FAILURE_EXIT_CODE
206
228
  console.info(url)
229
+ await link_release(url, filing)
207
230
  await place(url, filing.target)
208
231
 
209
232
  return SUCCESS_EXIT_CODE
@@ -43,6 +43,9 @@ interface FileArguments {
43
43
  is_over_cap: boolean
44
44
  // `--no-auto-ok`: the Issue needs a person's judgement, so `auto-ok` is not applied (`issue-auto-ok.ts`).
45
45
  is_auto_ok_opted_out: boolean
46
+ // `--release`: the change reaches a consumer only once published, so the Issue is linked to the
47
+ // repository's release Issue as a blocker (`issue-release-cli.ts`).
48
+ is_release: boolean
46
49
  }
47
50
 
48
51
  const OPTIONS = {
@@ -54,6 +57,7 @@ const OPTIONS = {
54
57
  distinct: { type: 'string', multiple: true },
55
58
  'over-cap': { type: 'boolean' },
56
59
  'no-auto-ok': { type: 'boolean' },
60
+ release: { type: 'boolean' },
57
61
  } as const
58
62
 
59
63
  // The label a `--depth` / `--route` value names, or `undefined` when it names none — read against the
@@ -123,6 +127,7 @@ function arguments_of(values: ParsedValues, title: string | undefined): FileArgu
123
127
  repo: values.repo,
124
128
  is_over_cap: values['over-cap'] === true,
125
129
  is_auto_ok_opted_out: values['no-auto-ok'] === true,
130
+ is_release: values.release === true,
126
131
  }
127
132
  }
128
133
 
@@ -65,6 +65,12 @@ const NEEDS_HUMAN_REVIEW_LABEL = 'needs-human-review'
65
65
  // takes it off, because taking it off asserts the work is *not* done, which is the same claim in
66
66
  // reverse and is no more a run's to make.
67
67
  const ALREADY_DONE_LABEL = 'already-done'
68
+ // Marks the Issue that tracks a repository's next release (joshuafolkken/kit#3360). An Issue whose
69
+ // change reaches a consumer only once published is linked to it as a `blocked_by` blocker
70
+ // (`josh issue:release`), so "merged but not yet released" is visible in the backlog. A run applies
71
+ // it; **`auto-ok` never rides on it** — `issue-auto-ok.ts` refuses the default for it on every path,
72
+ // since a release is Tier C — and only a person opts it into unattended execution.
73
+ const RELEASE_LABEL = 'release'
68
74
 
69
75
  // The four labels that mean an open issue must not be handed to a run as it stands: an `epic`
70
76
  // tracks a batch and is never run directly (its children are), `in-progress` is already claimed by
@@ -228,6 +234,11 @@ const WORKFLOW_LABELS: ReadonlyArray<LabelDefinition> = [
228
234
  color: 'e99695',
229
235
  description: 'Offered first in a backlogrun; only a person removes it',
230
236
  },
237
+ {
238
+ name: RELEASE_LABEL,
239
+ color: 'fef2c0',
240
+ description: 'Tracks the next release; its blockers are the merged work it publishes',
241
+ },
231
242
  ...FILING_ROUTE_LABELS,
232
243
  ...DEPTH_LABELS,
233
244
  ]
@@ -340,6 +351,7 @@ const ALL_LABELS: ReadonlySet<string> = new Set([
340
351
  NEEDS_HUMAN_REVIEW_LABEL,
341
352
  ALREADY_DONE_LABEL,
342
353
  PRIORITY_HIGH_LABEL,
354
+ RELEASE_LABEL,
343
355
  INTERRUPT_ROUTE_LABEL,
344
356
  REVIEW_CAP_ROUTE_LABEL,
345
357
  REVIEW_ROUND2_SKIPPED_LABEL,
@@ -386,6 +398,7 @@ export {
386
398
  PRIORITY_HIGH_LABEL,
387
399
  RELEASE_CLASSIFICATION_LABELS,
388
400
  RELEASE_CLASSIFICATION_NAMES,
401
+ RELEASE_LABEL,
389
402
  REPOSITORY_LABELS,
390
403
  REVIEW_CAP_ROUTE_LABEL,
391
404
  REVIEW_ROUND2_SKIPPED_LABEL,
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env tsx
2
+ import { fileURLToPath } from 'node:url'
3
+ import { git_gh_command } from '#scripts/gh/git-gh-command'
4
+ import { git_gh_exec } from '#scripts/gh/git-gh-exec'
5
+ import { git_gh_issue_list, MAX_SCANNED } from '#scripts/gh/git-gh-issue-list'
6
+ import { git_gh_issue_write } from '#scripts/gh/git-gh-issue-write'
7
+ import { github_issue_url } from '#scripts/gh/github-issue-url'
8
+ import { cli_flags } from '#scripts/lib/cli-flags'
9
+ import { error_text } from '#scripts/lib/error-message'
10
+ import { repository_labels } from '#scripts/repo/repository-labels'
11
+ import { RELEASE_LABEL } from './issue-labels'
12
+ import { issue_release } from './issue-release'
13
+
14
+ // `josh issue:release <N>` — link Issue `<N>` to its repository's release Issue as a `blocked_by`
15
+ // blocker, filing the release Issue when none is open (joshuafolkken/kit#3360). `josh issue:file
16
+ // --release` calls the same `link` once its Issue exists, so a new and an existing Issue are linked
17
+ // by one body of code.
18
+ //
19
+ // The relation is written through `issue_add_blocked_by`, which addresses the repository `gh`
20
+ // resolves — the current one, or the target a cross-repository `issue:file` has pointed `GH_REPO` at.
21
+
22
+ const SUCCESS_EXIT_CODE = 0
23
+ const FAILURE_EXIT_CODE = 1
24
+ const ARGV_OFFSET = 2
25
+ const ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/u
26
+ const USAGE = 'Usage: josh issue:release <issue-number>'
27
+ const NONE = 'none'
28
+ const UNKNOWN_REPO_MESSAGE =
29
+ 'Could not read this repository from `git remote` — check `gh auth status`.'
30
+
31
+ interface ReleaseIssue {
32
+ issue_number: number
33
+ // Filed by this call, rather than found open.
34
+ is_filed: boolean
35
+ }
36
+
37
+ // `undefined` when the listing is unreadable, `NONE` when it reads and names no open release Issue.
38
+ async function find_open(target: string): Promise<number | typeof NONE | undefined> {
39
+ const outcome = await git_gh_issue_list.issue_list({
40
+ json_fields: 'number',
41
+ limit: MAX_SCANNED,
42
+ label: RELEASE_LABEL,
43
+ repo: target,
44
+ })
45
+
46
+ if (outcome.json === undefined) return undefined
47
+
48
+ return issue_release.open_release_of(outcome.json) ?? NONE
49
+ }
50
+
51
+ // The new release Issue's number, or `undefined` when the create call failed. Never `auto-ok`: the
52
+ // labels are `RELEASE_LABELS`, decided here rather than by `issue_auto_ok`, which refuses the label too.
53
+ async function file_release(target: string): Promise<number | undefined> {
54
+ repository_labels.ensure_labels(target)
55
+ const request = git_gh_issue_write.issue_create_request({
56
+ title: issue_release.title_of(target),
57
+ body: issue_release.body_of(target),
58
+ labels: issue_release.RELEASE_LABELS,
59
+ repo: target,
60
+ })
61
+
62
+ try {
63
+ const url = await git_gh_exec.exec_gh_api(request)
64
+ const issue_number = Number(github_issue_url.parse(url)?.issue_number) || undefined
65
+
66
+ if (issue_number === undefined) console.error(`✖ the release number is unreadable: ${url}`)
67
+
68
+ return issue_number
69
+ } catch (error) {
70
+ console.error(`✖ the release Issue could not be filed: ${error_text.message_of(error)}`)
71
+
72
+ return undefined
73
+ }
74
+ }
75
+
76
+ // An unreadable listing files nothing: a second release Issue beside one the listing failed to show
77
+ // would split the blockers between two releases.
78
+ async function release_of(target: string): Promise<ReleaseIssue | undefined> {
79
+ const open = await find_open(target)
80
+
81
+ if (open === undefined) {
82
+ console.error(`✖ the open ${RELEASE_LABEL} Issues of ${target} could not be listed`)
83
+
84
+ return undefined
85
+ }
86
+
87
+ if (open !== NONE) return { issue_number: open, is_filed: false }
88
+ const filed = await file_release(target)
89
+
90
+ return filed === undefined ? undefined : { issue_number: filed, is_filed: true }
91
+ }
92
+
93
+ function linked_line(release: ReleaseIssue, issue_number: number): string {
94
+ const how = release.is_filed ? 'filed' : 'open'
95
+
96
+ return `release: #${String(release.issue_number)} (${how}) is blocked by #${String(issue_number)}`
97
+ }
98
+
99
+ // Whether Issue `issue_number` of `target` now blocks the release Issue. Every outcome is printed.
100
+ async function link(issue_number: number, target: string): Promise<boolean> {
101
+ const release = await release_of(target)
102
+
103
+ if (release === undefined) return false
104
+ const blocked = String(release.issue_number)
105
+ const is_linked = await git_gh_issue_write.issue_add_blocked_by(blocked, String(issue_number))
106
+
107
+ if (is_linked) console.info(linked_line(release, issue_number))
108
+ else console.error(`✖ #${String(issue_number)} could not be recorded as a blocker of #${blocked}`)
109
+
110
+ return is_linked
111
+ }
112
+
113
+ // An unknown flag parses to no positionals, so it refuses the call like a missing number.
114
+ function positionals_of(argv: ReadonlyArray<string>): ReadonlyArray<string> {
115
+ return cli_flags.arguments_of(argv, {})?.positionals ?? []
116
+ }
117
+
118
+ // The number is the one positional: a missing, malformed or second one refuses the whole call.
119
+ function issue_number_of(argv: ReadonlyArray<string>): number | undefined {
120
+ const [issue_number, ...rest] = positionals_of(argv)
121
+ const is_single = rest.length === 0 && issue_number !== undefined
122
+
123
+ return is_single && ISSUE_NUMBER_PATTERN.test(issue_number) ? Number(issue_number) : undefined
124
+ }
125
+
126
+ async function run(argv: ReadonlyArray<string>): Promise<number> {
127
+ const issue_number = issue_number_of(argv)
128
+
129
+ if (issue_number === undefined) {
130
+ console.error(USAGE)
131
+
132
+ return FAILURE_EXIT_CODE
133
+ }
134
+
135
+ const current = await git_gh_command.repo_get_name_with_owner()
136
+
137
+ if (current === undefined) {
138
+ console.error(UNKNOWN_REPO_MESSAGE)
139
+
140
+ return FAILURE_EXIT_CODE
141
+ }
142
+
143
+ return (await link(issue_number, current)) ? SUCCESS_EXIT_CODE : FAILURE_EXIT_CODE
144
+ }
145
+
146
+ // `process.exitCode` rather than `process.exit()`, so the report on standard output drains first.
147
+ async function main(argv: ReadonlyArray<string>): Promise<void> {
148
+ process.exitCode = await run(argv)
149
+ }
150
+
151
+ const issue_release_cli = { link, run }
152
+
153
+ if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
154
+
155
+ export { issue_release_cli }