@joshuafolkken/kit 1.887.0 → 1.921.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.claude/skills/workflow-commands/fullrun-steps.md +4 -2
  2. package/.claude/skills/workflow-commands/halfrun.md +3 -1
  3. package/.claude/skills/workflow-commands/kickoff.md +5 -3
  4. package/.claude/skills/workflow-commands/observation-filing.md +3 -1
  5. package/.claude/skills/workflow-commands/prerequisite.md +3 -1
  6. package/.claude/skills/workflow-commands/target-repository.md +1 -1
  7. package/.github/pull_request_template.md +12 -0
  8. package/.github/release.yml +3 -0
  9. package/.github/workflows/ci.yml +8 -0
  10. package/.github/workflows/production.yml +10 -1
  11. package/.github/workflows/publish.yml +72 -2
  12. package/CLAUDE.md +2 -0
  13. package/dist/CLAUDE.md +2 -0
  14. package/dist/CLAUDE.static.md +8 -0
  15. package/dist/hooks/batch-guard.js +4 -4
  16. package/dist/hooks/{chunk-36UE53LS.js → chunk-3HZ5MJIW.js} +2 -2
  17. package/dist/hooks/chunk-3V37IID6.js +82 -0
  18. package/dist/hooks/{chunk-FRJKVSK3.js → chunk-77RLKZ5V.js} +2 -2
  19. package/dist/hooks/{chunk-VGYSA2NT.js → chunk-LC765SUA.js} +16 -2
  20. package/dist/hooks/{chunk-ZBK4XRAS.js → chunk-NWD47MW3.js} +3 -3
  21. package/dist/hooks/{chunk-4OXFU3MK.js → chunk-O2FBZPSW.js} +619 -156
  22. package/dist/hooks/{chunk-6MQCXEEA.js → chunk-OLTAGGHH.js} +25 -88
  23. package/dist/hooks/{chunk-FNFOLFFV.js → chunk-OYU6KPRA.js} +1 -1
  24. package/dist/hooks/{chunk-PRDCNGA6.js → chunk-UANRLZV7.js} +4 -4
  25. package/dist/hooks/{chunk-264QY6KS.js → chunk-UULARQ5Q.js} +23 -1
  26. package/dist/hooks/{chunk-Y5OYCU2F.js → chunk-XMR3BS35.js} +3 -3
  27. package/dist/hooks/codex-hook-adapter.js +11 -10
  28. package/dist/hooks/duplicate-read-guard.js +5 -5
  29. package/dist/hooks/format-edited.js +3 -2
  30. package/dist/hooks/investigation-guard.js +5 -5
  31. package/dist/hooks/pretool-guard.js +10 -9
  32. package/dist/hooks/stop-guard.js +3 -3
  33. package/dist/josh.js +155 -12
  34. package/package.json +68 -17
  35. package/prettier/index.js +3 -8
  36. package/prettier/static.d.ts +3 -0
  37. package/prettier/static.js +10 -0
  38. package/prompts/collaboration-workflow/issue-template.md +11 -4
  39. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  40. package/prompts/collaboration-workflow/wip-cap.md +2 -2
  41. package/prompts/review.md +2 -2
  42. package/prompts/testing-guide.md +1 -0
  43. package/scripts/build/build-claude-md.ts +6 -1
  44. package/scripts/ci/pr-classification.ts +114 -0
  45. package/scripts/document/document-byte-budget.ts +3 -2
  46. package/scripts/gate/gate-report.ts +2 -0
  47. package/scripts/gate/gate-steps.ts +50 -0
  48. package/scripts/gate/project-checks.ts +121 -0
  49. package/scripts/gate/verification-gate.ts +14 -43
  50. package/scripts/git/git-common-directory.ts +9 -1
  51. package/scripts/git/git-gh-pr-rollup.ts +29 -1
  52. package/scripts/git/git-gh-pr.ts +61 -1
  53. package/scripts/git/git-pr.ts +62 -13
  54. package/scripts/git/issue-labels.ts +9 -0
  55. package/scripts/init/distributed-paths.ts +9 -5
  56. package/scripts/init/hook-command-bootstrap.ts +40 -8
  57. package/scripts/init/hook-command-rewrite.ts +7 -3
  58. package/scripts/init/init-actions.ts +94 -7
  59. package/scripts/init/init-ai-copy.ts +104 -19
  60. package/scripts/init/init-static.ts +52 -0
  61. package/scripts/init/init.ts +97 -30
  62. package/scripts/init/project-profile-cli.ts +14 -0
  63. package/scripts/init/project-profile.ts +149 -0
  64. package/scripts/init/start.ts +30 -0
  65. package/scripts/issue/issue-bug-label.ts +13 -0
  66. package/scripts/issue/issue-classification.ts +74 -0
  67. package/scripts/issue/issue-lint-cli.ts +10 -3
  68. package/scripts/issue/markdown-section.ts +38 -4
  69. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +27 -1
  70. package/scripts/josh/josh-command-map.ts +3 -0
  71. package/scripts/josh/josh-commands-development.ts +6 -0
  72. package/scripts/josh/josh-commands-project.ts +14 -1
  73. package/scripts/josh/josh-logic.ts +13 -0
  74. package/scripts/josh/run-github-prerequisite.ts +32 -0
  75. package/scripts/lane/lane-await.ts +8 -1
  76. package/scripts/lane/openai-lane-ship.ts +30 -0
  77. package/scripts/lane/openai-lane-supervisor.ts +35 -8
  78. package/scripts/lane/openai-review-broker.ts +260 -0
  79. package/scripts/lint/cspell-cached.ts +21 -0
  80. package/scripts/lint/lint-parallel.ts +44 -1
  81. package/scripts/observations/observations-flush.ts +1 -1
  82. package/scripts/package/production-dependency-graph.ts +68 -0
  83. package/scripts/package/verify-optional-eslint-install.ts +62 -0
  84. package/scripts/release/github-release-cli.ts +11 -0
  85. package/scripts/release/github-release.ts +220 -0
  86. package/scripts/release/publish-tag-cli.ts +8 -0
  87. package/scripts/release/publish-tag.ts +11 -0
  88. package/scripts/release/release-publish.ts +7 -2
  89. package/scripts/rules/delivered-rules.ts +2 -0
  90. package/scripts/rules/issue-bug-label.ts +85 -0
  91. package/scripts/rules/issue-filing-args.ts +50 -0
  92. package/scripts/run/run-merge-cli.ts +19 -0
  93. package/scripts/run/run-merge.ts +9 -1
  94. package/scripts/run/run-ship-cli.ts +20 -0
  95. package/scripts/run/run-ship-detach.ts +180 -14
  96. package/scripts/run/run-ship-review-steps.ts +45 -25
  97. package/scripts/run/run-wake-session.ts +3 -1
  98. package/scripts/test/pilot-files.ts +0 -2
  99. package/scripts/test/skip-marker.ts +3 -0
  100. package/scripts/test/test-declared-changed.ts +2 -1
  101. package/scripts/test/test-declared-logic.ts +15 -10
  102. package/scripts/test/test-declared.ts +20 -8
  103. package/scripts/test/test-red-logic.ts +3 -3
  104. package/scripts/test/test-unit-guard.ts +24 -12
  105. package/templates/CLAUDE.static.md +8 -0
  106. package/templates/cursorrules.static +1 -0
  107. package/templates/prettierignore.static +12 -0
  108. package/templates/workflows/ci.yml +5 -0
@@ -0,0 +1,114 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { z } from 'zod'
4
+
5
+ const BUGFIX = 'bugfix'
6
+ const BREAKING = 'breaking-change'
7
+ const CLASSIFICATION_LABELS = [
8
+ BREAKING,
9
+ 'enhancement',
10
+ BUGFIX,
11
+ 'other-change',
12
+ 'ignore-for-release',
13
+ ] as const
14
+ type ReleaseClassification = (typeof CLASSIFICATION_LABELS)[number]
15
+ const CLASSIFICATION_SET: ReadonlySet<string> = new Set(CLASSIFICATION_LABELS)
16
+ const ISSUE_BUG_LABEL = 'bug'
17
+ const DECLARATION = /^- リリース分類: (.+)$/gmu
18
+ const LEGACY_LABELS: ReadonlySet<string> = new Set(['semver-major', 'semver-minor'])
19
+
20
+ const LABEL = z.object({ name: z.string() })
21
+ const USER = z.object({ login: z.string() })
22
+ const PULL_REQUEST = z.object({ user: USER, labels: z.array(LABEL) })
23
+ const EVENT = z.object({
24
+ pull_request: PULL_REQUEST,
25
+ })
26
+
27
+ function classification_error(labels: ReadonlyArray<string>, author: string): string | undefined {
28
+ const legacy = labels.filter((label) => LEGACY_LABELS.has(label.toLowerCase()))
29
+ if (legacy.length > 0) return `Remove legacy release labels: ${legacy.join(', ')}`
30
+
31
+ const selected = labels.filter((label) => CLASSIFICATION_SET.has(label.toLowerCase()))
32
+
33
+ if (selected.length > 1) {
34
+ return `Choose exactly one release classification; found: ${selected.join(', ')}`
35
+ }
36
+
37
+ if (selected.length === 1 || author.endsWith('[bot]')) return undefined
38
+
39
+ return `Choose one release classification: ${CLASSIFICATION_LABELS.join(', ')}`
40
+ }
41
+
42
+ function is_classification(value: string): value is ReleaseClassification {
43
+ return CLASSIFICATION_SET.has(value)
44
+ }
45
+
46
+ function release_candidates(
47
+ candidates: ReadonlyArray<ReleaseClassification>,
48
+ ): ReadonlyArray<ReleaseClassification> {
49
+ if (!candidates.includes(BREAKING)) return candidates
50
+
51
+ return candidates.filter((candidate) => candidate !== 'enhancement')
52
+ }
53
+
54
+ function issue_candidates(issue_json: string): ReadonlyArray<ReleaseClassification> {
55
+ const issue = z
56
+ .object({ labels: z.array(LABEL), body: z.string().nullable() })
57
+ .parse(JSON.parse(issue_json))
58
+ const names = issue.labels.map((label) => label.name.toLowerCase())
59
+ const declared = [...(issue.body ?? '').matchAll(DECLARATION)].map((match) => match[1] ?? '')
60
+ const candidates = [...release_candidates([...names, ...declared].filter(is_classification))]
61
+ const unknown = declared.filter((value) => !is_classification(value))
62
+
63
+ if (unknown.length > 0) throw new Error(`Unknown release classification: ${unknown.join(', ')}`)
64
+
65
+ if (names.includes(ISSUE_BUG_LABEL) && !candidates.includes(BREAKING)) {
66
+ candidates.push(BUGFIX)
67
+ }
68
+
69
+ return candidates
70
+ }
71
+
72
+ function select_issue_classification(issue_json: string): ReleaseClassification {
73
+ const candidates = issue_candidates(issue_json)
74
+ const selected = [...new Set(candidates)]
75
+ const [label] = selected
76
+
77
+ if (label === undefined || selected.length > 1) {
78
+ throw new Error(
79
+ `Choose one release classification before opening the PR: ${CLASSIFICATION_LABELS.join(', ')}; found: ${selected.join(', ') || 'none'}. Add "- リリース分類: <label>" to the Issue body.`,
80
+ )
81
+ }
82
+
83
+ return label
84
+ }
85
+
86
+ function check_event(event_path: string): string | undefined {
87
+ const event = EVENT.parse(JSON.parse(readFileSync(event_path, 'utf8')))
88
+
89
+ return classification_error(
90
+ event.pull_request.labels.map((label) => label.name),
91
+ event.pull_request.user.login,
92
+ )
93
+ }
94
+
95
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
96
+ const event_path = process.env['GITHUB_EVENT_PATH']
97
+ if (!event_path) throw new Error('GITHUB_EVENT_PATH is required')
98
+
99
+ const error = check_event(event_path)
100
+
101
+ if (error) {
102
+ console.error(`::error::${error}`)
103
+ process.exitCode = 1
104
+ }
105
+ }
106
+
107
+ const pr_classification = {
108
+ classification_error,
109
+ check_event,
110
+ is_classification,
111
+ select_issue_classification,
112
+ }
113
+
114
+ export { pr_classification, CLASSIFICATION_LABELS, type ReleaseClassification }
@@ -95,7 +95,8 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
95
95
  { path: 'prompts/collaboration-workflow/file-edits.md', bytes: 12_288 },
96
96
  { path: 'prompts/collaboration-workflow/gh-rest.md', bytes: 8192 },
97
97
  { path: 'prompts/collaboration-workflow/issue-citation.md', bytes: 8192 },
98
- { path: 'prompts/collaboration-workflow/issue-template.md', bytes: 24_576 },
98
+ // #2662 adds the feature and compatibility declarations to the canonical filing procedure.
99
+ { path: 'prompts/collaboration-workflow/issue-template.md', bytes: 28_672 },
99
100
  { path: 'prompts/collaboration-workflow/latest-first.md', bytes: 4096 },
100
101
  { path: 'prompts/collaboration-workflow/no-clones.md', bytes: 4096 },
101
102
  { path: 'prompts/collaboration-workflow/operating-rules.md', bytes: 24_576 },
@@ -116,7 +117,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
116
117
  { path: 'prompts/review-rubric.md', bytes: 20_480 },
117
118
  { path: 'prompts/review.md', bytes: 20_480 },
118
119
  { path: 'prompts/sonar-hotspot-handling.md', bytes: 8192 },
119
- { path: 'prompts/testing-guide.md', bytes: 20_480 },
120
+ { path: 'prompts/testing-guide.md', bytes: 24_576 },
120
121
  ]
121
122
 
122
123
  // The recorded ceiling for one path, or undefined when it carries no budget entry.
@@ -16,6 +16,8 @@ const { FAIL_ICON, PASS_ICON } = status_icons
16
16
  interface GateStep {
17
17
  label: string
18
18
  command_args: ReadonlyArray<string>
19
+ cwd?: string | undefined
20
+ skip_reason?: string | undefined
19
21
  }
20
22
 
21
23
  interface GateStepResult extends BufferedProcessResult {
@@ -0,0 +1,50 @@
1
+ import { gate_plan, type GateCheck, type GatePlan } from './gate-plan'
2
+ import type { GateStep } from './gate-report'
3
+ import { project_checks } from './project-checks'
4
+ import { type_check_step } from './type-check-step'
5
+
6
+ const JOSH = 'josh'
7
+ const { TYPE_CHECK_LABEL, UNIT_LABEL } = gate_plan
8
+ const UNIT_WORKER_FLAG = '--maxWorkers'
9
+
10
+ function unit_worker_args(check: GateCheck, plan: GatePlan): ReadonlyArray<string> {
11
+ if (check.label !== UNIT_LABEL || plan.unit_worker_cap === undefined) return []
12
+
13
+ return [`${UNIT_WORKER_FLAG}=${String(plan.unit_worker_cap)}`]
14
+ }
15
+
16
+ // Resolve the type checker per project and run every static check from its package root.
17
+ async function build_gate_step(
18
+ check: GateCheck,
19
+ start_directory: string,
20
+ plan: GatePlan = gate_plan.resolve_gate_plan(),
21
+ ): Promise<GateStep> {
22
+ const cwd = project_checks.is_static(start_directory)
23
+ ? project_checks.project_root(start_directory)
24
+ : undefined
25
+
26
+ if (check.label !== TYPE_CHECK_LABEL) {
27
+ return {
28
+ label: check.label,
29
+ command_args: [JOSH, check.target, ...unit_worker_args(check, plan)],
30
+ cwd,
31
+ }
32
+ }
33
+
34
+ const command_args = await type_check_step.resolve_type_check_args(start_directory)
35
+ const skip_reason =
36
+ command_args[0] === JOSH ? project_checks.type_check_skip_reason(start_directory) : undefined
37
+
38
+ return { label: check.label, command_args, cwd, skip_reason }
39
+ }
40
+
41
+ async function build_gate_steps(
42
+ start_directory: string,
43
+ plan: GatePlan = gate_plan.resolve_gate_plan(),
44
+ ): Promise<ReadonlyArray<GateStep>> {
45
+ return await Promise.all(
46
+ plan.checks.map(async (check) => await build_gate_step(check, start_directory, plan)),
47
+ )
48
+ }
49
+
50
+ export { build_gate_step, build_gate_steps, UNIT_WORKER_FLAG }
@@ -0,0 +1,121 @@
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
+ const project_checks = {
106
+ CSPELL_CONFIGS,
107
+ ESLINT_CONFIGS,
108
+ SCRIPT_FILES,
109
+ TYPE_CONFIGS,
110
+ TYPE_FILES,
111
+ WEB_FILES,
112
+ has_bin,
113
+ has_config,
114
+ has_files,
115
+ is_static,
116
+ project_root,
117
+ skip_notice,
118
+ type_check_skip_reason,
119
+ }
120
+
121
+ 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 GateCheck, type GatePlan } from './gate-plan'
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 JOSH = 'josh'
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
- // vitest's own flag, appended to the sub-command rather than set in `vitest.config.ts`: the config
42
- // is one project's, and the number this carries is a property of the machine the gate is running
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: check.label,
64
- command_args: [JOSH, check.target, ...unit_worker_args(check, plan)],
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
- return {
69
- label: check.label,
70
- command_args: await type_check_step.resolve_type_check_args(start_directory),
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
- const git_common_directory = { resolve, select_linked }
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 pages.flatMap((page) => page.check_runs)
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> {
@@ -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 pr_create(title: string, body: string): Promise<string> {
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,