@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,55 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process'
|
|
3
|
+
import { createInterface, type Interface } from 'node:readline/promises'
|
|
4
|
+
import { ask_yes_no_simple } from '#scripts/git/git-prompt'
|
|
5
|
+
import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
|
|
6
|
+
import type { Consent, StartOptions } from './start-plan'
|
|
7
|
+
|
|
8
|
+
interface Choices {
|
|
9
|
+
options: StartOptions
|
|
10
|
+
shape: ProjectShape
|
|
11
|
+
consent: Consent
|
|
12
|
+
root: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DECLINED =
|
|
16
|
+
'Stopped before creating the GitHub repository. Nothing was changed. Run josh init to set up without GitHub.'
|
|
17
|
+
|
|
18
|
+
// The detected profile is the default, so pressing Enter accepts it; the reason is shown because an
|
|
19
|
+
// ambiguous project is exactly the case this question exists for.
|
|
20
|
+
async function ask_profile(prompt: Interface, shape: ProjectShape): Promise<ProjectProfile> {
|
|
21
|
+
const question = `Profile — static or node [${shape.profile}, ${shape.reason}]: `
|
|
22
|
+
const raw_answer = await prompt.question(question)
|
|
23
|
+
const answer = raw_answer.trim()
|
|
24
|
+
if (answer.length === 0) return shape.profile
|
|
25
|
+
|
|
26
|
+
return project_profile.requested_profile(['--profile', answer]) ?? shape.profile
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function ask_repository(prompt: Interface, choices: Choices): Promise<void> {
|
|
30
|
+
const name = path.basename(choices.root)
|
|
31
|
+
const question = `Create a ${choices.options.visibility} GitHub repository "${name}" and push main? (y/n): `
|
|
32
|
+
|
|
33
|
+
if (!(await ask_yes_no_simple(prompt, question))) throw new Error(DECLINED)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function ask_choices(prompt: Interface, choices: Choices): Promise<ProjectProfile> {
|
|
37
|
+
const profile = choices.options.profile ?? (await ask_profile(prompt, choices.shape))
|
|
38
|
+
if (choices.consent === 'ask') await ask_repository(prompt, choices)
|
|
39
|
+
|
|
40
|
+
return profile
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function confirm_choices(choices: Choices): Promise<ProjectProfile> {
|
|
44
|
+
const prompt = createInterface({ input, output })
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return await ask_choices(prompt, choices)
|
|
48
|
+
} finally {
|
|
49
|
+
prompt.close()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const start_prompt = { confirm_choices }
|
|
54
|
+
export { start_prompt }
|
|
55
|
+
export type { Choices }
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { git_gh_exec } from '#scripts/git/git-gh-exec'
|
|
3
|
+
import { WORKFLOW_LABELS } from '#scripts/git/issue-labels'
|
|
4
|
+
import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
|
|
5
|
+
import { execaSync } from 'execa'
|
|
6
|
+
import { main as init_main } from './init'
|
|
7
|
+
import { PACKAGE_DIR } from './init-paths'
|
|
8
|
+
import type { ProjectProfile, ProjectShape } from './project-profile'
|
|
9
|
+
import { start_plan, type GitState, type StartStep, type Visibility } from './start-plan'
|
|
10
|
+
|
|
11
|
+
interface StepContext {
|
|
12
|
+
root: string
|
|
13
|
+
profile: ProjectProfile
|
|
14
|
+
visibility: Visibility
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type WorkflowLabel = (typeof WORKFLOW_LABELS)[number]
|
|
18
|
+
|
|
19
|
+
const DEFAULT_BRANCH = 'main'
|
|
20
|
+
const LABELS_ENDPOINT = 'repos/{owner}/{repo}/labels'
|
|
21
|
+
const GH_INSTALL_HINT =
|
|
22
|
+
'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, run gh auth login, then run josh start again. Nothing was changed.'
|
|
23
|
+
const GH_AUTH_HINT =
|
|
24
|
+
'GitHub CLI is not signed in. Run gh auth login, then run josh start again. Nothing was changed.'
|
|
25
|
+
|
|
26
|
+
function succeeds(command: string, args: ReadonlyArray<string>, root: string): boolean {
|
|
27
|
+
return execaSync(command, args, { cwd: root, reject: false }).exitCode === 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function read_output(
|
|
31
|
+
command: string,
|
|
32
|
+
args: ReadonlyArray<string>,
|
|
33
|
+
root: string,
|
|
34
|
+
): string | undefined {
|
|
35
|
+
const result = execaSync(command, args, { cwd: root, reject: false })
|
|
36
|
+
|
|
37
|
+
return result.exitCode === 0 ? result.stdout.trim() : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Every step spawned here dials GitHub directly, past a scanner's loopback proxy, the same as every
|
|
41
|
+
// other `gh` spawn under scripts/ (joshuafolkken/kit#2436); a local `git` step is unaffected by it.
|
|
42
|
+
function run(command: string, args: ReadonlyArray<string>, root: string): void {
|
|
43
|
+
execaSync(command, args, { ...git_gh_exec.direct_environment(), cwd: root, stdio: 'inherit' })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function read_git_state(root: string, shape: ProjectShape): GitState {
|
|
47
|
+
const { has_git, has_github } = shape
|
|
48
|
+
|
|
49
|
+
if (!has_git) {
|
|
50
|
+
return { has_git, has_github, has_origin: false, branch: undefined, has_commits: false }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const has_origin = succeeds('git', ['remote', 'get-url', 'origin'], root)
|
|
54
|
+
const branch = read_output('git', ['symbolic-ref', '--short', 'HEAD'], root)
|
|
55
|
+
const has_commits = succeeds('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], root)
|
|
56
|
+
|
|
57
|
+
return { has_git, has_github, has_origin, branch, has_commits }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Every step talks to GitHub, so the CLI is checked before the first write rather than at the step
|
|
61
|
+
// that needs it — a missing `gh` then leaves the directory exactly as it was.
|
|
62
|
+
function github_cli_refusal(root: string): string | undefined {
|
|
63
|
+
if (!succeeds('gh', ['--version'], root)) return GH_INSTALL_HINT
|
|
64
|
+
if (!succeeds('gh', ['auth', 'status'], root)) return GH_AUTH_HINT
|
|
65
|
+
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// `josh init` refuses to run inside the kit source repository by printing and setting the exit code
|
|
70
|
+
// rather than throwing, so its step would pass and the later steps would commit and publish the
|
|
71
|
+
// source repository. The same check is raised before the first write instead.
|
|
72
|
+
function self_run_refusal(root: string): string | undefined {
|
|
73
|
+
return self_sync_guard.self_sync_refusal(PACKAGE_DIR, root)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function create_git_repository(context: StepContext): void {
|
|
77
|
+
run('git', ['init', `--initial-branch=${DEFAULT_BRANCH}`], context.root)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The same setup `josh init` runs, called rather than copied, with the profile the user confirmed.
|
|
81
|
+
function initialize(context: StepContext): void {
|
|
82
|
+
init_main(['--profile', context.profile])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function commit_all(context: StepContext): void {
|
|
86
|
+
run('git', ['add', '--all'], context.root)
|
|
87
|
+
// The first commit of a new repository has no branch to come from, so the hook that keeps commits
|
|
88
|
+
// off main — installed by the initialize step in a node project — would refuse the only commit
|
|
89
|
+
// that has to land there.
|
|
90
|
+
run('git', ['commit', '--no-verify', '--message', 'Initial commit'], context.root)
|
|
91
|
+
// A repository created by an older `git init` may name its unborn branch `master`.
|
|
92
|
+
run('git', ['branch', '--move', '--force', DEFAULT_BRANCH], context.root)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function create_github_repository(context: StepContext): void {
|
|
96
|
+
const name = path.basename(context.root)
|
|
97
|
+
const visibility = `--${context.visibility}`
|
|
98
|
+
const source = ['--source', '.', '--remote', 'origin', '--push']
|
|
99
|
+
|
|
100
|
+
run('gh', ['repo', 'create', name, visibility, ...source], context.root)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Labels already present are left as they are — only the missing ones are created, so a repository
|
|
104
|
+
// that recolored a label keeps its choice. GitHub compares label names case-insensitively.
|
|
105
|
+
function missing_workflow_labels(existing: ReadonlyArray<string>): ReadonlyArray<WorkflowLabel> {
|
|
106
|
+
const names = new Set(existing.map((name) => name.toLowerCase()))
|
|
107
|
+
|
|
108
|
+
return WORKFLOW_LABELS.filter((label) => !names.has(label.name))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function create_label(label: WorkflowLabel, root: string): void {
|
|
112
|
+
const fields = [`name=${label.name}`, `color=${label.color}`, `description=${label.description}`]
|
|
113
|
+
|
|
114
|
+
run('gh', ['api', LABELS_ENDPOINT, ...fields.flatMap((field) => ['-f', field]), '--silent'], root)
|
|
115
|
+
console.info(` ✔ label ${label.name}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function add_missing_labels(context: StepContext): void {
|
|
119
|
+
const listing = execaSync('gh', ['api', '--paginate', LABELS_ENDPOINT, '--jq', '.[].name'], {
|
|
120
|
+
...git_gh_exec.direct_environment(),
|
|
121
|
+
cwd: context.root,
|
|
122
|
+
})
|
|
123
|
+
const existing = listing.stdout.split('\n').filter((name) => name.length > 0)
|
|
124
|
+
|
|
125
|
+
for (const label of missing_workflow_labels(existing)) create_label(label, context.root)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const STEP_ACTIONS: Readonly<Record<StartStep, (context: StepContext) => void>> = {
|
|
129
|
+
git_init: create_git_repository,
|
|
130
|
+
initialize,
|
|
131
|
+
commit: commit_all,
|
|
132
|
+
repository: create_github_repository,
|
|
133
|
+
labels: add_missing_labels,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function run_step(
|
|
137
|
+
step: StartStep,
|
|
138
|
+
context: StepContext,
|
|
139
|
+
completed: ReadonlyArray<StartStep>,
|
|
140
|
+
): void {
|
|
141
|
+
try {
|
|
142
|
+
STEP_ACTIONS[step](context)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const cause = error instanceof Error ? error.message : String(error)
|
|
145
|
+
|
|
146
|
+
throw new Error(start_plan.progress_report(step, completed, cause), { cause: error })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext): void {
|
|
151
|
+
for (const [index, step] of steps.entries()) {
|
|
152
|
+
const position = `${String(index + 1)}/${String(steps.length)}`
|
|
153
|
+
|
|
154
|
+
console.info(`\n[${position}] ${start_plan.STEP_LABELS[step]}`)
|
|
155
|
+
run_step(step, context, steps.slice(0, index))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const start_steps = {
|
|
160
|
+
read_git_state,
|
|
161
|
+
github_cli_refusal,
|
|
162
|
+
self_run_refusal,
|
|
163
|
+
missing_workflow_labels,
|
|
164
|
+
run_steps,
|
|
165
|
+
}
|
|
166
|
+
export { start_steps }
|
|
167
|
+
export type { StepContext }
|
package/scripts/init/start.ts
CHANGED
|
@@ -1,30 +1,106 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { project_profile, type ProjectProfile } from './project-profile'
|
|
4
|
+
import {
|
|
5
|
+
start_plan,
|
|
6
|
+
type Consent,
|
|
7
|
+
type Interaction,
|
|
8
|
+
type StartOptions,
|
|
9
|
+
type StartStep,
|
|
10
|
+
} from './start-plan'
|
|
11
|
+
import { start_prompt, type Choices } from './start-prompt'
|
|
12
|
+
import { start_steps } from './start-steps'
|
|
5
13
|
|
|
6
14
|
const ARGUMENT_START_INDEX = 2
|
|
15
|
+
const READY_MESSAGE = 'GitHub workflow ready. Use kickoff new in your assistant to plan an Issue.'
|
|
16
|
+
const START_USAGE = 'josh start [--profile static|node] [--yes] [--github] [--public]'
|
|
17
|
+
const YES_FLAG = '--yes'
|
|
18
|
+
const GITHUB_FLAG = '--github'
|
|
19
|
+
const PUBLIC_FLAG = '--public'
|
|
20
|
+
const SWITCHES: ReadonlySet<string> = new Set([YES_FLAG, GITHUB_FLAG, PUBLIC_FLAG])
|
|
7
21
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
22
|
+
interface Prepared {
|
|
23
|
+
steps: ReadonlyArray<StartStep>
|
|
24
|
+
interaction: Interaction
|
|
25
|
+
choices: Choices
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The switches are peeled off first, so what remains is exactly the `--profile` pair `josh init`
|
|
29
|
+
// accepts — one parser for the profile in both commands.
|
|
30
|
+
function parse_start_options(args: ReadonlyArray<string>): StartOptions {
|
|
31
|
+
const profile_args = args.filter((argument) => !SWITCHES.has(argument))
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
profile: project_profile.requested_profile(profile_args, START_USAGE),
|
|
35
|
+
is_yes: args.includes(YES_FLAG),
|
|
36
|
+
is_github: args.includes(GITHUB_FLAG),
|
|
37
|
+
visibility: args.includes(PUBLIC_FLAG) ? 'public' : 'private',
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function settle<T>(verdict: { value: T | undefined; refusal: string | undefined }): T {
|
|
42
|
+
if (verdict.value === undefined) throw new Error(verdict.refusal)
|
|
11
43
|
|
|
12
|
-
return
|
|
44
|
+
return verdict.value
|
|
13
45
|
}
|
|
14
46
|
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
47
|
+
function stop_on(refusal: string | undefined): void {
|
|
48
|
+
if (refusal !== undefined) throw new Error(refusal)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// An unattended run already holds every answer: `--profile` or the detected one, and a consent that
|
|
52
|
+
// `github_consent` only grants when `--github` was given.
|
|
53
|
+
async function resolve_profile(
|
|
54
|
+
choices: Choices,
|
|
55
|
+
interaction: Interaction,
|
|
56
|
+
): Promise<ProjectProfile> {
|
|
57
|
+
if (interaction === 'unattended') return choices.options.profile ?? choices.shape.profile
|
|
58
|
+
|
|
59
|
+
return await start_prompt.confirm_choices(choices)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Every refusal is raised here, before the first write, so a run that stops here changed nothing.
|
|
63
|
+
function prepare(args: ReadonlyArray<string>, is_tty: boolean, root: string): Prepared {
|
|
64
|
+
stop_on(start_steps.self_run_refusal(root))
|
|
65
|
+
const options = parse_start_options(args)
|
|
66
|
+
const interaction = settle(start_plan.interaction_of(options, is_tty))
|
|
67
|
+
const shape = project_profile.inspect_project(root, options.profile)
|
|
68
|
+
const plan = start_plan.plan_steps(start_steps.read_git_state(root, shape))
|
|
69
|
+
|
|
70
|
+
stop_on(plan.refusal)
|
|
71
|
+
const consent: Consent = settle(start_plan.github_consent(plan.steps, options, interaction))
|
|
72
|
+
|
|
73
|
+
stop_on(start_steps.github_cli_refusal(root))
|
|
74
|
+
|
|
75
|
+
return { steps: plan.steps, interaction, choices: { options, shape, consent, root } }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
|
|
79
|
+
const { steps, interaction, choices } = prepare(args, is_tty, process.cwd())
|
|
80
|
+
|
|
81
|
+
console.info(start_plan.plan_summary(steps))
|
|
82
|
+
const profile = await resolve_profile(choices, interaction)
|
|
83
|
+
const { root, options } = choices
|
|
84
|
+
|
|
85
|
+
start_steps.run_steps(steps, { root, profile, visibility: options.visibility })
|
|
86
|
+
console.info(`\n${READY_MESSAGE}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function run(args: ReadonlyArray<string>, is_tty: boolean): Promise<number> {
|
|
90
|
+
try {
|
|
91
|
+
await main(args, is_tty)
|
|
92
|
+
|
|
93
|
+
return 0
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
96
|
+
|
|
97
|
+
return 1
|
|
98
|
+
}
|
|
23
99
|
}
|
|
24
100
|
|
|
25
101
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
26
|
-
|
|
102
|
+
process.exitCode = await run(process.argv.slice(ARGUMENT_START_INDEX), process.stdin.isTTY)
|
|
27
103
|
}
|
|
28
104
|
|
|
29
|
-
const start = {
|
|
105
|
+
const start = { run, parse_start_options }
|
|
30
106
|
export { start }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { git_closes_keyword } from '#scripts/git/git-closes-keyword'
|
|
2
|
+
import { git_gh_api_path } from '#scripts/git/git-gh-api-path'
|
|
3
|
+
import { git_gh_exec } from '#scripts/git/git-gh-exec'
|
|
4
|
+
import { git_gh_repo } from '#scripts/git/git-gh-repo'
|
|
5
|
+
import { parse_json_object_safe } from '#scripts/git/parse-json-array'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
|
|
8
|
+
// **The merged pull request that closes an issue GitHub left open** (joshuafolkken/kit#2769). A
|
|
9
|
+
// `backlogrun` child whose pull request merged while GitHub did not apply its `closes #N`
|
|
10
|
+
// (joshuafolkken/kit#2770) came back OPEN, and `run:merge` counted it a failure and parked it — the
|
|
11
|
+
// work had landed, only the issue's close had not.
|
|
12
|
+
//
|
|
13
|
+
// **A merged reference is not enough; the body must close this issue.** A merged pull request that
|
|
14
|
+
// merely mentions the issue — a follow-up, a sibling in the same epic — cross-references it too, and
|
|
15
|
+
// closing on that would close unfinished work. So the pull request counts only when its body's
|
|
16
|
+
// `closes #N` names this issue, read through the one parser every `closes #N` reader shares.
|
|
17
|
+
//
|
|
18
|
+
// **Only references after the last reopen count.** A reopened issue whose earlier attempt merged
|
|
19
|
+
// still carries that pull request's reference, and reading it would close the reopened work — the
|
|
20
|
+
// same reason `issue-merged.ts` reads the last closed/reopened event.
|
|
21
|
+
//
|
|
22
|
+
// **Only a pull request in this repository counts.** A bare `closes #N` names an issue in the pull
|
|
23
|
+
// request's own repository, so a merged pull request elsewhere whose `closes #N` carries the same
|
|
24
|
+
// number closes a different issue — its cross-reference here is a mention, not a close.
|
|
25
|
+
|
|
26
|
+
const LINE_SEPARATOR = '\n'
|
|
27
|
+
// One compact JSON object per merged pull request that cross-references the issue, and a
|
|
28
|
+
// `{"reopened":true}` marker per reopen, in timeline order.
|
|
29
|
+
const TIMELINE_FILTER = [
|
|
30
|
+
'.[]',
|
|
31
|
+
'| if .event == "cross-referenced" and .source.issue.pull_request.merged_at != null',
|
|
32
|
+
'then {url: .source.issue.html_url, repo: .source.issue.repository.full_name, body: (.source.issue.body // "")}',
|
|
33
|
+
'elif .event == "reopened" then {reopened: true}',
|
|
34
|
+
'else empty end',
|
|
35
|
+
].join(' ')
|
|
36
|
+
|
|
37
|
+
const reference_schema = z.object({ url: z.string(), repo: z.string(), body: z.string() })
|
|
38
|
+
const reopen_schema = z.object({ reopened: z.literal(true) })
|
|
39
|
+
const row_schema = z.union([reference_schema, reopen_schema])
|
|
40
|
+
|
|
41
|
+
type MergedReference = z.infer<typeof reference_schema>
|
|
42
|
+
type TimelineRow = z.infer<typeof row_schema>
|
|
43
|
+
|
|
44
|
+
function is_reference(row: TimelineRow): row is MergedReference {
|
|
45
|
+
return 'url' in row
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parse_references(raw: string): Array<MergedReference> {
|
|
49
|
+
const rows = raw
|
|
50
|
+
.split(LINE_SEPARATOR)
|
|
51
|
+
.map((line) => parse_json_object_safe(line, row_schema))
|
|
52
|
+
.filter((row) => row !== undefined)
|
|
53
|
+
const last_reopen = rows.findLastIndex((row) => !is_reference(row))
|
|
54
|
+
|
|
55
|
+
return rows.slice(last_reopen + 1).filter((row) => is_reference(row))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pick_closing_pr(
|
|
59
|
+
references: ReadonlyArray<MergedReference>,
|
|
60
|
+
issue: string,
|
|
61
|
+
repo: string,
|
|
62
|
+
): string | undefined {
|
|
63
|
+
return references.find(
|
|
64
|
+
(reference) =>
|
|
65
|
+
reference.repo === repo &&
|
|
66
|
+
git_closes_keyword.parse_closes_issue_number(reference.body) === issue,
|
|
67
|
+
)?.url
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Every doubt falls to `undefined` — an unreadable timeline or repository name leaves the child
|
|
71
|
+
// judged as it was, never closed on a guess.
|
|
72
|
+
async function read_closing_pr(issue: string): Promise<string | undefined> {
|
|
73
|
+
try {
|
|
74
|
+
const [raw, repo] = await Promise.all([
|
|
75
|
+
git_gh_exec.exec_gh_api({
|
|
76
|
+
path: `${git_gh_api_path.issue_api_path(issue)}/timeline?per_page=100`,
|
|
77
|
+
should_paginate: true,
|
|
78
|
+
jq_filter: TIMELINE_FILTER,
|
|
79
|
+
}),
|
|
80
|
+
git_gh_repo.repo_get_name_with_owner(),
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
if (repo === undefined) return undefined
|
|
84
|
+
|
|
85
|
+
return pick_closing_pr(parse_references(raw), issue, repo)
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const issue_closing_pr = { parse_references, pick_closing_pr, read_closing_pr }
|
|
92
|
+
|
|
93
|
+
export { issue_closing_pr }
|
|
@@ -449,10 +449,11 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
|
|
|
449
449
|
],
|
|
450
450
|
],
|
|
451
451
|
"init": [
|
|
452
|
-
"[--profile static|node]",
|
|
452
|
+
"[--profile static|node] [--no-install]",
|
|
453
453
|
"developer",
|
|
454
454
|
[
|
|
455
455
|
"files",
|
|
456
|
+
"network",
|
|
456
457
|
"processes",
|
|
457
458
|
],
|
|
458
459
|
],
|
|
@@ -1103,12 +1104,13 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
|
|
|
1103
1104
|
],
|
|
1104
1105
|
],
|
|
1105
1106
|
"start": [
|
|
1106
|
-
"[--profile static|node]",
|
|
1107
|
+
"[--profile static|node] [--yes] [--github] [--public]",
|
|
1107
1108
|
"developer",
|
|
1108
1109
|
[
|
|
1109
1110
|
"files",
|
|
1110
1111
|
"git",
|
|
1111
1112
|
"network",
|
|
1113
|
+
"processes",
|
|
1112
1114
|
],
|
|
1113
1115
|
],
|
|
1114
1116
|
"stash:pop": [
|
|
@@ -14,15 +14,23 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
|
|
|
14
14
|
},
|
|
15
15
|
start: {
|
|
16
16
|
script: 'scripts/init/start.ts',
|
|
17
|
-
description: '
|
|
17
|
+
description: 'Create a new project for the GitHub Issue workflow, from git init to labels',
|
|
18
18
|
category: 'Project',
|
|
19
|
-
reference: [
|
|
19
|
+
reference: [
|
|
20
|
+
`${PROFILE_ARGUMENTS} [--yes] [--github] [--public]`,
|
|
21
|
+
'developer',
|
|
22
|
+
['files', 'git', 'network', 'processes'],
|
|
23
|
+
],
|
|
20
24
|
},
|
|
21
25
|
init: {
|
|
22
26
|
script: 'scripts/init/init.ts',
|
|
23
27
|
description: 'Initialize config in a new project',
|
|
24
28
|
category: 'Project',
|
|
25
|
-
reference: [
|
|
29
|
+
reference: [
|
|
30
|
+
`${PROFILE_ARGUMENTS} [--no-install]`,
|
|
31
|
+
'developer',
|
|
32
|
+
['files', 'network', 'processes'],
|
|
33
|
+
],
|
|
26
34
|
},
|
|
27
35
|
sync: {
|
|
28
36
|
script: 'scripts/sync/sync.ts',
|
|
@@ -52,9 +52,9 @@ const WORKFLOW_COMMANDS: Record<string, CommandEntry> = {
|
|
|
52
52
|
reference: ['--task-type <type> --body <text>', 'automation', ['notifications']],
|
|
53
53
|
tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
|
|
54
54
|
},
|
|
55
|
-
// The observation ledger's
|
|
56
|
-
//
|
|
57
|
-
//
|
|
55
|
+
// The observation ledger's commit path for the lines no run's own commit carried
|
|
56
|
+
// (joshuafolkken/kit#1756, joshuafolkken/kit#2763): a lane's lines sit in the primary checkout, which
|
|
57
|
+
// its commit cannot see, and the recurrence count the promotion rule reads is a count of main.
|
|
58
58
|
'observations:flush': {
|
|
59
59
|
script: 'scripts/observations/observations-flush-cli.ts',
|
|
60
60
|
description: 'Commit the observation ledger as a pull request of its own, and merge it',
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { OBSERVATION_LEDGER_PATH } from './observation-ledger'
|
|
4
|
+
import { observation_ledger_line, type BrokenLine } from './observation-ledger-line'
|
|
5
|
+
import { observation_ledger_migrate } from './observation-ledger-migrate'
|
|
6
|
+
|
|
7
|
+
// **What has to hold before a ledger line is committed, asked by both of the paths that commit one**
|
|
8
|
+
// (joshuafolkken/kit#2763): `pnpm josh observations:flush` and the run's own commit in
|
|
9
|
+
// `scripts/git/git-staging.ts`. Lines still on the old path are moved first (joshuafolkken/kit#2724),
|
|
10
|
+
// so the commit carries the move and the lines together, and every entry line is then parsed against
|
|
11
|
+
// the grammar (joshuafolkken/kit#2123) — each caller decides what a broken line costs it.
|
|
12
|
+
//
|
|
13
|
+
// **A ledger that is not there has no line to break** — the change a status saw is then its deletion,
|
|
14
|
+
// and a commit carries that like any other change to it.
|
|
15
|
+
async function read_ledger(root: string): Promise<string> {
|
|
16
|
+
try {
|
|
17
|
+
return await readFile(path.join(root, OBSERVATION_LEDGER_PATH), 'utf8')
|
|
18
|
+
} catch {
|
|
19
|
+
return ''
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function prepare(root: string): Promise<ReadonlyArray<BrokenLine>> {
|
|
24
|
+
observation_ledger_migrate.migrate(root)
|
|
25
|
+
|
|
26
|
+
return observation_ledger_line.broken_ledger_lines(await read_ledger(root))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const observation_ledger_prepare = { prepare }
|
|
30
|
+
|
|
31
|
+
export { observation_ledger_prepare }
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// The observation ledger's one path, named here rather than inside any of the places that act on it
|
|
2
|
-
// (joshuafolkken/kit#1756). `git-staging.ts`
|
|
3
|
-
// `hook-gate-reuse.ts` excludes it from what a hook calls a carried
|
|
4
|
-
// `observations-flush.ts`
|
|
5
|
-
// answers to one question, and a rename reaching only some of them would silently
|
|
6
|
-
// contamination the exclusion exists to prevent.
|
|
2
|
+
// (joshuafolkken/kit#1756). `git-staging.ts` stages it with the run's own commit when its grammar
|
|
3
|
+
// holds (joshuafolkken/kit#2763), `hook-gate-reuse.ts` excludes it from what a hook calls a carried
|
|
4
|
+
// tree, and `observations-flush.ts` commits what no run carried — so a literal in each would be three
|
|
5
|
+
// answers to one question, and a rename reaching only some of them would silently split them.
|
|
7
6
|
//
|
|
8
7
|
// **It sits under `docs/maintainers/`, apart from the reader-facing documents** (joshuafolkken/kit#2724).
|
|
9
8
|
const OBSERVATION_LEDGER_PATH = 'docs/maintainers/observations.md'
|
|
@@ -70,6 +69,7 @@ const observation_ledger = {
|
|
|
70
69
|
has_pending_append,
|
|
71
70
|
is_ledger_line,
|
|
72
71
|
is_ledger_path,
|
|
72
|
+
is_migration_claim,
|
|
73
73
|
ledger_paths,
|
|
74
74
|
status_path,
|
|
75
75
|
}
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises'
|
|
2
1
|
import { git_command } from '#scripts/git/git-command'
|
|
3
2
|
import { git_gh_command } from '#scripts/git/git-gh-command'
|
|
4
3
|
import { main_sync } from '#scripts/git/main-sync'
|
|
5
4
|
import { observation_ledger, OBSERVATION_LEDGER_PATH } from './observation-ledger'
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import type { BrokenLine } from './observation-ledger-line'
|
|
6
|
+
import { observation_ledger_prepare } from './observation-ledger-prepare'
|
|
8
7
|
import { observations_flush_landing } from './observations-flush-landing'
|
|
9
8
|
|
|
10
|
-
// The commit path the
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// staging (`scripts/git/git-staging.ts`) and this is the one thing that stages it.
|
|
9
|
+
// The commit path for the ledger lines no run's own commit carried (joshuafolkken/kit#1756). A run in
|
|
10
|
+
// the primary checkout commits its appended lines with its own pull request (`scripts/git/
|
|
11
|
+
// git-staging.ts`, joshuafolkken/kit#2763), so what is left here is a lane's lines — they sit in the
|
|
12
|
+
// primary checkout, which a lane's commit cannot see — and whatever was appended after a commit.
|
|
15
13
|
//
|
|
16
14
|
// **The shape is `scripts/release/release-publish.ts`'s, deliberately.** Both open a pull request of
|
|
17
15
|
// their own over one path, wait on the same required checks every other pull request waits on, and
|
|
@@ -57,7 +55,7 @@ function branch_name_for(stamp: string): string {
|
|
|
57
55
|
// single issue, and the ledger's own lines say where each one was seen.
|
|
58
56
|
function pull_request_body(): string {
|
|
59
57
|
return [
|
|
60
|
-
`Appended observations from \`${OBSERVATION_LEDGER_PATH}
|
|
58
|
+
`Appended observations from \`${OBSERVATION_LEDGER_PATH}\` that no run's own commit carried.`,
|
|
61
59
|
'',
|
|
62
60
|
'The ledger is append-only, and a second line under one key is what promotes an observation to an issue — so this pull request adds lines and changes none.',
|
|
63
61
|
'',
|
|
@@ -373,31 +371,14 @@ function broken_lines_message(broken: ReadonlyArray<BrokenLine>): string {
|
|
|
373
371
|
|
|
374
372
|
// **A malformed line is refused before a branch is cut, not after.** The ledger path was collected
|
|
375
373
|
// without a line ever being parsed (joshuafolkken/kit#2123), so a broken append could ride a flush
|
|
376
|
-
// through; validating here keeps the committed ledger to its grammar.
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
async function read_ledger(): Promise<string> {
|
|
381
|
-
try {
|
|
382
|
-
return await readFile(OBSERVATION_LEDGER_PATH, 'utf8')
|
|
383
|
-
} catch {
|
|
384
|
-
return ''
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async function refuse_broken_ledger(): Promise<void> {
|
|
389
|
-
const broken = observation_ledger_line.broken_ledger_lines(await read_ledger())
|
|
374
|
+
// through; validating here keeps the committed ledger to its grammar. The migration and the parse are
|
|
375
|
+
// `observation-ledger-prepare.ts`'s, shared with the run's own commit (joshuafolkken/kit#2763).
|
|
376
|
+
async function prepare_ledger(): Promise<void> {
|
|
377
|
+
const broken = await observation_ledger_prepare.prepare(process.cwd())
|
|
390
378
|
|
|
391
379
|
if (broken.length > 0) throw new Error(broken_lines_message(broken))
|
|
392
380
|
}
|
|
393
381
|
|
|
394
|
-
// Lines still on the ledger's old path are moved before anything is validated or staged
|
|
395
|
-
// (joshuafolkken/kit#2724), so the flush commits the move and the lines together.
|
|
396
|
-
async function prepare_ledger(): Promise<void> {
|
|
397
|
-
observation_ledger_migrate.migrate(process.cwd())
|
|
398
|
-
await refuse_broken_ledger()
|
|
399
|
-
}
|
|
400
|
-
|
|
401
382
|
// **Nothing pulls in front of the branch, and that is deliberate.** The ledger is dirty by
|
|
402
383
|
// definition at this point, so `git pull --ff-only` aborts on it in exactly the case a pull would
|
|
403
384
|
// have been for — an upstream flush that already advanced the ledger — and reports a failure about
|