@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.
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
- package/.claude/skills/workflow-commands/followup.md +5 -0
- package/.claude/skills/workflow-commands/kickoff.md +1 -0
- package/.claude/skills/workflow-commands/latest-gate.md +1 -1
- package/.github/workflows/publish.yml +2 -0
- package/README.md +30 -31
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-JUDKDCDP.js → chunk-4TZVB44A.js} +8 -8
- package/dist/hooks/{chunk-RNWHN5BP.js → chunk-F3ELQ2AF.js} +3 -3
- package/dist/hooks/{chunk-VC3PZPZO.js → chunk-GPTFMK5E.js} +1 -1
- package/dist/hooks/{chunk-FIVDHQGT.js → chunk-KHGB5S3Y.js} +1 -1
- package/dist/hooks/{chunk-NV2JRZZO.js → chunk-KHX4U3BG.js} +17 -2
- package/dist/hooks/{chunk-F56MFS4B.js → chunk-KRBERBBI.js} +3 -3
- package/dist/hooks/{chunk-TQNPDZNH.js → chunk-QVSWU24O.js} +4 -4
- package/dist/hooks/{chunk-55HHG5PX.js → chunk-WK2XSHSI.js} +284 -63
- package/dist/hooks/{chunk-5VMKCCC4.js → chunk-XZYKEM6J.js} +2 -2
- package/dist/hooks/{chunk-6HJJXRBX.js → chunk-ZQGD4GBY.js} +1 -1
- 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/inputs.json +172 -170
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +3 -17
- package/dist/josh.js +279 -63
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +1 -0
- package/scripts/gate/core-admission.ts +162 -0
- package/scripts/gate/core-budget.ts +121 -67
- package/scripts/gate/gate-plan.ts +35 -2
- package/scripts/gate/gate-report.ts +28 -9
- package/scripts/gate/machine-capacity.ts +175 -0
- package/scripts/gate/verification-gate.ts +7 -2
- package/scripts/init/init.ts +5 -1
- package/scripts/init/package-manager-pin.ts +35 -0
- package/scripts/issue/issue-auto-ok.ts +31 -6
- package/scripts/issue/issue-file-cli.ts +31 -8
- package/scripts/issue/issue-file.ts +5 -0
- package/scripts/issue/issue-labels.ts +13 -0
- package/scripts/issue/issue-release-cli.ts +155 -0
- package/scripts/issue/issue-release.ts +58 -0
- package/scripts/josh/josh-command-types.ts +3 -0
- package/scripts/josh/josh-commands-development.ts +8 -0
- package/scripts/josh/josh-commands-issue.ts +8 -1
- package/scripts/josh/josh-commands-lane.ts +2 -1
- package/scripts/josh/josh-commands-maintenance.ts +2 -2
- package/scripts/josh/josh-logic.ts +4 -3
- package/scripts/lane/lane-cli.ts +9 -1
- package/scripts/lane/lane-leftover.ts +43 -29
- package/scripts/lane/lane-open.ts +5 -5
- package/scripts/lane/lane-paths.ts +13 -0
- package/scripts/lane/lane-registry.ts +11 -0
- package/scripts/lane/lane-stray.ts +140 -0
- package/scripts/release/github-release-environment.ts +1 -0
- package/scripts/release/github-release-runs.ts +115 -0
- package/scripts/release/github-release.ts +13 -67
- package/scripts/run/cut/run-cut-cli.ts +16 -3
- package/scripts/run/event/run-event-render.ts +1 -0
- package/scripts/run/event/run-event-stream.ts +4 -0
- package/scripts/run/run-step.ts +5 -2
- package/scripts/run/wake/run-wake-cli.ts +2 -3
- package/scripts/run/wake/run-wake-describe.ts +56 -5
- package/scripts/run/wake/run-wake-loop.ts +5 -13
- package/scripts/run/wake/run-wake.ts +15 -64
- package/scripts/version/latest-corepack.ts +109 -78
- 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 () =>
|
|
209
|
-
|
|
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
|
|
package/scripts/init/init.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
22
|
-
// filing step run in order (joshuafolkken/kit#2808): the third-party refusal, the body
|
|
23
|
-
// `## Origin` check for another repository, the `auto-ok` decision (joshuafolkken/kit#3213)
|
|
24
|
-
// run label it owes (joshuafolkken/kit#3313), the WIP cap count (joshuafolkken/kit#3181), the
|
|
25
|
-
// scout, the missing workflow labels created (joshuafolkken/kit#3176), the create call
|
|
26
|
-
// label, and `epic:bundle
|
|
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 =
|
|
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 }
|