@joshuafolkken/kit 1.887.0 → 1.929.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-progress.md +13 -12
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -1
- package/.claude/skills/workflow-commands/fullrun-steps.md +4 -2
- package/.claude/skills/workflow-commands/halfrun.md +3 -1
- package/.claude/skills/workflow-commands/kickoff.md +5 -3
- package/.claude/skills/workflow-commands/observation-filing.md +3 -1
- package/.claude/skills/workflow-commands/prerequisite.md +3 -1
- package/.claude/skills/workflow-commands/target-repository.md +1 -1
- package/.github/pull_request_template.md +12 -0
- package/.github/release.yml +3 -0
- package/.github/workflows/ci.yml +8 -0
- package/.github/workflows/production.yml +10 -1
- package/.github/workflows/publish.yml +72 -2
- package/CLAUDE.md +2 -0
- package/README.md +2 -1
- package/dist/CLAUDE.md +2 -0
- package/dist/CLAUDE.static.md +8 -0
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-264QY6KS.js → chunk-2PGRQ62K.js} +25 -10
- package/dist/hooks/chunk-3V37IID6.js +82 -0
- package/dist/hooks/{chunk-36UE53LS.js → chunk-7XQ6TK3R.js} +2 -2
- package/dist/hooks/{chunk-PRDCNGA6.js → chunk-BNFG2IFK.js} +4 -4
- package/dist/hooks/{chunk-VGYSA2NT.js → chunk-GQ3EVWHY.js} +22 -3
- package/dist/hooks/{chunk-Y5OYCU2F.js → chunk-I3JP3D4A.js} +3 -3
- package/dist/hooks/{chunk-6MQCXEEA.js → chunk-LCJZE42I.js} +25 -88
- package/dist/hooks/{chunk-ZBK4XRAS.js → chunk-NJZSO7BK.js} +3 -3
- package/dist/hooks/{chunk-SSX23SJ2.js → chunk-RKUAD3I6.js} +1 -0
- package/dist/hooks/{chunk-FRJKVSK3.js → chunk-ROULPSQC.js} +2 -2
- package/dist/hooks/{chunk-FNFOLFFV.js → chunk-SXPTRFPG.js} +2 -2
- package/dist/hooks/{chunk-FXAKJKTB.js → chunk-WPALEIRX.js} +1 -1
- package/dist/hooks/{chunk-4OXFU3MK.js → chunk-YO43HEPY.js} +634 -156
- package/dist/hooks/codex-hook-adapter.js +13 -12
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +4 -3
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -11
- package/dist/hooks/session-lang.js +2 -2
- package/dist/hooks/stop-guard.js +5 -5
- package/dist/josh.js +156 -20
- package/eslint/base.js +4 -1
- package/package.json +72 -18
- package/playwright.config.ts +9 -17
- package/ports/index.d.ts +1 -0
- package/ports/index.js +1 -0
- package/ports/web-server.d.ts +5 -0
- package/ports/web-server.js +73 -0
- package/prettier/index.js +3 -8
- package/prettier/static.d.ts +3 -0
- package/prettier/static.js +10 -0
- package/prompts/collaboration-workflow/issue-template.md +11 -4
- package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
- package/prompts/collaboration-workflow/wip-cap.md +2 -2
- package/prompts/review.md +2 -2
- package/prompts/testing-guide.md +1 -0
- package/scripts/agent/agent-role-profile.ts +11 -0
- package/scripts/build/build-claude-md.ts +6 -1
- package/scripts/ci/pr-classification.ts +114 -0
- package/scripts/document/document-byte-budget.ts +3 -2
- package/scripts/gate/gate-report.ts +2 -0
- package/scripts/gate/gate-steps.ts +50 -0
- package/scripts/gate/project-checks.ts +145 -0
- package/scripts/gate/verification-gate.ts +14 -43
- package/scripts/git/git-common-directory.ts +9 -1
- package/scripts/git/git-gh-pr-rollup.ts +29 -1
- package/scripts/git/git-gh-pr.ts +61 -1
- package/scripts/git/git-pr.ts +62 -13
- package/scripts/git/issue-labels.ts +9 -0
- package/scripts/init/distributed-paths.ts +9 -5
- package/scripts/init/hook-command-bootstrap.ts +40 -8
- package/scripts/init/hook-command-rewrite.ts +7 -3
- package/scripts/init/init-actions.ts +94 -7
- package/scripts/init/init-ai-copy.ts +107 -19
- package/scripts/init/init-logic-workspace.ts +38 -5
- package/scripts/init/init-logic.ts +1 -0
- package/scripts/init/init-static.ts +55 -0
- package/scripts/init/init.ts +97 -30
- package/scripts/init/project-profile-cli.ts +14 -0
- package/scripts/init/project-profile.ts +158 -0
- package/scripts/init/start.ts +30 -0
- package/scripts/issue/issue-bug-label.ts +13 -0
- package/scripts/issue/issue-classification.ts +74 -0
- package/scripts/issue/issue-lint-cli.ts +10 -3
- package/scripts/issue/markdown-section.ts +38 -4
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +27 -1
- package/scripts/josh/josh-command-map.ts +3 -0
- package/scripts/josh/josh-commands-development.ts +8 -15
- package/scripts/josh/josh-commands-project.ts +14 -1
- package/scripts/josh/josh-logic.ts +13 -0
- package/scripts/josh/run-github-prerequisite.ts +32 -0
- package/scripts/lane/lane-await.ts +8 -1
- package/scripts/lane/openai-lane-ship.ts +30 -0
- package/scripts/lane/openai-lane-supervisor.ts +35 -8
- package/scripts/lane/openai-review-broker.ts +260 -0
- package/scripts/lint/cspell-cached.ts +21 -0
- package/scripts/lint/format.ts +68 -0
- package/scripts/lint/lint-parallel.ts +30 -1
- package/scripts/observations/observations-flush.ts +1 -1
- package/scripts/package/production-dependency-graph.ts +68 -0
- package/scripts/package/verify-optional-eslint-install.ts +62 -0
- package/scripts/release/github-release-cli.ts +11 -0
- package/scripts/release/github-release.ts +220 -0
- package/scripts/release/publish-tag-cli.ts +8 -0
- package/scripts/release/publish-tag.ts +11 -0
- package/scripts/release/release-publish.ts +7 -2
- package/scripts/rules/delivered-rules.ts +2 -0
- package/scripts/rules/issue-bug-label.ts +85 -0
- package/scripts/rules/issue-filing-args.ts +50 -0
- package/scripts/run/run-merge-cli.ts +19 -0
- package/scripts/run/run-merge.ts +9 -1
- package/scripts/run/run-ship-cli.ts +20 -0
- package/scripts/run/run-ship-detach.ts +180 -14
- package/scripts/run/run-ship-review-steps.ts +45 -25
- package/scripts/run/run-step-cli.ts +11 -1
- package/scripts/run/run-step.ts +36 -8
- package/scripts/run/run-wake-session.ts +3 -1
- package/scripts/test/pilot-files.ts +0 -2
- package/scripts/test/skip-marker.ts +3 -0
- package/scripts/test/test-declared-changed.ts +2 -1
- package/scripts/test/test-declared-logic.ts +15 -10
- package/scripts/test/test-declared.ts +20 -8
- package/scripts/test/test-red-logic.ts +3 -3
- package/scripts/test/test-unit-guard.ts +24 -12
- package/templates/CLAUDE.static.md +8 -0
- package/templates/cursorrules.static +1 -0
- package/templates/pnpm-workspace.static.yaml +4 -0
- package/templates/prettierignore.static +12 -0
- package/templates/workflows/ci.yml +5 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, globSync, readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { find_local_bin_upwards } from '#scripts/build/local-bin'
|
|
4
|
+
import { SKIP_MARKER } from '#scripts/test/skip-marker'
|
|
5
|
+
|
|
6
|
+
const PACKAGE_JSON = 'package.json'
|
|
7
|
+
const IGNORED_DIRS = new Set([
|
|
8
|
+
'node_modules',
|
|
9
|
+
'.git',
|
|
10
|
+
'.venv',
|
|
11
|
+
'venv',
|
|
12
|
+
'target',
|
|
13
|
+
'dist',
|
|
14
|
+
'build',
|
|
15
|
+
'.svelte-kit',
|
|
16
|
+
])
|
|
17
|
+
const WEB_FILES = '**/*.{html,css,js,jsx,mjs,cjs}'
|
|
18
|
+
const SCRIPT_FILES = '**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'
|
|
19
|
+
const TYPE_FILES = '**/*.{ts,tsx,mts,cts}'
|
|
20
|
+
const ESLINT_CONFIGS = [
|
|
21
|
+
'eslint.config.js',
|
|
22
|
+
'eslint.config.mjs',
|
|
23
|
+
'eslint.config.cjs',
|
|
24
|
+
'eslint.config.ts',
|
|
25
|
+
'eslint.config.mts',
|
|
26
|
+
'eslint.config.cts',
|
|
27
|
+
]
|
|
28
|
+
const CSPELL_CONFIGS = [
|
|
29
|
+
'cspell.config.yaml',
|
|
30
|
+
'cspell.config.yml',
|
|
31
|
+
'cspell.config.json',
|
|
32
|
+
'cspell.config.js',
|
|
33
|
+
'cspell.config.cjs',
|
|
34
|
+
'cspell.config.mjs',
|
|
35
|
+
'cspell.json',
|
|
36
|
+
'cspell.yaml',
|
|
37
|
+
]
|
|
38
|
+
const TYPE_CONFIGS = ['tsconfig.json', 'jsconfig.json']
|
|
39
|
+
|
|
40
|
+
function project_root(directory: string): string {
|
|
41
|
+
let current = directory
|
|
42
|
+
|
|
43
|
+
while (!existsSync(path.join(current, PACKAGE_JSON))) {
|
|
44
|
+
const parent = path.dirname(current)
|
|
45
|
+
if (parent === current) return directory
|
|
46
|
+
current = parent
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return current
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function is_ignored(entry: string): boolean {
|
|
53
|
+
return entry.split(/[/\\]/u).some((part) => IGNORED_DIRS.has(part))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function has_files(directory: string, pattern: string): boolean {
|
|
57
|
+
return globSync(pattern, { cwd: project_root(directory), exclude: is_ignored }).length > 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function has_config(directory: string, names: ReadonlyArray<string>): boolean {
|
|
61
|
+
return names.some((name) => existsSync(path.join(project_root(directory), name)))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function has_bin(directory: string, bin_name: string): boolean {
|
|
65
|
+
return find_local_bin_upwards(directory, bin_name) !== undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function josh_settings(manifest: unknown): unknown {
|
|
69
|
+
if (typeof manifest !== 'object' || manifest === null || !('josh' in manifest)) return undefined
|
|
70
|
+
|
|
71
|
+
return manifest.josh
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function is_static_manifest(manifest: unknown): boolean {
|
|
75
|
+
const josh = josh_settings(manifest)
|
|
76
|
+
if (typeof josh !== 'object' || josh === null || !('profile' in josh)) return false
|
|
77
|
+
|
|
78
|
+
return josh.profile === 'static'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function is_static(directory: string): boolean {
|
|
82
|
+
try {
|
|
83
|
+
const manifest_path = path.join(project_root(directory), PACKAGE_JSON)
|
|
84
|
+
const manifest: unknown = JSON.parse(readFileSync(manifest_path, 'utf8'))
|
|
85
|
+
|
|
86
|
+
return is_static_manifest(manifest)
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function skip_notice(check: string, reason: string): string {
|
|
93
|
+
return `josh ${check}: ${reason} ${SKIP_MARKER} ${check}.`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function type_check_skip_reason(directory: string): string | undefined {
|
|
97
|
+
if (!is_static(directory)) return undefined
|
|
98
|
+
if (!has_files(directory, TYPE_FILES)) return 'no TypeScript files were found'
|
|
99
|
+
if (!has_config(directory, TYPE_CONFIGS)) return 'no TypeScript configuration was found'
|
|
100
|
+
if (!has_bin(directory, 'tsc')) return 'typescript is not installed'
|
|
101
|
+
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Shared by `josh lint` and `josh format`, so a static project without Web files — a Python or Rust
|
|
106
|
+
// project, which `josh init` gives no Prettier — is skipped for the same reason by both
|
|
107
|
+
// (joshuafolkken/kit#2606).
|
|
108
|
+
function prettier_skip_reason(directory: string): string | undefined {
|
|
109
|
+
if (!is_static(directory)) return undefined
|
|
110
|
+
if (!has_files(directory, WEB_FILES)) return 'no HTML, CSS or JavaScript files were found'
|
|
111
|
+
if (!has_bin(directory, 'prettier')) return 'prettier is not installed'
|
|
112
|
+
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Shared by `josh lint` and `josh format`, so a static project without ESLint is skipped for the
|
|
117
|
+
// same reason by both (joshuafolkken/kit#2693).
|
|
118
|
+
function eslint_skip_reason(directory: string): string | undefined {
|
|
119
|
+
if (!is_static(directory)) return undefined
|
|
120
|
+
if (!has_files(directory, SCRIPT_FILES)) return 'no JavaScript or TypeScript files were found'
|
|
121
|
+
if (!has_config(directory, ESLINT_CONFIGS)) return 'no ESLint configuration was found'
|
|
122
|
+
if (!has_bin(directory, 'eslint')) return 'eslint is not installed'
|
|
123
|
+
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const project_checks = {
|
|
128
|
+
CSPELL_CONFIGS,
|
|
129
|
+
ESLINT_CONFIGS,
|
|
130
|
+
SCRIPT_FILES,
|
|
131
|
+
TYPE_CONFIGS,
|
|
132
|
+
TYPE_FILES,
|
|
133
|
+
WEB_FILES,
|
|
134
|
+
eslint_skip_reason,
|
|
135
|
+
has_bin,
|
|
136
|
+
has_config,
|
|
137
|
+
has_files,
|
|
138
|
+
is_static,
|
|
139
|
+
prettier_skip_reason,
|
|
140
|
+
project_root,
|
|
141
|
+
skip_notice,
|
|
142
|
+
type_check_skip_reason,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { project_checks }
|
|
@@ -10,12 +10,13 @@ import { review_stamps } from '#scripts/review/review-stamps'
|
|
|
10
10
|
import { review_tree } from '#scripts/review/review-tree'
|
|
11
11
|
import { unit_worker_share } from '#scripts/test/unit-worker-share'
|
|
12
12
|
import { core_budget } from './core-budget'
|
|
13
|
-
import { gate_plan, type
|
|
13
|
+
import { gate_plan, type GatePlan } from './gate-plan'
|
|
14
14
|
import { gate_report, type GateStep, type GateStepResult } from './gate-report'
|
|
15
15
|
import { gate_skip } from './gate-skip'
|
|
16
|
+
import { build_gate_step, build_gate_steps, UNIT_WORKER_FLAG } from './gate-steps'
|
|
16
17
|
import { gate_tree, type GateTree } from './gate-tree'
|
|
18
|
+
import { project_checks } from './project-checks'
|
|
17
19
|
import { scoped_green, type ScopedSources } from './scoped-green'
|
|
18
|
-
import { type_check_step } from './type-check-step'
|
|
19
20
|
|
|
20
21
|
// joshuafolkken/kit#914: the completion gate's four checks are independent and share no mutable
|
|
21
22
|
// state, yet every entry point ran them one after another — paid again on every `epicrun` child,
|
|
@@ -33,55 +34,25 @@ import { type_check_step } from './type-check-step'
|
|
|
33
34
|
// `process.argv` is [runner, script, ...arguments].
|
|
34
35
|
const FIRST_ARGUMENT_INDEX = 2
|
|
35
36
|
|
|
36
|
-
const
|
|
37
|
-
const { GATE_CHECKS, TYPE_CHECK_LABEL, UNIT_LABEL } = gate_plan
|
|
37
|
+
const { GATE_CHECKS } = gate_plan
|
|
38
38
|
|
|
39
39
|
const GATE_TARGETS: ReadonlyArray<string> = GATE_CHECKS.map((check) => check.target)
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// on (joshuafolkken/kit#1258). `josh test:unit` forwards what it is given straight to
|
|
44
|
-
// `vitest run`, so nothing between here and vitest has to know about it.
|
|
45
|
-
const UNIT_WORKER_FLAG = '--maxWorkers'
|
|
46
|
-
|
|
47
|
-
function unit_worker_args(check: GateCheck, plan: GatePlan): ReadonlyArray<string> {
|
|
48
|
-
if (check.label !== UNIT_LABEL || plan.unit_worker_cap === undefined) return []
|
|
49
|
-
|
|
50
|
-
return [`${UNIT_WORKER_FLAG}=${String(plan.unit_worker_cap)}`]
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Only the type check is resolved per project (joshuafolkken/kit#934) — a SvelteKit project
|
|
54
|
-
// type-checks through its own toolkit, not through `tsc --noEmit`. Resolving inside the step keeps
|
|
55
|
-
// the probe concurrent with the other three checks rather than delaying every one of them.
|
|
56
|
-
async function build_gate_step(
|
|
57
|
-
check: GateCheck,
|
|
58
|
-
start_directory: string,
|
|
59
|
-
plan: GatePlan = gate_plan.resolve_gate_plan(),
|
|
60
|
-
): Promise<GateStep> {
|
|
61
|
-
if (check.label !== TYPE_CHECK_LABEL) {
|
|
41
|
+
async function run_gate_step(step: GateStep): Promise<GateStepResult> {
|
|
42
|
+
if (step.skip_reason !== undefined) {
|
|
62
43
|
return {
|
|
63
|
-
label:
|
|
64
|
-
|
|
44
|
+
label: step.label,
|
|
45
|
+
command: step.command_args.join(' '),
|
|
46
|
+
output: project_checks.skip_notice('check', step.skip_reason),
|
|
47
|
+
exit_code: 0,
|
|
48
|
+
elapsed_ms: 0,
|
|
65
49
|
}
|
|
66
50
|
}
|
|
67
51
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function build_gate_steps(
|
|
75
|
-
start_directory: string,
|
|
76
|
-
plan: GatePlan = gate_plan.resolve_gate_plan(),
|
|
77
|
-
): Promise<ReadonlyArray<GateStep>> {
|
|
78
|
-
return await Promise.all(
|
|
79
|
-
plan.checks.map(async (check) => await build_gate_step(check, start_directory, plan)),
|
|
52
|
+
const result = await buffered_process.run_buffered_process(
|
|
53
|
+
step.command_args,
|
|
54
|
+
step.cwd === undefined ? {} : { cwd: step.cwd },
|
|
80
55
|
)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function run_gate_step(step: GateStep): Promise<GateStepResult> {
|
|
84
|
-
const result = await buffered_process.run_buffered_process(step.command_args)
|
|
85
56
|
|
|
86
57
|
return { label: step.label, command: step.command_args.join(' '), ...result }
|
|
87
58
|
}
|
|
@@ -33,6 +33,14 @@ function resolve(cwd: string): string | undefined {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
function repository(cwd: string): string | undefined {
|
|
37
|
+
try {
|
|
38
|
+
return read_directories(cwd)[COMMON_GIT_DIRECTORY]
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const git_common_directory = { repository, resolve, select_linked }
|
|
37
45
|
|
|
38
46
|
export { git_common_directory }
|
|
@@ -29,6 +29,7 @@ const check_runs_page_schema = z.looseObject({
|
|
|
29
29
|
// answer an empty rollup — which `git-pr-followup.ts` reports as "this branch has no checks".
|
|
30
30
|
check_runs: z.array(rollup_element_schema),
|
|
31
31
|
})
|
|
32
|
+
const check_run_app_schema = z.object({ id: z.number() })
|
|
32
33
|
|
|
33
34
|
const status_page_schema = z.looseObject({
|
|
34
35
|
statuses: z.array(rollup_element_schema),
|
|
@@ -50,10 +51,37 @@ function read_pages<T>(raw_json: string, schema: z.ZodType<T>, message: string):
|
|
|
50
51
|
return pages
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
function check_run_group_key(run: RollupElement): string | undefined {
|
|
55
|
+
const { name, app: raw_app } = run
|
|
56
|
+
if (typeof name !== 'string') return undefined
|
|
57
|
+
const app = check_run_app_schema.safeParse(raw_app)
|
|
58
|
+
|
|
59
|
+
return JSON.stringify([name, app.success ? app.data.id : undefined])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function remember_newest_run(run: RollupElement, latest_ids: Map<string, number>): void {
|
|
63
|
+
const key = check_run_group_key(run)
|
|
64
|
+
const { id } = run
|
|
65
|
+
if (key === undefined || typeof id !== 'number') return
|
|
66
|
+
const previous_id = latest_ids.get(key)
|
|
67
|
+
if (previous_id === undefined || id > previous_id) latest_ids.set(key, id)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function is_newest_run(run: RollupElement, latest_ids: Map<string, number>): boolean {
|
|
71
|
+
const key = check_run_group_key(run)
|
|
72
|
+
const { id } = run
|
|
73
|
+
if (key === undefined || typeof id !== 'number') return true
|
|
74
|
+
|
|
75
|
+
return id === latest_ids.get(key)
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
function to_check_run_items(check_runs_json: string): Array<RollupElement> {
|
|
54
79
|
const pages = read_pages(check_runs_json, check_runs_page_schema, NOT_A_CHECK_RUNS_LISTING)
|
|
80
|
+
const runs = pages.flatMap((page) => page.check_runs)
|
|
81
|
+
const latest_ids = new Map<string, number>()
|
|
82
|
+
for (const run of runs) remember_newest_run(run, latest_ids)
|
|
55
83
|
|
|
56
|
-
return
|
|
84
|
+
return runs.filter((run) => is_newest_run(run, latest_ids))
|
|
57
85
|
}
|
|
58
86
|
|
|
59
87
|
function to_status_context_items(status_json: string): Array<RollupElement> {
|
package/scripts/git/git-gh-pr.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { pr_classification, type ReleaseClassification } from '#scripts/ci/pr-classification'
|
|
1
2
|
import { poll } from '#scripts/lib/poll'
|
|
3
|
+
import { z } from 'zod'
|
|
2
4
|
import { git_command } from './git-command'
|
|
3
5
|
import { git_gh_api_path } from './git-gh-api-path'
|
|
4
6
|
import { git_gh_exec } from './git-gh-exec'
|
|
@@ -46,6 +48,44 @@ const MERGE_UNCONFIRMED_MESSAGE =
|
|
|
46
48
|
'gh api could not read the pull request back after the merge request failed'
|
|
47
49
|
const MERGE_READ_BACK_ATTEMPTS = 3
|
|
48
50
|
const MERGE_READ_BACK_INTERVAL_MS = 1000
|
|
51
|
+
const PR_NUMBER_PATTERN = /\/pull\/([1-9]\d*)$/u
|
|
52
|
+
const LABELS_SEGMENT = '/labels'
|
|
53
|
+
const PR_LABELS = z.array(z.object({ name: z.string() }))
|
|
54
|
+
|
|
55
|
+
async function add_pr_classification(number: string, label: ReleaseClassification): Promise<void> {
|
|
56
|
+
await git_gh_exec.exec_gh_api({
|
|
57
|
+
path: `${git_gh_api_path.issue_api_path(number)}${LABELS_SEGMENT}`,
|
|
58
|
+
body: JSON.stringify({ labels: [label] }),
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function pr_get_classification(
|
|
63
|
+
branch_name: string,
|
|
64
|
+
): Promise<ReleaseClassification | undefined> {
|
|
65
|
+
const number = await require_pr_number(branch_name)
|
|
66
|
+
const response = await git_gh_exec.exec_gh_api({
|
|
67
|
+
path: `${git_gh_api_path.issue_api_path(String(number))}${LABELS_SEGMENT}`,
|
|
68
|
+
})
|
|
69
|
+
const names = PR_LABELS.parse(JSON.parse(response)).map((label) => label.name.toLowerCase())
|
|
70
|
+
const selected = names.filter((name) => pr_classification.is_classification(name))
|
|
71
|
+
if (selected.length === 0) return undefined
|
|
72
|
+
|
|
73
|
+
const error = pr_classification.classification_error(names, 'human')
|
|
74
|
+
if (error !== undefined) throw new Error(error)
|
|
75
|
+
|
|
76
|
+
return selected[0]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function pr_ensure_classification(
|
|
80
|
+
branch_name: string,
|
|
81
|
+
label: ReleaseClassification,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
if ((await pr_get_classification(branch_name)) !== undefined) return
|
|
84
|
+
|
|
85
|
+
const number = await require_pr_number(branch_name)
|
|
86
|
+
|
|
87
|
+
await add_pr_classification(String(number), label)
|
|
88
|
+
}
|
|
49
89
|
|
|
50
90
|
// **`head` is required and `gh pr create` never asked for it.** The CLI inferred it from the current
|
|
51
91
|
// branch; REST does not, and a request without it is a 422. The branch is read from git rather than
|
|
@@ -64,7 +104,7 @@ const MERGE_READ_BACK_INTERVAL_MS = 1000
|
|
|
64
104
|
// `to_gh_error` appends stdout (joshuafolkken/kit#1029); the `already exists` match itself is
|
|
65
105
|
// unchanged, because both wordings contain it and the 422 carries no machine-readable code for the
|
|
66
106
|
// case (`"code":"custom"`).
|
|
67
|
-
async function
|
|
107
|
+
async function post_pull_request(title: string, body: string): Promise<string> {
|
|
68
108
|
const base = await git_command.get_default_branch()
|
|
69
109
|
const head = await git_command.branch()
|
|
70
110
|
|
|
@@ -81,6 +121,24 @@ async function pr_create(title: string, body: string): Promise<string> {
|
|
|
81
121
|
}
|
|
82
122
|
}
|
|
83
123
|
|
|
124
|
+
async function pr_create(
|
|
125
|
+
title: string,
|
|
126
|
+
body: string,
|
|
127
|
+
label: ReleaseClassification,
|
|
128
|
+
): Promise<string> {
|
|
129
|
+
const classification_error = pr_classification.classification_error([label], 'human')
|
|
130
|
+
if (classification_error !== undefined) throw new Error(classification_error)
|
|
131
|
+
|
|
132
|
+
const url = await post_pull_request(title, body)
|
|
133
|
+
const number = PR_NUMBER_PATTERN.exec(url)?.[1]
|
|
134
|
+
|
|
135
|
+
if (number === undefined) throw new Error(`Could not identify the created pull request: ${url}`)
|
|
136
|
+
|
|
137
|
+
await add_pr_classification(number, label)
|
|
138
|
+
|
|
139
|
+
return url
|
|
140
|
+
}
|
|
141
|
+
|
|
84
142
|
// **`gh pr checkout` does use GraphQL** — one `POST /graphql` to resolve the pull request, measured
|
|
85
143
|
// with `GH_DEBUG=api` on the throwaway pull request (joshuafolkken/kit#1029). Everything after that
|
|
86
144
|
// is git, so only the resolution had to move: `pr_head_reference` is the REST read that already
|
|
@@ -210,6 +268,8 @@ const git_gh_pr = {
|
|
|
210
268
|
...git_gh_pr_read,
|
|
211
269
|
...git_gh_pr_snapshot,
|
|
212
270
|
pr_create,
|
|
271
|
+
pr_get_classification,
|
|
272
|
+
pr_ensure_classification,
|
|
213
273
|
pr_checkout,
|
|
214
274
|
pr_comment,
|
|
215
275
|
pr_merge,
|
package/scripts/git/git-pr.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pr_classification, type ReleaseClassification } from '#scripts/ci/pr-classification'
|
|
1
2
|
import { animation_helpers, type AnimationOptions } from './animation-helpers'
|
|
2
3
|
import { git_gh_command } from './git-gh-command'
|
|
3
4
|
import type { IssueInfo } from './git-issue'
|
|
@@ -12,7 +13,11 @@ import { pr_info_schema } from './schemas'
|
|
|
12
13
|
// in the same instant the pull request was created it can answer nothing, and `pr_get_url` folds
|
|
13
14
|
// "not there yet" and "the read failed" into the same `undefined`. The five-second sleep used to
|
|
14
15
|
// cover that gap incidentally.
|
|
15
|
-
async function create_pr(
|
|
16
|
+
async function create_pr(
|
|
17
|
+
title: string,
|
|
18
|
+
body: string,
|
|
19
|
+
label: ReleaseClassification,
|
|
20
|
+
): Promise<string | undefined> {
|
|
16
21
|
const config: AnimationOptions<string> = {
|
|
17
22
|
icon_selector: () => '✅',
|
|
18
23
|
error_message: 'Failed to create PR',
|
|
@@ -22,7 +27,7 @@ async function create_pr(title: string, body: string): Promise<string | undefine
|
|
|
22
27
|
try {
|
|
23
28
|
return await animation_helpers.execute_with_animation(
|
|
24
29
|
'Creating pull request...',
|
|
25
|
-
async () => await git_gh_command.pr_create(title, body),
|
|
30
|
+
async () => await git_gh_command.pr_create(title, body, label),
|
|
26
31
|
config,
|
|
27
32
|
)
|
|
28
33
|
} catch (error) {
|
|
@@ -72,8 +77,13 @@ async function report_open_pr(branch_name: string, created_url?: string): Promis
|
|
|
72
77
|
|
|
73
78
|
const PR_STATE_MERGED = 'MERGED'
|
|
74
79
|
|
|
75
|
-
async function create_and_report(
|
|
76
|
-
|
|
80
|
+
async function create_and_report(
|
|
81
|
+
title: string,
|
|
82
|
+
body: string,
|
|
83
|
+
branch_name: string,
|
|
84
|
+
label: ReleaseClassification,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const created_url = await create_pr(title, body, label)
|
|
77
87
|
|
|
78
88
|
await report_open_pr(branch_name, created_url)
|
|
79
89
|
}
|
|
@@ -113,7 +123,9 @@ async function report_existing_pr(
|
|
|
113
123
|
body: string,
|
|
114
124
|
branch_name: string,
|
|
115
125
|
should_replace_body: boolean,
|
|
126
|
+
label: ReleaseClassification,
|
|
116
127
|
): Promise<void> {
|
|
128
|
+
await git_gh_command.pr_ensure_classification(branch_name, label)
|
|
117
129
|
if (should_replace_body) await git_gh_command.pr_update_body(branch_name, body)
|
|
118
130
|
|
|
119
131
|
await report_open_pr(branch_name)
|
|
@@ -123,35 +135,36 @@ async function handle_existing_pr(
|
|
|
123
135
|
title: string,
|
|
124
136
|
body: string,
|
|
125
137
|
branch_name: string,
|
|
126
|
-
should_replace_body: boolean,
|
|
138
|
+
options: { label: ReleaseClassification; should_replace_body: boolean },
|
|
127
139
|
): Promise<void> {
|
|
128
140
|
const pr_state_result = await get_pr_state_safe(branch_name)
|
|
129
141
|
|
|
130
142
|
if (is_pr_state_merged(pr_state_result)) {
|
|
131
143
|
git_pr_messages.display_merged_pr_message()
|
|
132
|
-
await create_and_report(title, body, branch_name)
|
|
144
|
+
await create_and_report(title, body, branch_name, options.label)
|
|
133
145
|
|
|
134
146
|
return
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
await report_existing_pr(body, branch_name, should_replace_body)
|
|
149
|
+
await report_existing_pr(body, branch_name, options.should_replace_body, options.label)
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
async function create(
|
|
141
153
|
title: string,
|
|
142
154
|
body: string,
|
|
143
155
|
branch_name: string,
|
|
144
|
-
should_replace_body
|
|
156
|
+
options: { label: ReleaseClassification; should_replace_body?: boolean },
|
|
145
157
|
): Promise<void> {
|
|
158
|
+
const { label, should_replace_body = false } = options
|
|
146
159
|
const has_pr = await git_gh_command.pr_exists(branch_name)
|
|
147
160
|
|
|
148
161
|
if (!has_pr) {
|
|
149
|
-
await create_and_report(title, body, branch_name)
|
|
162
|
+
await create_and_report(title, body, branch_name, label)
|
|
150
163
|
|
|
151
164
|
return
|
|
152
165
|
}
|
|
153
166
|
|
|
154
|
-
await handle_existing_pr(title, body, branch_name, should_replace_body)
|
|
167
|
+
await handle_existing_pr(title, body, branch_name, { label, should_replace_body })
|
|
155
168
|
}
|
|
156
169
|
|
|
157
170
|
function build_title(issue_info: IssueInfo): string {
|
|
@@ -166,11 +179,47 @@ function build_body(issue_info: IssueInfo, extra_body?: string): string {
|
|
|
166
179
|
return `${closes}\n\n${extra_body}`
|
|
167
180
|
}
|
|
168
181
|
|
|
182
|
+
async function existing_pr_label(branch_name: string): Promise<ReleaseClassification | undefined> {
|
|
183
|
+
if (!(await git_gh_command.pr_exists(branch_name))) return undefined
|
|
184
|
+
if (is_pr_state_merged(await get_pr_state_safe(branch_name))) return undefined
|
|
185
|
+
|
|
186
|
+
return await git_gh_command.pr_get_classification(branch_name)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function create_for_issue(
|
|
190
|
+
issue_info: IssueInfo,
|
|
191
|
+
extra_body: string | undefined,
|
|
192
|
+
label: ReleaseClassification,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
await create(
|
|
195
|
+
build_title(issue_info),
|
|
196
|
+
build_body(issue_info, extra_body),
|
|
197
|
+
issue_info.branch_name,
|
|
198
|
+
{
|
|
199
|
+
label,
|
|
200
|
+
should_replace_body: extra_body !== undefined,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
169
205
|
async function create_with_issue_info(issue_info: IssueInfo, extra_body?: string): Promise<void> {
|
|
170
|
-
const
|
|
171
|
-
|
|
206
|
+
const existing_label = await existing_pr_label(issue_info.branch_name)
|
|
207
|
+
|
|
208
|
+
if (existing_label !== undefined) {
|
|
209
|
+
await create_for_issue(issue_info, extra_body, existing_label)
|
|
210
|
+
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const issue_json = await git_gh_command.issue_view_json(issue_info.number, 'labels,body')
|
|
215
|
+
|
|
216
|
+
if (issue_json === undefined) {
|
|
217
|
+
throw new Error(`Could not read issue #${issue_info.number} for release classification`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const label = pr_classification.select_issue_classification(issue_json)
|
|
172
221
|
|
|
173
|
-
await
|
|
222
|
+
await create_for_issue(issue_info, extra_body, label)
|
|
174
223
|
}
|
|
175
224
|
|
|
176
225
|
const git_pr = {
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// labeler and the next-issues display all key on these exact strings, and a drifted copy would
|
|
3
3
|
// fail silently — an epic filtered on the wrong name is simply never closed or never excluded.
|
|
4
4
|
const EPIC_LABEL = 'epic'
|
|
5
|
+
const BUG_LABEL = 'bug'
|
|
6
|
+
const ENHANCEMENT_LABEL = 'enhancement'
|
|
7
|
+
const BREAKING_CHANGE_LABEL = 'breaking-change'
|
|
5
8
|
const IN_PROGRESS_LABEL = 'in-progress'
|
|
6
9
|
// Parks a child that cannot advance without a person deciding something. `epic:next` is what reads
|
|
7
10
|
// it: a parked child is why a run reports "nothing left that time will fix" rather than waiting
|
|
@@ -237,6 +240,9 @@ const REVIEW_ROUND2_SKIPPED_LABEL = 'review-round2-skipped'
|
|
|
237
240
|
|
|
238
241
|
const ALL_LABELS: ReadonlySet<string> = new Set([
|
|
239
242
|
EPIC_LABEL,
|
|
243
|
+
BUG_LABEL,
|
|
244
|
+
ENHANCEMENT_LABEL,
|
|
245
|
+
BREAKING_CHANGE_LABEL,
|
|
240
246
|
IN_PROGRESS_LABEL,
|
|
241
247
|
NEEDS_DECISION_LABEL,
|
|
242
248
|
AUTO_OK_LABEL,
|
|
@@ -256,12 +262,15 @@ export {
|
|
|
256
262
|
ALL_LABELS,
|
|
257
263
|
ALREADY_DONE_LABEL,
|
|
258
264
|
AUTO_OK_LABEL,
|
|
265
|
+
BUG_LABEL,
|
|
266
|
+
BREAKING_CHANGE_LABEL,
|
|
259
267
|
depth_label_of,
|
|
260
268
|
DEPTH_0_LABEL,
|
|
261
269
|
DEPTH_1_LABEL,
|
|
262
270
|
DEPTH_2_LABEL,
|
|
263
271
|
DEPTH_LABEL_ORDER,
|
|
264
272
|
DEPTH_LABELS,
|
|
273
|
+
ENHANCEMENT_LABEL,
|
|
265
274
|
EPIC_LABEL,
|
|
266
275
|
FILING_ROUTE_LABELS,
|
|
267
276
|
has_any_label,
|
|
@@ -20,8 +20,10 @@ const GITHUB_REPO_TREE_BASE = 'https://github.com/joshuafolkken/kit/tree/main/'
|
|
|
20
20
|
// The consumer imports kit's published, already path-transformed rules. A tracked bootstrap note
|
|
21
21
|
// remains readable before installation, while the rule body stays in the package (kit#1878).
|
|
22
22
|
const CLAUDE_MD_IMPORT_LINE = '@node_modules/@joshuafolkken/kit/dist/CLAUDE.md'
|
|
23
|
-
const
|
|
23
|
+
const OLD_CLAUDE_MD_BOOTSTRAP =
|
|
24
24
|
'> Fresh checkout: if kit is not installed, run `pnpm install` first, then reread this file before doing any other work.'
|
|
25
|
+
const CLAUDE_MD_BOOTSTRAP =
|
|
26
|
+
'> Fresh checkout: before `pnpm install`, run `pnpm config get "@joshuafolkken:registry"` from the project root to check the effective registry, including user-level settings. If it points to GitHub Packages, [set up authentication](https://github.com/joshuafolkken/kit/blob/main/docs/authentication.md#1-get-a-token-from-the-gh-cli) first. Then run `pnpm install` and reread this file before doing any other work.'
|
|
25
27
|
|
|
26
28
|
// A span containing `*` is excluded: it is a **glob**, not a reference to a file a consumer can
|
|
27
29
|
// open. A distributed document that writes a directory set as `prompts/**` means "anything beneath
|
|
@@ -84,13 +86,15 @@ function transform_distributed_paths(content: string): string {
|
|
|
84
86
|
function ensure_claude_md_import(existing: string | undefined): string {
|
|
85
87
|
if (existing === undefined) return `${CLAUDE_MD_BOOTSTRAP}\n\n${CLAUDE_MD_IMPORT_LINE}\n`
|
|
86
88
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
const updated = existing.replace(OLD_CLAUDE_MD_BOOTSTRAP, () => CLAUDE_MD_BOOTSTRAP)
|
|
90
|
+
|
|
91
|
+
if (updated.includes(CLAUDE_MD_IMPORT_LINE)) {
|
|
92
|
+
if (updated.includes(CLAUDE_MD_BOOTSTRAP)) return updated
|
|
89
93
|
|
|
90
|
-
return `${CLAUDE_MD_BOOTSTRAP}\n\n${
|
|
94
|
+
return `${CLAUDE_MD_BOOTSTRAP}\n\n${updated}`
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
return `${CLAUDE_MD_BOOTSTRAP}\n\n${CLAUDE_MD_IMPORT_LINE}\n\n${
|
|
97
|
+
return `${CLAUDE_MD_BOOTSTRAP}\n\n${CLAUDE_MD_IMPORT_LINE}\n\n${updated}`
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
const distributed_paths = {
|
|
@@ -10,6 +10,11 @@ interface HookResult {
|
|
|
10
10
|
stderr: string
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
interface CheckoutOptions {
|
|
14
|
+
git_overrides?: Record<string, string>
|
|
15
|
+
is_external_git?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
function create_partial_install(temporary_root: string): void {
|
|
14
19
|
const package_root = path.join(temporary_root, 'node_modules/@joshuafolkken/kit')
|
|
15
20
|
const hook_path = path.join(package_root, 'dist/hooks/pretool-guard.js')
|
|
@@ -19,25 +24,52 @@ function create_partial_install(temporary_root: string): void {
|
|
|
19
24
|
writeFileSync(hook_path, "process.stdout.write('guard ran')")
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
function
|
|
23
|
-
|
|
27
|
+
function execution_directory(temporary_root: string, is_nested: boolean): string {
|
|
28
|
+
if (!is_nested) return temporary_root
|
|
29
|
+
|
|
30
|
+
const nested_root = path.join(temporary_root, 'src')
|
|
31
|
+
|
|
32
|
+
mkdirSync(nested_root)
|
|
33
|
+
|
|
34
|
+
return nested_root
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkout_environment(temporary_root: string, options: CheckoutOptions): NodeJS.ProcessEnv {
|
|
38
|
+
const metadata_root = path.join(temporary_root, 'metadata')
|
|
24
39
|
const git_environment = {
|
|
25
40
|
...process.env,
|
|
26
41
|
...git_location_environment.location_free_environment(),
|
|
27
42
|
}
|
|
43
|
+
const git_arguments = options.is_external_git
|
|
44
|
+
? ['init', '--bare', '-q', metadata_root]
|
|
45
|
+
: ['init', '-q']
|
|
46
|
+
|
|
47
|
+
spawnSync('git', git_arguments, { cwd: temporary_root, env: git_environment })
|
|
48
|
+
|
|
49
|
+
if (options.is_external_git) {
|
|
50
|
+
return { ...git_environment, GIT_DIR: metadata_root, GIT_WORK_TREE: temporary_root }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { ...git_environment, ...options.git_overrides }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function run_in_temporary_checkout(
|
|
57
|
+
command: string,
|
|
58
|
+
is_partial_install = false,
|
|
59
|
+
is_nested = false,
|
|
60
|
+
options: CheckoutOptions = {},
|
|
61
|
+
): HookResult {
|
|
62
|
+
const temporary_root = mkdtempSync(path.join(tmpdir(), 'kit-hook-bootstrap-'))
|
|
28
63
|
|
|
29
64
|
try {
|
|
30
|
-
|
|
31
|
-
cwd: temporary_root,
|
|
32
|
-
env: git_environment,
|
|
33
|
-
})
|
|
65
|
+
const hook_environment = checkout_environment(temporary_root, options)
|
|
34
66
|
|
|
35
67
|
if (is_partial_install) create_partial_install(temporary_root)
|
|
36
68
|
|
|
37
69
|
const { status, stdout, stderr } = spawnSync('sh', ['-c', command], {
|
|
38
|
-
cwd: temporary_root,
|
|
70
|
+
cwd: execution_directory(temporary_root, is_nested),
|
|
39
71
|
encoding: 'utf8',
|
|
40
|
-
env:
|
|
72
|
+
env: hook_environment,
|
|
41
73
|
})
|
|
42
74
|
|
|
43
75
|
return { status, stdout, stderr }
|