@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.
- 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/dist/CLAUDE.md +2 -0
- package/dist/CLAUDE.static.md +8 -0
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-36UE53LS.js → chunk-3HZ5MJIW.js} +2 -2
- package/dist/hooks/chunk-3V37IID6.js +82 -0
- package/dist/hooks/{chunk-FRJKVSK3.js → chunk-77RLKZ5V.js} +2 -2
- package/dist/hooks/{chunk-VGYSA2NT.js → chunk-LC765SUA.js} +16 -2
- package/dist/hooks/{chunk-ZBK4XRAS.js → chunk-NWD47MW3.js} +3 -3
- package/dist/hooks/{chunk-4OXFU3MK.js → chunk-O2FBZPSW.js} +619 -156
- package/dist/hooks/{chunk-6MQCXEEA.js → chunk-OLTAGGHH.js} +25 -88
- package/dist/hooks/{chunk-FNFOLFFV.js → chunk-OYU6KPRA.js} +1 -1
- package/dist/hooks/{chunk-PRDCNGA6.js → chunk-UANRLZV7.js} +4 -4
- package/dist/hooks/{chunk-264QY6KS.js → chunk-UULARQ5Q.js} +23 -1
- package/dist/hooks/{chunk-Y5OYCU2F.js → chunk-XMR3BS35.js} +3 -3
- package/dist/hooks/codex-hook-adapter.js +11 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +3 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +10 -9
- package/dist/hooks/stop-guard.js +3 -3
- package/dist/josh.js +155 -12
- package/package.json +68 -17
- 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/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 +121 -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 +104 -19
- package/scripts/init/init-static.ts +52 -0
- package/scripts/init/init.ts +97 -30
- package/scripts/init/project-profile-cli.ts +14 -0
- package/scripts/init/project-profile.ts +149 -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 +6 -0
- 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/lint-parallel.ts +44 -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-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/prettierignore.static +12 -0
- 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
|
-
|
|
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:
|
|
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.
|
|
@@ -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
|
|
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,
|