@joshuafolkken/kit 1.1435.0 → 1.1453.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-park.md +2 -0
- package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
- package/.claude/skills/workflow-commands/backlogrun.md +2 -2
- package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
- package/.claude/skills/workflow-commands/halfrun.md +1 -1
- package/.claude/skills/workflow-commands/kickoff.md +12 -13
- package/.claude/skills/workflow-commands/observation-filing.md +1 -1
- package/.claude/skills/workflow-commands/prerequisite.md +6 -0
- package/.claude/skills/workflow-commands/retrospective.md +5 -5
- package/.claude/skills/workflow-commands/split-assessment.md +2 -2
- package/README.md +15 -17
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
- package/dist/hooks/{chunk-JXQBK323.js → chunk-5C7L6FRC.js} +131 -104
- package/dist/hooks/{chunk-EMB55BDF.js → chunk-D3NJXN5M.js} +2 -2
- package/dist/hooks/{chunk-7KMN5QB5.js → chunk-EF5GQ6WS.js} +3 -3
- package/dist/hooks/{chunk-2G5F2YCD.js → chunk-LBFYEOXJ.js} +3 -3
- package/dist/hooks/{chunk-XER6SSIB.js → chunk-MBJYQPEK.js} +16 -6
- package/dist/hooks/{chunk-QL6QTPWN.js → chunk-NO4AALP5.js} +3 -3
- package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
- package/dist/hooks/{chunk-MSYSUSY6.js → chunk-SHYUQOIS.js} +5 -5
- package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
- package/dist/hooks/{chunk-MU4LJFIV.js → chunk-W5ZKGTSY.js} +3 -3
- package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-Y3GKIKGW.js} +11 -5
- package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
- package/dist/hooks/codex-hook-adapter.js +13 -13
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/inputs.json +241 -240
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +3 -3
- package/dist/hooks/stop-guard.js +6 -6
- package/dist/josh.js +33 -10
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +10 -8
- package/prompts/review.md +8 -4
- package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
- package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
- package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
- package/scripts/auto-ok/auto-ok-judge.ts +67 -0
- package/scripts/backlog/backlog-drive-cli.ts +14 -0
- package/scripts/backlog/backlog-drive.ts +22 -3
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
- package/scripts/gate/project-checks.ts +5 -2
- package/scripts/gh/git-gh-issue-rest.ts +1 -0
- package/scripts/gh/git-gh-issue.ts +9 -0
- package/scripts/init/init-actions.ts +3 -3
- package/scripts/init/init-ai-copy.ts +1 -1
- package/scripts/init/init-basic.ts +1 -1
- package/scripts/init/init-bootstrap.ts +6 -2
- package/scripts/init/init-dry-run-report.ts +150 -0
- package/scripts/init/init-dry-run.ts +189 -0
- package/scripts/init/init-install.ts +8 -1
- package/scripts/init/init.ts +57 -19
- package/scripts/init/kit-setup-state.ts +11 -17
- package/scripts/init/own-formatter.ts +21 -0
- package/scripts/init/project-profile.ts +25 -6
- package/scripts/init/start-dry-run.ts +70 -0
- package/scripts/init/start-plan.ts +8 -3
- package/scripts/init/start-setup-pr.ts +13 -24
- package/scripts/init/start-steps.ts +16 -1
- package/scripts/init/start.ts +38 -18
- package/scripts/init/working-tree.ts +116 -0
- package/scripts/issue/issue-auto-ok.ts +68 -99
- package/scripts/issue/issue-file-cli.ts +17 -17
- package/scripts/issue/issue-file.ts +1 -6
- package/scripts/issue/issue-labels.ts +6 -2
- package/scripts/issue/issue-release-cli.ts +69 -23
- package/scripts/issue/issue-release.ts +3 -1
- package/scripts/josh/josh-commands-ai.ts +11 -0
- package/scripts/josh/josh-commands-issue.ts +3 -3
- package/scripts/josh/josh-commands-project.ts +2 -2
- package/scripts/josh/process-identity.ts +32 -6
- package/scripts/lane/lane-broker-server.ts +69 -0
- package/scripts/lane/lane-session-broker.ts +37 -14
- package/scripts/observations/observation-ledger-migrate.ts +3 -15
- package/scripts/release/release-issue-close.ts +133 -0
- package/scripts/release/release-publish.ts +12 -1
- package/scripts/repo/repository-labels.ts +11 -1
- package/scripts/review/comment-ranges.ts +199 -0
- package/scripts/review/review-brief-cli.ts +22 -5
- package/scripts/review/review-code-tree.ts +119 -0
- package/scripts/review/review-round2-cli.ts +16 -5
- package/scripts/review/review-round2.ts +30 -10
- package/scripts/review/review-stamps.ts +22 -1
- package/scripts/uninstall/distributed-files.ts +1 -1
|
@@ -15,6 +15,7 @@ import { start_exec } from './start-exec'
|
|
|
15
15
|
import { start_plan, type GitState, type StartStep, type Visibility } from './start-plan'
|
|
16
16
|
import { start_setup_pr } from './start-setup-pr'
|
|
17
17
|
import { workflow_mark } from './workflow-mark'
|
|
18
|
+
import { working_tree } from './working-tree'
|
|
18
19
|
|
|
19
20
|
interface StepContext {
|
|
20
21
|
root: string
|
|
@@ -83,6 +84,17 @@ function git_identity_refusal(steps: ReadonlyArray<StartStep>, root: string): st
|
|
|
83
84
|
: undefined
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
// A repository with a history receives the setup as a commit, and the user's own uncommitted change to
|
|
88
|
+
// a file kit also writes would land in it, so the tree must be clean before the first write (#3835).
|
|
89
|
+
// The setup branch a failed commit or push left checked out is the exception: what is uncommitted
|
|
90
|
+
// there is that run's own output, which the re-run resumes with. Without a commit there is nothing to
|
|
91
|
+
// keep apart — the initial commit takes every file.
|
|
92
|
+
function uncommitted_refusal(root: string, state: GitState): string | undefined {
|
|
93
|
+
if (!state.has_commits || start_setup_pr.is_setup_branch(state.branch)) return undefined
|
|
94
|
+
|
|
95
|
+
return working_tree.dirty_tree_refusal(root, 'josh start')
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
// `josh init` refuses to run inside the kit source repository by printing and setting the exit code
|
|
87
99
|
// rather than throwing, so its step would pass and the later steps would commit and publish the
|
|
88
100
|
// source repository. The same check is raised before the first write instead.
|
|
@@ -141,7 +153,9 @@ async function open_setup_pull_request(context: RunContext): Promise<void> {
|
|
|
141
153
|
// What was already changed before the setup ran, read whenever a setup pull request will follow, so
|
|
142
154
|
// it can tell the setup's files from the user's own (#2872). Kit's own `josh init` needs it as much as
|
|
143
155
|
// a caller's command: its `josh format` rewrites the project's own sources, which kit cannot name, and
|
|
144
|
-
// leaving them out failed the pull request's Prettier check.
|
|
156
|
+
// leaving them out failed the pull request's Prettier check. On `main` it is empty, since a dirty tree
|
|
157
|
+
// is refused there (#3835); it is kept for the setup branch a failed commit left, where the earlier
|
|
158
|
+
// run's output is already uncommitted when the re-run starts.
|
|
145
159
|
function baseline_of(
|
|
146
160
|
steps: ReadonlyArray<StartStep>,
|
|
147
161
|
context: StepContext,
|
|
@@ -239,6 +253,7 @@ const start_steps = {
|
|
|
239
253
|
github_cli_refusal,
|
|
240
254
|
git_identity_refusal,
|
|
241
255
|
self_run_refusal,
|
|
256
|
+
uncommitted_refusal,
|
|
242
257
|
run_steps,
|
|
243
258
|
}
|
|
244
259
|
export { start_steps }
|
package/scripts/init/start.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { error_text } from '#scripts/lib/error-message'
|
|
4
|
-
import {
|
|
4
|
+
import { init_dry_run } from './init-dry-run'
|
|
5
|
+
import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
|
|
6
|
+
import { start_dry_run } from './start-dry-run'
|
|
5
7
|
import {
|
|
6
8
|
start_plan,
|
|
7
9
|
type Consent,
|
|
@@ -15,13 +17,14 @@ import { start_steps } from './start-steps'
|
|
|
15
17
|
const ARGUMENT_START_INDEX = 2
|
|
16
18
|
const READY_MESSAGE = 'GitHub workflow ready. Use kickoff new in your assistant to plan an Issue.'
|
|
17
19
|
const START_USAGE =
|
|
18
|
-
'josh start [--profile basic|full] [--yes] [--github] [--public] [--init-command <command>] [--push-preflight-command <command>]'
|
|
20
|
+
'josh start [--profile basic|full] [--yes] [--github] [--public] [--dry-run] [--init-command <command>] [--push-preflight-command <command>]'
|
|
19
21
|
const INIT_COMMAND_FLAG = '--init-command'
|
|
20
22
|
const PUSH_PREFLIGHT_COMMAND_FLAG = '--push-preflight-command'
|
|
21
23
|
const YES_FLAG = '--yes'
|
|
22
24
|
const GITHUB_FLAG = '--github'
|
|
23
25
|
const PUBLIC_FLAG = '--public'
|
|
24
|
-
const
|
|
26
|
+
const { DRY_RUN_FLAG } = init_dry_run
|
|
27
|
+
const SWITCHES: ReadonlySet<string> = new Set([YES_FLAG, GITHUB_FLAG, PUBLIC_FLAG, DRY_RUN_FLAG])
|
|
25
28
|
|
|
26
29
|
interface Prepared {
|
|
27
30
|
steps: ReadonlyArray<StartStep>
|
|
@@ -67,6 +70,7 @@ function parse_start_options(args: ReadonlyArray<string>): StartOptions {
|
|
|
67
70
|
profile: project_profile.requested_profile(profile_args, START_USAGE),
|
|
68
71
|
is_yes: rest.includes(YES_FLAG),
|
|
69
72
|
is_github: rest.includes(GITHUB_FLAG),
|
|
73
|
+
is_dry_run: rest.includes(DRY_RUN_FLAG),
|
|
70
74
|
visibility: rest.includes(PUBLIC_FLAG) ? 'public' : 'private',
|
|
71
75
|
init_command: init.command,
|
|
72
76
|
push_preflight_command,
|
|
@@ -94,34 +98,34 @@ async function resolve_profile(
|
|
|
94
98
|
return await start_prompt.confirm_choices(choices)
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
function planned_steps(root: string, shape: ProjectShape): ReadonlyArray<StartStep> {
|
|
102
|
+
const state = start_steps.read_git_state(root, shape)
|
|
103
|
+
const plan = start_plan.plan_steps(state)
|
|
104
|
+
|
|
105
|
+
stop_on(plan.refusal)
|
|
106
|
+
stop_on(start_steps.uncommitted_refusal(root, state))
|
|
107
|
+
|
|
108
|
+
return plan.steps
|
|
109
|
+
}
|
|
110
|
+
|
|
97
111
|
// Every refusal is raised here, before the first write, so a run that stops here changed nothing.
|
|
98
112
|
function prepare(args: ReadonlyArray<string>, is_tty: boolean, root: string): Prepared {
|
|
99
113
|
stop_on(start_steps.self_run_refusal(root))
|
|
100
114
|
const options = parse_start_options(args)
|
|
101
115
|
const interaction = settle(start_plan.interaction_of(options, is_tty))
|
|
102
116
|
const shape = project_profile.inspect_project(root, options.profile)
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
stop_on(plan.refusal)
|
|
106
|
-
const consent: Consent = settle(start_plan.github_consent(plan.steps, options, interaction))
|
|
117
|
+
const steps = planned_steps(root, shape)
|
|
118
|
+
const consent: Consent = settle(start_plan.github_consent(steps, options, interaction))
|
|
107
119
|
|
|
108
120
|
stop_on(start_steps.github_cli_refusal(root))
|
|
109
|
-
stop_on(start_steps.git_identity_refusal(
|
|
121
|
+
stop_on(start_steps.git_identity_refusal(steps, root))
|
|
110
122
|
|
|
111
|
-
return {
|
|
112
|
-
steps: plan.steps,
|
|
113
|
-
interaction,
|
|
114
|
-
choices: { options, shape, consent, root, steps: plan.steps },
|
|
115
|
-
}
|
|
123
|
+
return { steps, interaction, choices: { options, shape, consent, root, steps } }
|
|
116
124
|
}
|
|
117
125
|
|
|
118
|
-
async function
|
|
119
|
-
const { steps, interaction, choices } = prepare(args, is_tty, process.cwd())
|
|
120
|
-
|
|
126
|
+
async function run_steps({ steps, interaction, choices }: Prepared): Promise<void> {
|
|
121
127
|
const { root, options } = choices
|
|
122
128
|
const { visibility, init_command, push_preflight_command } = options
|
|
123
|
-
|
|
124
|
-
console.info(start_plan.plan_summary(steps, start_plan.step_labels(init_command)))
|
|
125
129
|
const profile = await resolve_profile(choices, interaction)
|
|
126
130
|
const context = { root, profile, visibility, init_command, push_preflight_command }
|
|
127
131
|
|
|
@@ -129,6 +133,22 @@ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void>
|
|
|
129
133
|
console.info(`\n${READY_MESSAGE}`)
|
|
130
134
|
}
|
|
131
135
|
|
|
136
|
+
// A dry run stops after the plan, adding what each step would create, before the first write.
|
|
137
|
+
async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
|
|
138
|
+
const prepared = prepare(args, is_tty, process.cwd())
|
|
139
|
+
const { steps, choices } = prepared
|
|
140
|
+
|
|
141
|
+
console.info(start_plan.plan_summary(steps, start_plan.step_labels(choices.options.init_command)))
|
|
142
|
+
|
|
143
|
+
if (choices.options.is_dry_run) {
|
|
144
|
+
console.info(start_dry_run.report(choices))
|
|
145
|
+
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await run_steps(prepared)
|
|
150
|
+
}
|
|
151
|
+
|
|
132
152
|
async function run(args: ReadonlyArray<string>, is_tty: boolean): Promise<number> {
|
|
133
153
|
try {
|
|
134
154
|
await main(args, is_tty)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { git_location_environment } from '#scripts/git/git-location-environment'
|
|
2
|
+
import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
|
|
3
|
+
import { start_exec } from './start-exec'
|
|
4
|
+
|
|
5
|
+
const UNTRACKED_ARGUMENTS = ['ls-files', '--others', '--exclude-standard']
|
|
6
|
+
// `--relative` keeps the listing to the project and its paths relative to it, the way `ls-files`
|
|
7
|
+
// already reads them, for a project that sits below its repository's top level.
|
|
8
|
+
const MODIFIED_ARGUMENTS = ['diff', '--name-only', '--relative', 'HEAD']
|
|
9
|
+
// Under an unborn HEAD every indexed file is uncommitted, so the index itself is the listing.
|
|
10
|
+
const INDEXED_ARGUMENTS = ['ls-files', '--cached']
|
|
11
|
+
const HISTORY_ARGUMENTS = ['rev-list', '--max-count=1', '--all']
|
|
12
|
+
const UNBORN_HEAD_EXIT_CODE = 1
|
|
13
|
+
const NOT_A_REPOSITORY = 'not a git repository'
|
|
14
|
+
|
|
15
|
+
// What a project's working tree holds that is not committed, read one way for every setup command:
|
|
16
|
+
// the setup pull request tells the setup's files from the user's own with it, and `josh init` refuses
|
|
17
|
+
// to write over it.
|
|
18
|
+
|
|
19
|
+
// Listings are read NUL-separated (`-z`), so a path is never quoted — a name a caller's initialize
|
|
20
|
+
// command wrote need not be ASCII the way kit's own are (#2872).
|
|
21
|
+
function paths_of(output: string | undefined = ''): Array<string> {
|
|
22
|
+
return output.split('\0').filter((listed) => listed.length > 0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function listing(args: ReadonlyArray<string>, root: string): Array<string> {
|
|
26
|
+
return paths_of(start_exec.git_read([...args, '-z'], root))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Untracked files the ignore rules let through, plus tracked files changed since HEAD, staged or not
|
|
30
|
+
// — two plain listings, so nothing has to be parsed out of `git status`.
|
|
31
|
+
function changed_paths(root: string): Array<string> {
|
|
32
|
+
const untracked = listing(UNTRACKED_ARGUMENTS, root)
|
|
33
|
+
const modified = listing(MODIFIED_ARGUMENTS, root)
|
|
34
|
+
|
|
35
|
+
return [...untracked, ...modified]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A read that failed is thrown rather than read as an empty listing: the refusal guards the user's
|
|
39
|
+
// work, so a git that did not answer must never pass for a clean tree.
|
|
40
|
+
function strict_listing(args: ReadonlyArray<string>, root: string): Array<string> {
|
|
41
|
+
const output = start_exec.git_read([...args, '-z'], root)
|
|
42
|
+
|
|
43
|
+
if (output === undefined) throw new Error(`git ${args.join(' ')} failed in ${root}`)
|
|
44
|
+
|
|
45
|
+
return paths_of(output)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type HeadState = 'born' | 'unborn' | 'absent'
|
|
49
|
+
|
|
50
|
+
// Asked of git rather than of a `.git` beside the project, so a project below its repository's top
|
|
51
|
+
// level is still found. `--verify --quiet` exits 1 for an unborn HEAD, and only `not a git repository`
|
|
52
|
+
// (read under the C locale) proves there is no repository; any other failure is an unanswered
|
|
53
|
+
// question, not a missing commit.
|
|
54
|
+
function head_state(root: string): HeadState {
|
|
55
|
+
const { exit_code, stderr } = git_spawn_sync.run(['rev-parse', '--verify', '--quiet', 'HEAD'], {
|
|
56
|
+
cwd: root,
|
|
57
|
+
env: { ...git_spawn_sync.C_LOCALE, ...git_location_environment.location_free_environment() },
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (exit_code === 0) return 'born'
|
|
61
|
+
if (stderr.includes(NOT_A_REPOSITORY)) return 'absent'
|
|
62
|
+
if (exit_code === UNBORN_HEAD_EXIT_CODE) return 'unborn'
|
|
63
|
+
throw new Error(`git rev-parse could not read HEAD in ${root}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// An unborn HEAD is either the repository `josh start` has yet to make its first commit in, or an
|
|
67
|
+
// orphan branch (`git checkout --orphan`) whose index still holds another branch's files — told
|
|
68
|
+
// apart by whether any ref reaches a commit.
|
|
69
|
+
function has_history(root: string): boolean {
|
|
70
|
+
const output = start_exec.git_read(HISTORY_ARGUMENTS, root)
|
|
71
|
+
|
|
72
|
+
if (output === undefined) throw new Error(`git ${HISTORY_ARGUMENTS.join(' ')} failed in ${root}`)
|
|
73
|
+
|
|
74
|
+
return output.trim().length > 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// What the tracked files are compared with, or undefined when there is nothing to tell the user's
|
|
78
|
+
// work from — a directory outside any repository, or a repository without any commit.
|
|
79
|
+
function tracked_arguments(root: string): ReadonlyArray<string> | undefined {
|
|
80
|
+
const state = head_state(root)
|
|
81
|
+
|
|
82
|
+
if (state === 'born') return MODIFIED_ARGUMENTS
|
|
83
|
+
if (state === 'unborn' && has_history(root)) return INDEXED_ARGUMENTS
|
|
84
|
+
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function uncommitted_paths(root: string): Array<string> {
|
|
89
|
+
const tracked = tracked_arguments(root)
|
|
90
|
+
|
|
91
|
+
if (tracked === undefined) return []
|
|
92
|
+
|
|
93
|
+
const untracked = strict_listing(UNTRACKED_ARGUMENTS, root)
|
|
94
|
+
const modified = strict_listing(tracked, root)
|
|
95
|
+
|
|
96
|
+
return [...untracked, ...modified]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The refusal a setup command stops with before its first write, or undefined when nothing is
|
|
100
|
+
// uncommitted: what kit then writes would land in the same files as the user's own work, and
|
|
101
|
+
// `git diff` could no longer tell the two apart.
|
|
102
|
+
function dirty_tree_refusal(root: string, command: string): string | undefined {
|
|
103
|
+
const paths = uncommitted_paths(root)
|
|
104
|
+
|
|
105
|
+
if (paths.length === 0) return undefined
|
|
106
|
+
|
|
107
|
+
return [
|
|
108
|
+
`${command} refuses to run: the working tree has uncommitted changes.`,
|
|
109
|
+
...paths.map((listed) => ` ${listed}`),
|
|
110
|
+
`Commit them, or stash them with \`git stash push -u -- .\`, then run ${command} again.`,
|
|
111
|
+
].join('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const working_tree = { listing, changed_paths, uncommitted_paths, dirty_tree_refusal }
|
|
115
|
+
|
|
116
|
+
export { working_tree }
|
|
@@ -1,34 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// `
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
import {
|
|
2
|
+
BREAKING_CHANGE_LABEL,
|
|
3
|
+
DEFERRED_LABEL,
|
|
4
|
+
EPIC_LABEL,
|
|
5
|
+
has_label_name,
|
|
6
|
+
NEEDS_HUMAN_REVIEW_LABEL,
|
|
7
|
+
RELEASE_LABEL,
|
|
8
|
+
} from './issue-labels'
|
|
9
|
+
import { markdown_section } from './markdown-section'
|
|
10
|
+
|
|
11
|
+
// Whether `josh issue:file` applies `auto-ok` to the Issue it files, judged against the criteria
|
|
12
|
+
// `docs/how-to/run-backlog.md` records rather than the context the filing happens in, so a filing a
|
|
13
|
+
// person asked for (`kickoff new`) is judged the same as one an agent makes on its own. The answer is
|
|
14
|
+
// a pure reading of the filing — its labels, its body and its target — so `backlogrun`'s judgement
|
|
15
|
+
// of an Issue already open reuses `exclusion_of` rather than a second list (joshuafolkken/kit#3854).
|
|
16
|
+
// The order: `--no-auto-ok`, a filing to another repository (whose own `backlogrun` would implement
|
|
17
|
+
// and merge it though nobody opted that repository in), a label that keeps a person in the loop, and
|
|
18
|
+
// last the body's judgement declaration (`JUDGEMENT_NEEDED` / `JUDGEMENT_NOT_NEEDED`): whether a
|
|
19
|
+
// policy decision, an external account or a human visual check is needed.
|
|
20
|
+
|
|
21
|
+
interface AutoOkFiling {
|
|
22
|
+
// `--no-auto-ok`: the filer declared the Issue needs a person's judgement.
|
|
23
23
|
is_opted_out: boolean
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
is_carried: boolean
|
|
29
|
-
// The labels of the Issue the current branch names; `undefined` when the branch names none or the
|
|
30
|
-
// Issue could not be read.
|
|
31
|
-
branch_labels: ReadonlyArray<string> | undefined
|
|
24
|
+
is_cross_repository: boolean
|
|
25
|
+
// Every label the filing carries, including the ones the body's declarations add.
|
|
26
|
+
labels: ReadonlyArray<string>
|
|
27
|
+
body: string
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
interface AutoOkDecision {
|
|
@@ -36,98 +32,71 @@ interface AutoOkDecision {
|
|
|
36
32
|
reason: string
|
|
37
33
|
}
|
|
38
34
|
|
|
35
|
+
const BACKGROUND_HEADING = '## 背景'
|
|
36
|
+
const JUDGEMENT_NOT_NEEDED = '- 人の判断: 不要'
|
|
37
|
+
const JUDGEMENT_NEEDED = '- 人の判断: 要'
|
|
38
|
+
const DECLARATION_PROBLEM = `declare under \`${BACKGROUND_HEADING}\` whether a person's judgement (a policy decision, an external account, a human visual check) is needed: \`${JUDGEMENT_NOT_NEEDED}\` or \`${JUDGEMENT_NEEDED}(<which one>)\` — exactly one (\`issue-template.md\`)`
|
|
39
|
+
|
|
39
40
|
const OPTED_OUT: AutoOkDecision = { is_applied: false, reason: '--no-auto-ok declared' }
|
|
40
41
|
const CROSS_REPOSITORY: AutoOkDecision = {
|
|
41
42
|
is_applied: false,
|
|
42
43
|
reason: 'the Issue is filed to another repository',
|
|
43
44
|
}
|
|
44
|
-
const
|
|
45
|
-
is_applied: false,
|
|
46
|
-
reason: `a ${RELEASE_LABEL} Issue is opted in by a person only`,
|
|
47
|
-
}
|
|
48
|
-
const REQUESTED: AutoOkDecision = {
|
|
45
|
+
const JUDGEMENT: AutoOkDecision = {
|
|
49
46
|
is_applied: false,
|
|
50
|
-
reason:
|
|
47
|
+
reason: `the body declares \`${JUDGEMENT_NEEDED}\``,
|
|
51
48
|
}
|
|
52
|
-
const
|
|
53
|
-
const BRANCH_OPTED_IN: AutoOkDecision = {
|
|
49
|
+
const APPLIED: AutoOkDecision = {
|
|
54
50
|
is_applied: true,
|
|
55
|
-
reason: 'the
|
|
56
|
-
}
|
|
57
|
-
const NOT_OPTED_IN: AutoOkDecision = {
|
|
58
|
-
is_applied: false,
|
|
59
|
-
reason: 'no backlogrun carry record is live and the branch names no auto-ok Issue',
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// What the filing itself declares: `--no-auto-ok` and `--requested`.
|
|
63
|
-
type AutoOkDeclared = Pick<AutoOkSignals, 'is_opted_out' | 'is_requested'>
|
|
64
|
-
type Withholding = AutoOkDeclared & Pick<AutoOkSignals, 'is_release'>
|
|
65
|
-
|
|
66
|
-
// The three signals that withhold `auto-ok` before any other is read, or `undefined` when none holds.
|
|
67
|
-
function withheld(signals: Withholding): AutoOkDecision | undefined {
|
|
68
|
-
if (signals.is_opted_out) return OPTED_OUT
|
|
69
|
-
if (signals.is_release) return RELEASE
|
|
70
|
-
if (signals.is_requested) return REQUESTED
|
|
71
|
-
|
|
72
|
-
return undefined
|
|
51
|
+
reason: 'no exclusion holds and the body declares no judgement is needed',
|
|
73
52
|
}
|
|
74
53
|
|
|
75
|
-
// The
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
54
|
+
// The labels that keep a person in the loop, each with the reason it withholds `auto-ok`. A
|
|
55
|
+
// `breaking-change` declared in the body arrives here as its label, added before the decision.
|
|
56
|
+
const EXCLUDED_LABELS: ReadonlyArray<readonly [string, string]> = [
|
|
57
|
+
[RELEASE_LABEL, 'a release is Tier C'],
|
|
58
|
+
[BREAKING_CHANGE_LABEL, 'a breaking change needs a person to accept the migration'],
|
|
59
|
+
[DEFERRED_LABEL, 'the Issue is deferred'],
|
|
60
|
+
[NEEDS_HUMAN_REVIEW_LABEL, 'a person reviews the change before it lands'],
|
|
61
|
+
[EPIC_LABEL, 'an epic is opted in by a person, and that opts in every descendant'],
|
|
62
|
+
]
|
|
79
63
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
64
|
+
// The first label that withholds `auto-ok`, or `undefined` when none of them is present.
|
|
65
|
+
function exclusion_of(labels: ReadonlyArray<string>): AutoOkDecision | undefined {
|
|
66
|
+
const found = EXCLUDED_LABELS.find(([label]) => has_label_name(labels, label))
|
|
83
67
|
|
|
84
|
-
return
|
|
85
|
-
}
|
|
68
|
+
if (found === undefined) return undefined
|
|
86
69
|
|
|
87
|
-
|
|
88
|
-
return `auto-ok: ${decision.is_applied ? 'applied' : 'not applied'} — ${decision.reason}`
|
|
70
|
+
return { is_applied: false, reason: `a \`${found[0]}\` Issue — ${found[1]}` }
|
|
89
71
|
}
|
|
90
72
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
const
|
|
73
|
+
// `true` / `false` for a declared need, `undefined` when the background declares neither or both.
|
|
74
|
+
function judgement_of(body: string): boolean | undefined {
|
|
75
|
+
const background = markdown_section.section_lines(body, BACKGROUND_HEADING).join('\n')
|
|
76
|
+
const lines = markdown_section.unfenced_lines(background).map((line) => line.trim())
|
|
77
|
+
const is_not_needed = lines.some((line) => line.startsWith(JUDGEMENT_NOT_NEEDED))
|
|
78
|
+
const is_needed = lines.some((line) => line.startsWith(JUDGEMENT_NEEDED))
|
|
94
79
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return run_carry.read_carry(run_carry.carry_path(directory)).kind === 'carried'
|
|
80
|
+
return is_not_needed === is_needed ? undefined : is_needed
|
|
98
81
|
}
|
|
99
82
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const issue_number = git_branch.issue_from_branch(await git_command.branch())
|
|
83
|
+
// Why the filing is refused for its declaration, or `undefined` when it declares exactly one answer.
|
|
84
|
+
function declaration_problem(body: string): string | undefined {
|
|
85
|
+
return judgement_of(body) === undefined ? DECLARATION_PROBLEM : undefined
|
|
86
|
+
}
|
|
105
87
|
|
|
106
|
-
|
|
107
|
-
|
|
88
|
+
function decide(filing: AutoOkFiling): AutoOkDecision {
|
|
89
|
+
if (filing.is_opted_out) return OPTED_OUT
|
|
90
|
+
if (filing.is_cross_repository) return CROSS_REPOSITORY
|
|
108
91
|
|
|
109
|
-
return
|
|
92
|
+
return exclusion_of(filing.labels) ?? (judgement_of(filing.body) === false ? APPLIED : JUDGEMENT)
|
|
110
93
|
}
|
|
111
94
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
async function resolve(
|
|
115
|
-
declared: AutoOkDeclared,
|
|
116
|
-
target: string,
|
|
117
|
-
current: string,
|
|
118
|
-
labels: ReadonlyArray<string> = [],
|
|
119
|
-
): Promise<AutoOkDecision> {
|
|
120
|
-
const withholding = { ...declared, is_release: has_label_name(labels, RELEASE_LABEL) }
|
|
121
|
-
const decision = withheld(withholding)
|
|
122
|
-
|
|
123
|
-
if (decision !== undefined) return decision
|
|
124
|
-
if (!issue_file.is_same_repository(target, current)) return CROSS_REPOSITORY
|
|
125
|
-
if (await is_carried()) return CARRIED
|
|
126
|
-
|
|
127
|
-
return decide({ ...withholding, is_carried: false, branch_labels: await branch_labels(current) })
|
|
95
|
+
function line_of(decision: AutoOkDecision): string {
|
|
96
|
+
return `auto-ok: ${decision.is_applied ? 'applied' : 'not applied'} — ${decision.reason}`
|
|
128
97
|
}
|
|
129
98
|
|
|
130
|
-
const issue_auto_ok = { decide,
|
|
99
|
+
const issue_auto_ok = { declaration_problem, decide, exclusion_of, line_of }
|
|
131
100
|
|
|
132
|
-
export type {
|
|
101
|
+
export type { AutoOkDecision, AutoOkFiling }
|
|
133
102
|
export { issue_auto_ok }
|
|
@@ -24,7 +24,7 @@ import { issue_scout_cli } from './issue-scout-cli'
|
|
|
24
24
|
import { issue_wip } from './issue-wip'
|
|
25
25
|
|
|
26
26
|
// `josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>]…
|
|
27
|
-
// [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--
|
|
27
|
+
// [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--release]` — file an Issue
|
|
28
28
|
// with every filing step run in order: the third-party refusal, the body
|
|
29
29
|
// lint, the `## Origin` check for another repository, the `auto-ok` decision
|
|
30
30
|
// with the run label it owes, the fold question against the run's earlier
|
|
@@ -37,7 +37,7 @@ const SUCCESS_EXIT_CODE = 0
|
|
|
37
37
|
const FAILURE_EXIT_CODE = 1
|
|
38
38
|
const ARGV_OFFSET = 2
|
|
39
39
|
const USAGE =
|
|
40
|
-
'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] [--
|
|
40
|
+
'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]'
|
|
41
41
|
const UNKNOWN_REPO_MESSAGE =
|
|
42
42
|
'Could not read this repository from `git remote`, so the filing has no repository to compare against — check `gh auth status`.'
|
|
43
43
|
const THIRD_PARTY_MESSAGE =
|
|
@@ -74,10 +74,13 @@ function is_third_party(target: string): boolean {
|
|
|
74
74
|
// the filing may go on to the scout.
|
|
75
75
|
function refusals_of(filing: Filing): ReadonlyArray<string> {
|
|
76
76
|
if (is_third_party(filing.target)) return [THIRD_PARTY_MESSAGE]
|
|
77
|
-
const problems =
|
|
78
|
-
|
|
77
|
+
const problems = [
|
|
78
|
+
...issue_lint_cli.problems(filing.body),
|
|
79
|
+
issue_auto_ok.declaration_problem(filing.body),
|
|
80
|
+
issue_file.origin_problem(filing.body, filing.target, filing.current),
|
|
81
|
+
]
|
|
79
82
|
|
|
80
|
-
return
|
|
83
|
+
return problems.filter((problem) => problem !== undefined).map((problem) => `✖ ${problem}`)
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
function unacknowledged_message(numbers: ReadonlyArray<number>): string {
|
|
@@ -143,18 +146,15 @@ async function is_scout_clear(filing: Filing): Promise<boolean> {
|
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
// Every label the create call carries, or `undefined` when an `auto-ok` filing names neither run label.
|
|
146
|
-
// Decided before the WIP count and the scout, so a refusal costs no listing.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
// Decided before the WIP count and the scout, so a refusal costs no listing. The decision reads the
|
|
150
|
+
// labels the body's declarations add, so a declared breaking change is excluded like its label.
|
|
151
|
+
function labels_of(filing: Filing): ReadonlyArray<string> | undefined {
|
|
152
|
+
const auto_ok = issue_auto_ok.decide({
|
|
149
153
|
is_opted_out: filing.args.is_auto_ok_opted_out,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
filing.target,
|
|
155
|
-
filing.current,
|
|
156
|
-
filing.args.labels,
|
|
157
|
-
)
|
|
154
|
+
is_cross_repository: !issue_file.is_same_repository(filing.target, filing.current),
|
|
155
|
+
labels: issue_file.labels_of(filing.args, filing.body),
|
|
156
|
+
body: filing.body,
|
|
157
|
+
})
|
|
158
158
|
|
|
159
159
|
console.info(issue_auto_ok.line_of(auto_ok))
|
|
160
160
|
const labels = issue_file.labels_of(filing.args, filing.body, auto_ok.is_applied)
|
|
@@ -271,7 +271,7 @@ async function is_clear(filing: Filing): Promise<boolean> {
|
|
|
271
271
|
|
|
272
272
|
async function file(filing: Filing): Promise<number> {
|
|
273
273
|
if (!is_admitted(filing)) return FAILURE_EXIT_CODE
|
|
274
|
-
const labels =
|
|
274
|
+
const labels = labels_of(filing)
|
|
275
275
|
|
|
276
276
|
if (labels === undefined || !(await is_clear(filing))) return FAILURE_EXIT_CODE
|
|
277
277
|
|
|
@@ -41,9 +41,6 @@ interface FileArguments {
|
|
|
41
41
|
is_over_cap: boolean
|
|
42
42
|
// `--no-auto-ok`: the Issue needs a person's judgement, so `auto-ok` is not applied (`issue-auto-ok.ts`).
|
|
43
43
|
is_auto_ok_opted_out: boolean
|
|
44
|
-
// `--requested`: a person asked for this filing, so `auto-ok` is applied only when a `--label` names
|
|
45
|
-
// it (`issue-auto-ok.ts`).
|
|
46
|
-
is_requested: boolean
|
|
47
44
|
// `--release`: the change reaches a consumer only once published, so the Issue is linked to the
|
|
48
45
|
// repository's release Issue as a blocker (`issue-release-cli.ts`).
|
|
49
46
|
is_release: boolean
|
|
@@ -58,7 +55,6 @@ const OPTIONS = {
|
|
|
58
55
|
distinct: { type: 'string', multiple: true },
|
|
59
56
|
'over-cap': { type: 'boolean' },
|
|
60
57
|
'no-auto-ok': { type: 'boolean' },
|
|
61
|
-
requested: { type: 'boolean' },
|
|
62
58
|
release: { type: 'boolean' },
|
|
63
59
|
} as const
|
|
64
60
|
|
|
@@ -129,7 +125,6 @@ function arguments_of(values: ParsedValues, title: string | undefined): FileArgu
|
|
|
129
125
|
repo: values.repo,
|
|
130
126
|
is_over_cap: values['over-cap'] === true,
|
|
131
127
|
is_auto_ok_opted_out: values['no-auto-ok'] === true,
|
|
132
|
-
is_requested: values.requested === true,
|
|
133
128
|
is_release: values.release === true,
|
|
134
129
|
}
|
|
135
130
|
}
|
|
@@ -151,7 +146,7 @@ function is_undeclared_classification(label: string, body: string): boolean {
|
|
|
151
146
|
|
|
152
147
|
// Every label the filing carries: the depth, the route when there is one, the classification labels
|
|
153
148
|
// the body declares (`issue_classification.labels_for`, which merges case-insensitively), then any
|
|
154
|
-
// extra `--label`, then `auto-ok` when the filing
|
|
149
|
+
// extra `--label`, then `auto-ok` when the filing is judged in (`issue-auto-ok.ts`) and no `--label`
|
|
155
150
|
// already named it. One request applies them all, so no label is a separate step to forget.
|
|
156
151
|
function labels_of(args: FileArguments, body: string, is_auto_ok = false): ReadonlyArray<string> {
|
|
157
152
|
const extra = args.labels.filter((label) => !is_undeclared_classification(label, body))
|
|
@@ -67,9 +67,12 @@ const ALREADY_DONE_LABEL = 'already-done'
|
|
|
67
67
|
// Marks the Issue that tracks a repository's next release. An Issue whose
|
|
68
68
|
// change reaches a consumer only once published is linked to it as a `blocked_by` blocker
|
|
69
69
|
// (`josh issue:release`), so "merged but not yet released" is visible in the backlog. A run applies
|
|
70
|
-
// it; **`auto-ok` never rides on it** — `issue-auto-ok.ts`
|
|
71
|
-
//
|
|
70
|
+
// it; **`auto-ok` never rides on it** — `issue-auto-ok.ts` excludes it, since a release is Tier C —
|
|
71
|
+
// and only a person opts it into unattended execution.
|
|
72
72
|
const RELEASE_LABEL = 'release'
|
|
73
|
+
// A person's mark for work set aside for now; `issue-auto-ok.ts` reads it as an exclusion. Applied by
|
|
74
|
+
// hand and never provisioned, so it is not in `REPOSITORY_LABELS`.
|
|
75
|
+
const DEFERRED_LABEL = 'deferred'
|
|
73
76
|
// Applied to an Issue whose second review round was skipped, so the condition stays auditable
|
|
74
77
|
// (`prompts/review.md` → "When round 2 is skipped entirely, and when it is not").
|
|
75
78
|
const REVIEW_ROUND2_SKIPPED_LABEL = 'review-round2-skipped'
|
|
@@ -402,6 +405,7 @@ export {
|
|
|
402
405
|
BUG_LABEL,
|
|
403
406
|
BUGFIX_LABEL,
|
|
404
407
|
BREAKING_CHANGE_LABEL,
|
|
408
|
+
DEFERRED_LABEL,
|
|
405
409
|
depth_label_of,
|
|
406
410
|
DEPTH_0_LABEL,
|
|
407
411
|
DEPTH_1_LABEL,
|