@joshuafolkken/kit 1.978.0 → 1.998.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/epic-commands/SKILL.md +6 -5
- package/.claude/skills/workflow-commands/SKILL.md +9 -9
- package/.claude/skills/workflow-commands/background-commands.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
- package/.claude/skills/workflow-commands/fullrun-steps.md +8 -8
- package/.claude/skills/workflow-commands/halfrun.md +4 -5
- package/.claude/skills/workflow-commands/issue-comments.md +1 -1
- package/.claude/skills/workflow-commands/issue-scout.md +21 -22
- package/.claude/skills/workflow-commands/kickoff.md +8 -9
- package/.claude/skills/workflow-commands/observation-filing.md +9 -10
- package/.claude/skills/workflow-commands/prerequisite.md +7 -6
- package/.claude/skills/workflow-commands/retrospective.md +5 -5
- package/.claude/skills/workflow-commands/rule-residency.md +2 -2
- package/.claude/skills/workflow-commands/split-assessment.md +4 -3
- package/.claude/skills/workflow-commands/target-repository.md +7 -6
- package/.github/workflows/sonar-qube.yml +12 -2
- package/CLAUDE.md +2 -2
- package/README.md +30 -21
- package/dist/CLAUDE.md +2 -2
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-ZYYSBNR6.js → chunk-4GDR2YTV.js} +4 -4
- package/dist/hooks/{chunk-2K52BRWV.js → chunk-56LGMJJV.js} +2 -2
- package/dist/hooks/{chunk-BNGGEY3Z.js → chunk-762BDXDU.js} +266 -327
- package/dist/hooks/{chunk-Q7LE77CO.js → chunk-7WAOP42A.js} +34 -30
- package/dist/hooks/{chunk-NXL3SBXY.js → chunk-A2H5I2VD.js} +2 -2
- package/dist/hooks/{chunk-CRGJTD6E.js → chunk-AWTFKKFW.js} +1 -1
- package/dist/hooks/{chunk-KQG4776P.js → chunk-GBJUERYK.js} +1 -1
- package/dist/hooks/{chunk-BVMSPG3M.js → chunk-KPZN42RT.js} +3 -3
- package/dist/hooks/{chunk-ZPLXLJ7O.js → chunk-PAXMLGSS.js} +42 -8
- package/dist/hooks/{chunk-F6OM6SIG.js → chunk-WMIR5SNX.js} +12 -12
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +99 -27
- package/dist/josh.js +21 -6
- package/package.json +5 -1
- package/prettier/basic.d.ts +3 -0
- package/prettier/basic.js +10 -0
- package/prettier/index.js +2 -2
- package/prettier/static.js +3 -10
- package/prompts/collaboration-workflow/epic-bundle.md +8 -8
- package/prompts/collaboration-workflow/issue-template.md +5 -5
- package/prompts/collaboration-workflow/rule-delivery.md +43 -30
- package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
- package/prompts/collaboration-workflow/wip-cap.md +3 -3
- package/prompts/review.md +11 -9
- package/prompts/sonar-hotspot-handling.md +6 -0
- package/prompts/testing-guide.md +1 -1
- package/scripts/agent/agent-diagnostics.ts +1 -0
- package/scripts/agent/agent-role-profile.ts +1 -1
- package/scripts/behavior/behavior-rules.ts +8 -1
- package/scripts/build/build-claude-md.ts +13 -4
- package/scripts/delegation/delegation-policy.ts +9 -9
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/gate/gate-steps.ts +1 -1
- package/scripts/gate/project-checks.ts +12 -11
- package/scripts/gate/type-check-command.ts +1 -1
- package/scripts/git/git-branch.ts +14 -3
- package/scripts/git/git-issue.ts +13 -5
- package/scripts/git/git-pr.ts +16 -10
- package/scripts/git/git-preflight.ts +138 -0
- package/scripts/git/telegram-notify.ts +21 -0
- package/scripts/hooks/stop-guard.ts +2 -0
- package/scripts/init/basic-path-migration.ts +24 -0
- package/scripts/init/distributed-paths.ts +22 -0
- package/scripts/init/init-actions.ts +27 -19
- package/scripts/init/init-ai-copy.ts +86 -28
- package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
- package/scripts/init/init-bootstrap.ts +4 -3
- package/scripts/init/init-install.ts +1 -1
- package/scripts/init/init-logic-templates.ts +1 -1
- package/scripts/init/init-logic.ts +1 -1
- package/scripts/init/init.ts +41 -19
- package/scripts/init/kit-setup-state.ts +30 -0
- package/scripts/init/kit-written-paths.ts +33 -0
- package/scripts/init/project-profile-cli.ts +15 -1
- package/scripts/init/project-profile.ts +41 -12
- package/scripts/init/session-language-environment.ts +83 -0
- package/scripts/init/start-exec.ts +28 -0
- package/scripts/init/start-plan.ts +35 -13
- package/scripts/init/start-prompt.ts +22 -8
- package/scripts/init/start-setup-pr.ts +138 -0
- package/scripts/init/start-steps.ts +30 -30
- package/scripts/init/start.ts +7 -3
- package/scripts/issue/issue-file-cli.ts +188 -0
- package/scripts/issue/issue-file.ts +192 -0
- package/scripts/issue/issue-lint-cli.ts +1 -1
- package/scripts/issue/issue-scout-cli.ts +41 -97
- package/scripts/issue/issue-scout-closed.ts +102 -0
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +12 -4
- package/scripts/josh/josh-command-map.ts +1 -0
- package/scripts/josh/josh-commands-development.ts +3 -3
- package/scripts/josh/josh-commands-issue.ts +11 -0
- package/scripts/josh/josh-commands-project.ts +7 -3
- package/scripts/lint/cspell-cached.ts +3 -3
- package/scripts/lint/format.ts +1 -1
- package/scripts/lint/lint-parallel.ts +2 -2
- package/scripts/propagate/propagate-run.ts +1 -0
- package/scripts/propagate/propagate-select.ts +72 -0
- package/scripts/propagate/propagate-targets.ts +5 -3
- package/scripts/propagate/propagate.ts +86 -24
- package/scripts/rules/bash-triggers.ts +19 -6
- package/scripts/rules/delivered-rules.ts +28 -32
- package/scripts/rules/direct-filing.ts +38 -0
- package/scripts/rules/filing-cap.ts +12 -8
- package/scripts/rules/issue-citation.ts +30 -1
- package/scripts/rules/issue-fold.ts +4 -4
- package/scripts/rules/last-prompt.ts +79 -0
- package/scripts/rules/oracle-consulted.ts +4 -4
- package/scripts/rules/oracle-firing.ts +13 -16
- package/scripts/rules/rule-body-guard.ts +6 -9
- package/scripts/rules/stop-rules.ts +8 -5
- package/scripts/safe-chain/ci-installer-pin.ts +19 -12
- package/scripts/safe-chain/project-config.ts +1 -1
- package/scripts/sync/sync-ai-files.ts +204 -0
- package/scripts/sync/sync.ts +48 -184
- package/scripts/test/josh-harness-environment.ts +1 -1
- package/scripts/test/pilot-files.ts +0 -2
- package/scripts/test/test-declared-changed.ts +1 -1
- package/scripts/test/test-declared-logic.ts +55 -14
- package/scripts/test/test-declared.ts +23 -11
- package/scripts/test/test-unit-guard.ts +6 -6
- package/scripts/time-runtime/time-shell.ts +36 -2
- package/scripts/version/fresh-metadata-cache.ts +36 -0
- package/scripts/version/latest-update.ts +2 -1
- package/scripts-ai/git-workflow.ts +10 -0
- package/templates/CLAUDE.basic.md +8 -0
- package/templates/workflows/ci.yml +27 -0
- package/scripts/rules/issue-bug-label.ts +0 -85
- package/scripts/rules/issue-filing-args.ts +0 -50
- package/scripts/rules/issue-scout.ts +0 -64
- /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
- /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
- /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
- /package/templates/{prettierignore.static → prettierignore.basic} +0 -0
package/scripts/init/start.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { start_steps } from './start-steps'
|
|
|
13
13
|
|
|
14
14
|
const ARGUMENT_START_INDEX = 2
|
|
15
15
|
const READY_MESSAGE = 'GitHub workflow ready. Use kickoff new in your assistant to plan an Issue.'
|
|
16
|
-
const START_USAGE = 'josh start [--profile
|
|
16
|
+
const START_USAGE = 'josh start [--profile basic|full] [--yes] [--github] [--public]'
|
|
17
17
|
const YES_FLAG = '--yes'
|
|
18
18
|
const GITHUB_FLAG = '--github'
|
|
19
19
|
const PUBLIC_FLAG = '--public'
|
|
@@ -72,7 +72,11 @@ function prepare(args: ReadonlyArray<string>, is_tty: boolean, root: string): Pr
|
|
|
72
72
|
|
|
73
73
|
stop_on(start_steps.github_cli_refusal(root))
|
|
74
74
|
|
|
75
|
-
return {
|
|
75
|
+
return {
|
|
76
|
+
steps: plan.steps,
|
|
77
|
+
interaction,
|
|
78
|
+
choices: { options, shape, consent, root, steps: plan.steps },
|
|
79
|
+
}
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
|
|
@@ -82,7 +86,7 @@ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void>
|
|
|
82
86
|
const profile = await resolve_profile(choices, interaction)
|
|
83
87
|
const { root, options } = choices
|
|
84
88
|
|
|
85
|
-
start_steps.run_steps(steps, { root, profile, visibility: options.visibility })
|
|
89
|
+
await start_steps.run_steps(steps, { root, profile, visibility: options.visibility })
|
|
86
90
|
console.info(`\n${READY_MESSAGE}`)
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { repo_party } from '#scripts/discovery/repo-party'
|
|
5
|
+
import { epic_bundle_cli } from '#scripts/epic/epic-bundle-cli'
|
|
6
|
+
import { git_gh_command } from '#scripts/git/git-gh-command'
|
|
7
|
+
import { git_gh_exec } from '#scripts/git/git-gh-exec'
|
|
8
|
+
import { git_gh_issue_write } from '#scripts/git/git-gh-issue-write'
|
|
9
|
+
import { github_issue_url } from '#scripts/git/github-issue-url'
|
|
10
|
+
import { issue_file, type FileArguments } from './issue-file'
|
|
11
|
+
import { issue_lint_cli } from './issue-lint-cli'
|
|
12
|
+
import { issue_scout_cli } from './issue-scout-cli'
|
|
13
|
+
|
|
14
|
+
// `josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>]…
|
|
15
|
+
// [--repo <owner/repo>] [--distinct <N,…>]` — file an Issue with every filing step run in order
|
|
16
|
+
// (joshuafolkken/kit#2808): the third-party refusal, the body lint, the `## Origin` check for another
|
|
17
|
+
// repository, the duplicate scout, the create call carrying every label, and `epic:bundle` after it.
|
|
18
|
+
// A direct `gh api …/issues` filing is refused by the `direct-filing` delivered rule and pointed here.
|
|
19
|
+
|
|
20
|
+
const SUCCESS_EXIT_CODE = 0
|
|
21
|
+
const FAILURE_EXIT_CODE = 1
|
|
22
|
+
const ARGV_OFFSET = 2
|
|
23
|
+
const USAGE =
|
|
24
|
+
'Usage: josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <tier-a|split|interrupt|review-cap>] [--label <name>]… [--repo <owner/repo>] [--distinct <N,…>]'
|
|
25
|
+
const UNKNOWN_REPO_MESSAGE =
|
|
26
|
+
'Could not read this repository from `git remote`, so the filing has no repository to compare against — check `gh auth status`.'
|
|
27
|
+
const THIRD_PARTY_MESSAGE =
|
|
28
|
+
'✖ third-party target: a tracker we do not own is Tier C — draft the Issue for a person to post instead (`CLAUDE.md` → "Third-party repositories are Tier C").'
|
|
29
|
+
// `GH_REPO` is the variable `gh` reads `{owner}/{repo}` from, so setting it points every listing the
|
|
30
|
+
// scout and `epic:bundle` make at the target repository rather than at this checkout.
|
|
31
|
+
const GH_REPO_VARIABLE = 'GH_REPO'
|
|
32
|
+
|
|
33
|
+
interface Filing {
|
|
34
|
+
args: FileArguments
|
|
35
|
+
body: string
|
|
36
|
+
target: string
|
|
37
|
+
current: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function read_body(path: string): Promise<string | undefined> {
|
|
41
|
+
try {
|
|
42
|
+
return await readFile(path, 'utf8')
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function is_third_party(target: string): boolean {
|
|
49
|
+
const party = repo_party.classify(repo_party.current_owner(), repo_party.target_owner(target))
|
|
50
|
+
|
|
51
|
+
return party === repo_party.THIRD_PARTY
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Every refusal the filing can meet before a request is sent, in the order the steps run — `[]` when
|
|
55
|
+
// the filing may go on to the scout.
|
|
56
|
+
function refusals_of(filing: Filing): ReadonlyArray<string> {
|
|
57
|
+
if (is_third_party(filing.target)) return [THIRD_PARTY_MESSAGE]
|
|
58
|
+
const problems = issue_lint_cli.problems(filing.body).map((problem) => `✖ ${problem}`)
|
|
59
|
+
const origin = issue_file.origin_problem(filing.body, filing.target, filing.current)
|
|
60
|
+
|
|
61
|
+
return origin === undefined ? problems : [...problems, `✖ ${origin}`]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function unacknowledged_message(numbers: ReadonlyArray<number>): string {
|
|
65
|
+
const listed = numbers.map(String).join(',')
|
|
66
|
+
|
|
67
|
+
return `✖ duplicate candidate(s) ${listed} not declared separate — read each (\`issue-fold-existing.md\`); fold into a duplicate, or reissue with \`--distinct ${listed}\` when each is a separate deliverable.`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Set before the scout and left set for `epic:bundle`, so both read the target's backlog.
|
|
71
|
+
function point_listings_at(filing: Filing): void {
|
|
72
|
+
if (issue_file.is_same_repository(filing.target, filing.current)) return
|
|
73
|
+
|
|
74
|
+
process.env[GH_REPO_VARIABLE] = filing.target
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The scout's report is printed whatever it finds, and the filing is held until every candidate it
|
|
78
|
+
// names is declared separate.
|
|
79
|
+
async function is_scout_clear(filing: Filing): Promise<boolean> {
|
|
80
|
+
point_listings_at(filing)
|
|
81
|
+
const outcome = await issue_scout_cli.scout(
|
|
82
|
+
{ title: filing.args.title, body: filing.body },
|
|
83
|
+
filing.target,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if (outcome === undefined) return false
|
|
87
|
+
console.info(outcome.report)
|
|
88
|
+
const open = issue_file.unacknowledged(outcome.candidates, filing.args.distinct)
|
|
89
|
+
|
|
90
|
+
if (open.length > 0) console.error(unacknowledged_message(open))
|
|
91
|
+
|
|
92
|
+
return open.length === 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// `undefined` when the create call failed. `gh`'s standard error is captured into the thrown error
|
|
96
|
+
// rather than printed, so it is printed here — otherwise the filing fails with no reason given.
|
|
97
|
+
async function create(filing: Filing): Promise<string | undefined> {
|
|
98
|
+
const request = git_gh_issue_write.issue_create_request({
|
|
99
|
+
title: filing.args.title,
|
|
100
|
+
body: filing.body,
|
|
101
|
+
labels: issue_file.labels_of(filing.args, filing.body),
|
|
102
|
+
repo: filing.target,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
return await git_gh_exec.exec_gh_api(request)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
109
|
+
|
|
110
|
+
console.error(`✖ the create call failed: ${reason}`)
|
|
111
|
+
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The Issue exists once `create` returns, so a failed placement is reported rather than failed: a
|
|
117
|
+
// non-zero exit would read as "not filed" and invite the second filing the scout exists to stop.
|
|
118
|
+
async function place(url: string, target: string): Promise<void> {
|
|
119
|
+
const issue_number = Number(github_issue_url.parse(url)?.issue_number)
|
|
120
|
+
const exit_code = Number.isSafeInteger(issue_number)
|
|
121
|
+
? await epic_bundle_cli.report_for(issue_number, target)
|
|
122
|
+
: FAILURE_EXIT_CODE
|
|
123
|
+
|
|
124
|
+
if (exit_code !== SUCCESS_EXIT_CODE) {
|
|
125
|
+
console.error(`⚠ epic:bundle did not answer for ${url} — run \`pnpm josh epic:bundle <N>\`.`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function file(filing: Filing): Promise<number> {
|
|
130
|
+
const refusals = refusals_of(filing)
|
|
131
|
+
|
|
132
|
+
if (refusals.length > 0) {
|
|
133
|
+
console.error(refusals.join('\n'))
|
|
134
|
+
|
|
135
|
+
return FAILURE_EXIT_CODE
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!(await is_scout_clear(filing))) return FAILURE_EXIT_CODE
|
|
139
|
+
const url = await create(filing)
|
|
140
|
+
|
|
141
|
+
if (url === undefined) return FAILURE_EXIT_CODE
|
|
142
|
+
console.info(url)
|
|
143
|
+
await place(url, filing.target)
|
|
144
|
+
|
|
145
|
+
return SUCCESS_EXIT_CODE
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function filing_of(args: FileArguments): Promise<Filing | string> {
|
|
149
|
+
const body = await read_body(args.body_file)
|
|
150
|
+
|
|
151
|
+
if (body === undefined) return `Could not read the body file ${args.body_file}.`
|
|
152
|
+
const current = await git_gh_command.repo_get_name_with_owner()
|
|
153
|
+
|
|
154
|
+
if (current === undefined) return UNKNOWN_REPO_MESSAGE
|
|
155
|
+
|
|
156
|
+
return { args, body, target: args.repo ?? current, current }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
160
|
+
const args = issue_file.parse(argv)
|
|
161
|
+
|
|
162
|
+
if (args === undefined) {
|
|
163
|
+
console.error(USAGE)
|
|
164
|
+
|
|
165
|
+
return FAILURE_EXIT_CODE
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const filing = await filing_of(args)
|
|
169
|
+
|
|
170
|
+
if (typeof filing === 'string') {
|
|
171
|
+
console.error(filing)
|
|
172
|
+
|
|
173
|
+
return FAILURE_EXIT_CODE
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return await file(filing)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// `process.exitCode` rather than `process.exit()`, so the report on standard output drains first.
|
|
180
|
+
async function main(argv: ReadonlyArray<string>): Promise<void> {
|
|
181
|
+
process.exitCode = await run(argv)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const issue_file_cli = { USAGE, THIRD_PARTY_MESSAGE, UNKNOWN_REPO_MESSAGE, run, main }
|
|
185
|
+
|
|
186
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
|
|
187
|
+
|
|
188
|
+
export { issue_file_cli }
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util'
|
|
2
|
+
import { epic_issue } from '#scripts/epic/epic-issue'
|
|
3
|
+
import { github_issue_url } from '#scripts/git/github-issue-url'
|
|
4
|
+
import { DEPTH_LABEL_ORDER, FILING_ROUTE_LABELS, has_label_name } from '#scripts/git/issue-labels'
|
|
5
|
+
import { issue_backlinks } from './issue-backlinks'
|
|
6
|
+
import { issue_classification } from './issue-classification'
|
|
7
|
+
import { markdown_section } from './markdown-section'
|
|
8
|
+
|
|
9
|
+
// The decisions behind `josh issue:file` (joshuafolkken/kit#2808), kept apart from the command that
|
|
10
|
+
// reads the network so each one is a pure answer a test can pin. Every filing used to be a hand-built
|
|
11
|
+
// `gh api …/issues` call beside five steps spread over as many documents, and joshuafolkken/kit#2805
|
|
12
|
+
// ran two of them and skipped the lint and the labels. Here the steps are the command's arguments:
|
|
13
|
+
// a label the filing owes is a required option rather than a line to remember.
|
|
14
|
+
|
|
15
|
+
const DEPTH_PREFIX = 'depth:'
|
|
16
|
+
const ROUTE_PREFIX = 'route:'
|
|
17
|
+
const LIST_SEPARATOR = ','
|
|
18
|
+
const WORD_SEPARATOR = /\s+/u
|
|
19
|
+
const CODE_SPAN = /`/gu
|
|
20
|
+
// Prose written against a reference with no space — `(owner/repo#N)`, `owner/repo#N。`, Japanese text
|
|
21
|
+
// after the number — is trimmed off, so the anchored parsers below still see the reference itself.
|
|
22
|
+
const LEADING_NON_WORD = /^[^\w]+/u
|
|
23
|
+
const DIGIT = /\d/u
|
|
24
|
+
|
|
25
|
+
interface FileArguments {
|
|
26
|
+
title: string
|
|
27
|
+
body_file: string
|
|
28
|
+
depth: string
|
|
29
|
+
route: string | undefined
|
|
30
|
+
labels: ReadonlyArray<string>
|
|
31
|
+
repo: string | undefined
|
|
32
|
+
distinct: ReadonlyArray<number>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const OPTIONS = {
|
|
36
|
+
'body-file': { type: 'string' },
|
|
37
|
+
depth: { type: 'string' },
|
|
38
|
+
route: { type: 'string' },
|
|
39
|
+
label: { type: 'string', multiple: true },
|
|
40
|
+
repo: { type: 'string' },
|
|
41
|
+
distinct: { type: 'string', multiple: true },
|
|
42
|
+
} as const
|
|
43
|
+
|
|
44
|
+
// The label a `--depth` / `--route` value names, or `undefined` when it names none — read against the
|
|
45
|
+
// label sets `issue-labels.ts` defines, so the command cannot apply a depth or route nobody provisions.
|
|
46
|
+
function label_of(
|
|
47
|
+
prefix: string,
|
|
48
|
+
value: string | undefined,
|
|
49
|
+
known: ReadonlyArray<string>,
|
|
50
|
+
): string | undefined {
|
|
51
|
+
if (value === undefined) return undefined
|
|
52
|
+
const label = value.startsWith(prefix) ? value : `${prefix}${value}`
|
|
53
|
+
|
|
54
|
+
return known.includes(label) ? label : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ROUTE_NAMES: ReadonlyArray<string> = FILING_ROUTE_LABELS.map((label) => label.name)
|
|
58
|
+
|
|
59
|
+
// `--distinct 12,34 --distinct 56` → `[12, 34, 56]`; `undefined` when any entry is not an issue number,
|
|
60
|
+
// so a typo is refused rather than read as an acknowledgement of nothing.
|
|
61
|
+
function distinct_numbers(values: ReadonlyArray<string>): ReadonlyArray<number> | undefined {
|
|
62
|
+
const numbers = values
|
|
63
|
+
.flatMap((value) => value.split(LIST_SEPARATOR))
|
|
64
|
+
.map((raw) => {
|
|
65
|
+
return Number(raw.trim().replace('#', ''))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return numbers.every((number) => Number.isSafeInteger(number) && number > 0) ? numbers : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type ParsedValues = ReturnType<typeof parseArgs<{ options: typeof OPTIONS }>>['values']
|
|
72
|
+
|
|
73
|
+
function route_of(values: ParsedValues): string | undefined | false {
|
|
74
|
+
if (values.route === undefined) return undefined
|
|
75
|
+
|
|
76
|
+
return label_of(ROUTE_PREFIX, values.route, ROUTE_NAMES) ?? false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function has_text(value: string | undefined): value is string {
|
|
80
|
+
return value !== undefined && value.trim() !== ''
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The labelled half of the arguments, or `undefined` when a depth, route or `--distinct` entry is not
|
|
84
|
+
// one the command knows.
|
|
85
|
+
function labelled_of(
|
|
86
|
+
values: ParsedValues,
|
|
87
|
+
): Pick<FileArguments, 'depth' | 'route' | 'distinct'> | undefined {
|
|
88
|
+
const depth = label_of(DEPTH_PREFIX, values.depth, DEPTH_LABEL_ORDER)
|
|
89
|
+
const route = route_of(values)
|
|
90
|
+
const distinct = distinct_numbers(values.distinct ?? [])
|
|
91
|
+
|
|
92
|
+
if (route === false || depth === undefined || distinct === undefined) return undefined
|
|
93
|
+
|
|
94
|
+
return { depth, route, distinct }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function arguments_of(values: ParsedValues, title: string | undefined): FileArguments | undefined {
|
|
98
|
+
const labelled = labelled_of(values)
|
|
99
|
+
const body_file = values['body-file']
|
|
100
|
+
|
|
101
|
+
if (labelled === undefined || !has_text(title) || !has_text(body_file)) return undefined
|
|
102
|
+
|
|
103
|
+
return { title, body_file, ...labelled, labels: values.label ?? [], repo: values.repo }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// An unknown flag makes `parseArgs` throw; caught so the answer is the usage line, not a stack trace.
|
|
107
|
+
function parse(argv: ReadonlyArray<string>): FileArguments | undefined {
|
|
108
|
+
try {
|
|
109
|
+
const { values, positionals } = parseArgs({
|
|
110
|
+
args: [...argv],
|
|
111
|
+
options: OPTIONS,
|
|
112
|
+
allowPositionals: true,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
return arguments_of(values, positionals[0])
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A classification label is the body's to declare, so an extra `--label` naming one the body does not
|
|
122
|
+
// declare is dropped rather than applied — the label then always matches what `issue:lint` reads.
|
|
123
|
+
function is_undeclared_classification(label: string, body: string): boolean {
|
|
124
|
+
const is_classification = has_label_name(issue_classification.CLASSIFICATION_LABELS, label)
|
|
125
|
+
|
|
126
|
+
return is_classification && !has_label_name(issue_classification.required_labels(body), label)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Every label the filing carries: the depth, the route when there is one, the classification labels
|
|
130
|
+
// the body declares (`issue_classification.labels_for`, which merges case-insensitively), then any
|
|
131
|
+
// extra `--label`. One request applies them all, so no label is a separate step to forget.
|
|
132
|
+
function labels_of(args: FileArguments, body: string): ReadonlyArray<string> {
|
|
133
|
+
const extra = args.labels.filter((label) => !is_undeclared_classification(label, body))
|
|
134
|
+
const declared = [args.depth, ...(args.route === undefined ? [] : [args.route]), ...extra]
|
|
135
|
+
|
|
136
|
+
return issue_classification.labels_for(body, declared)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function is_same_repository(target: string, current: string): boolean {
|
|
140
|
+
return target.toLowerCase() === current.toLowerCase()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// `owner/repo#N` or a github.com issue URL — the qualified forms the issue template's backlink section
|
|
144
|
+
// requires, never a bare `#N`, which resolves to whichever repository renders it. Read through the
|
|
145
|
+
// parsers that already know both forms rather than a third pattern.
|
|
146
|
+
function without_trailing_prose(text: string): string {
|
|
147
|
+
let end = text.length
|
|
148
|
+
|
|
149
|
+
while (end > 0 && !DIGIT.test(text.charAt(end - 1))) end -= 1
|
|
150
|
+
|
|
151
|
+
return text.slice(0, end)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function is_qualified_reference(word: string): boolean {
|
|
155
|
+
const bare = without_trailing_prose(word.replaceAll(CODE_SPAN, '').replace(LEADING_NON_WORD, ''))
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
epic_issue.parse_epic_reference(bare)?.repo !== undefined ||
|
|
159
|
+
github_issue_url.parse(bare) !== undefined
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cites_origin(line: string): boolean {
|
|
164
|
+
return line.split(WORD_SEPARATOR).some((word) => is_qualified_reference(word))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Why a cross-repository filing is refused, or `undefined` when it is not refused. A filing into the
|
|
168
|
+
// session's own repository owes no `## Origin`; one into another repository owes the section with a
|
|
169
|
+
// qualified reference back to the Issue that found it (`issue-template.md`, the backlink section).
|
|
170
|
+
function origin_problem(body: string, target: string, current: string): string | undefined {
|
|
171
|
+
if (is_same_repository(target, current)) return undefined
|
|
172
|
+
const lines = markdown_section.section_lines(body, issue_backlinks.ORIGIN_HEADING)
|
|
173
|
+
|
|
174
|
+
if (lines.some((line) => cites_origin(line))) return undefined
|
|
175
|
+
|
|
176
|
+
return `filing into ${target} from ${current} needs a \`${issue_backlinks.ORIGIN_HEADING}\` section citing the originating Issue as \`owner/repo#N\` or its URL`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The duplicate candidates the caller has not declared separate. The scan cannot tell a duplicate from
|
|
180
|
+
// a neighbor — that is a reading of each candidate (`issue-fold-existing.md`) — so the command holds
|
|
181
|
+
// the filing until every candidate it printed is named back with `--distinct`.
|
|
182
|
+
function unacknowledged(
|
|
183
|
+
candidates: ReadonlyArray<number>,
|
|
184
|
+
distinct: ReadonlyArray<number>,
|
|
185
|
+
): ReadonlyArray<number> {
|
|
186
|
+
return candidates.filter((candidate) => !distinct.includes(candidate))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const issue_file = { parse, labels_of, origin_problem, unacknowledged, is_same_repository }
|
|
190
|
+
|
|
191
|
+
export type { FileArguments }
|
|
192
|
+
export { issue_file }
|
|
@@ -58,7 +58,7 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
|
|
|
58
58
|
process.exitCode = await run(argv[0])
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
const issue_lint_cli = { run, OK_MESSAGE, USAGE }
|
|
61
|
+
const issue_lint_cli = { problems, run, OK_MESSAGE, USAGE }
|
|
62
62
|
|
|
63
63
|
const ARGV_OFFSET = 2
|
|
64
64
|
|
|
@@ -7,18 +7,17 @@ import { epic_bundle, type BacklogIssue, type BundleDecision } from '#scripts/ep
|
|
|
7
7
|
import { epic_bundle_cli } from '#scripts/epic/epic-bundle-cli'
|
|
8
8
|
import { epic_bundle_gaps } from '#scripts/epic/epic-bundle-gaps'
|
|
9
9
|
import { git_gh_command } from '#scripts/git/git-gh-command'
|
|
10
|
-
import { EPIC_LABEL, has_any_label } from '#scripts/git/issue-labels'
|
|
11
|
-
import { PAGE_CEILING_CAUSE } from '#scripts/git/listing-cutoff'
|
|
12
|
-
import { parse_json_array_or_undefined } from '#scripts/git/parse-json-array'
|
|
13
|
-
import { issue_label_schema } from '#scripts/git/schemas'
|
|
14
10
|
import { issue_citation } from '#scripts/rules/issue-citation'
|
|
15
|
-
import { z } from 'zod'
|
|
16
11
|
import {
|
|
17
12
|
issue_scout,
|
|
18
13
|
type DuplicateCandidate,
|
|
19
14
|
type DuplicateSearch,
|
|
20
15
|
type ScoutIssue,
|
|
21
16
|
} from './issue-scout'
|
|
17
|
+
import { issue_scout_closed, type ClosedScan } from './issue-scout-closed'
|
|
18
|
+
|
|
19
|
+
const { CLOSED_CEILING_LINE, CLOSED_UNREADABLE_LINE, read_recently_closed, warn_about_closed } =
|
|
20
|
+
issue_scout_closed
|
|
22
21
|
|
|
23
22
|
// `josh issue:scout "<title>" [--body "<summary>"]` — before an issue is filed, answer the two
|
|
24
23
|
// questions every `new` entry point asks first: has this already been filed, and which epic does it
|
|
@@ -44,87 +43,6 @@ const UNKNOWN_REPO_MESSAGE =
|
|
|
44
43
|
// to be one. Zero is not a valid issue number, so it collides with nothing in the listing.
|
|
45
44
|
const DRAFT_NUMBER = 0
|
|
46
45
|
|
|
47
|
-
// How far back the closed half of the scan reaches (joshuafolkken/kit#1679). One page: the duplicate
|
|
48
|
-
// this half exists to catch closed hours ago, not months, and the listing is ordered by update so
|
|
49
|
-
// the newest hundred already covers a run's own day several times over.
|
|
50
|
-
//
|
|
51
|
-
// **It is a window, not a cut, which is why reaching it is not warned about.** Every other listing in
|
|
52
|
-
// this command reports its `limit` as a gap, because there the caller wanted the whole backlog and
|
|
53
|
-
// got part of it; here the hundredth-most-recently-updated closed issue is where the question itself
|
|
54
|
-
// stops. A warning printed on every invocation in any repository with a hundred closed issues would
|
|
55
|
-
// carry no signal and would train a reader past the gap lines beside it that do. **The page ceiling
|
|
56
|
-
// is still reported**, and `ClosedScan` below says why the two are not the same event.
|
|
57
|
-
const CLOSED_LIMIT = 100
|
|
58
|
-
|
|
59
|
-
// Read through `has_any_label` rather than compared directly, for the casing reason
|
|
60
|
-
// `issue-labels.ts` records: GitHub keeps the spelling a label was created with.
|
|
61
|
-
const EPIC_LABELS: ReadonlySet<string> = new Set([EPIC_LABEL])
|
|
62
|
-
|
|
63
|
-
// The listing's own label shape, not a second spelling of it: a copy here would be the drift the
|
|
64
|
-
// shared schema module exists to prevent.
|
|
65
|
-
const closed_labels_schema = z.array(issue_label_schema).optional()
|
|
66
|
-
|
|
67
|
-
const closed_row_schema = z.object({
|
|
68
|
-
number: z.number(),
|
|
69
|
-
title: z.string().nullable(),
|
|
70
|
-
labels: closed_labels_schema,
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
type ClosedRow = z.infer<typeof closed_row_schema>
|
|
74
|
-
|
|
75
|
-
// `rows` is `undefined` — never `[]` — when the listing failed: "nothing closed recently" is the
|
|
76
|
-
// answer that sends a run straight on to file the duplicate, which is the failure this half was
|
|
77
|
-
// added to prevent.
|
|
78
|
-
function to_closed_row(row: ClosedRow): ScoutIssue {
|
|
79
|
-
return {
|
|
80
|
-
number: row.number,
|
|
81
|
-
title: row.title ?? '',
|
|
82
|
-
is_closed: true,
|
|
83
|
-
// A closed epic is still a container, and `epic_bundle_cli`'s marking cannot reach it: that one
|
|
84
|
-
// is built from the *open* epic listing. Reported as a duplicate it reads as "this work is
|
|
85
|
-
// already done" against an epic that never had an implementation of its own.
|
|
86
|
-
is_epic: has_any_label(row.labels, EPIC_LABELS),
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function to_closed_rows(json: string): Array<ScoutIssue> | undefined {
|
|
91
|
-
const rows = parse_json_array_or_undefined(json, closed_row_schema)
|
|
92
|
-
|
|
93
|
-
return rows?.map((row) => to_closed_row(row))
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// The rows, and whether the **page ceiling** stopped the paging short of the window. That is a
|
|
97
|
-
// different thing from the `limit` above, which is the window itself: pull requests are filtered out
|
|
98
|
-
// client-side, so a repository whose recent closures are mostly merged pull requests can select
|
|
99
|
-
// fewer than `CLOSED_LIMIT` issue rows while the listing still has more to give. The scan then covers
|
|
100
|
-
// less than it was asked for, which `git-gh-issue.ts`'s disposition table records as a warning.
|
|
101
|
-
interface ClosedScan {
|
|
102
|
-
rows: Array<ScoutIssue> | undefined
|
|
103
|
-
is_capped: boolean
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async function read_recently_closed(): Promise<ClosedScan> {
|
|
107
|
-
const { json, is_capped } = await git_gh_command.issue_list_recently_closed(CLOSED_LIMIT)
|
|
108
|
-
|
|
109
|
-
return { rows: json === undefined ? undefined : to_closed_rows(json), is_capped }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// The open half already ran, so both of these are gaps in the answer rather than failures of it —
|
|
113
|
-
// the same disposition every other truncated listing in this command takes.
|
|
114
|
-
const CLOSED_UNREADABLE_LINE =
|
|
115
|
-
'⚠ The recently closed issues could not be read, so the scan below covers open issues only — work that finished hours ago will not appear in it.'
|
|
116
|
-
const CLOSED_CEILING_LINE = `⚠ The recently closed listing ${PAGE_CEILING_CAUSE}, so a duplicate that closed inside the window may not be below.`
|
|
117
|
-
|
|
118
|
-
function warn_about_closed(scan: ClosedScan): void {
|
|
119
|
-
if (scan.rows === undefined) {
|
|
120
|
-
console.error(CLOSED_UNREADABLE_LINE)
|
|
121
|
-
|
|
122
|
-
return
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (scan.is_capped) console.error(CLOSED_CEILING_LINE)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
46
|
interface ScoutArguments {
|
|
129
47
|
title: string
|
|
130
48
|
body: string
|
|
@@ -333,28 +251,32 @@ function format_epic_answer(
|
|
|
333
251
|
// are still being worked on, and a closed one can neither gain a sibling nor be recommended as an
|
|
334
252
|
// epic — so widening the placement pool with them would answer a different question from the one it
|
|
335
253
|
// was asked (joshuafolkken/kit#1679).
|
|
254
|
+
function duplicates_of(
|
|
255
|
+
draft: BacklogIssue,
|
|
256
|
+
issues: ReadonlyArray<BacklogIssue>,
|
|
257
|
+
closed: ReadonlyArray<ScoutIssue>,
|
|
258
|
+
): DuplicateSearch {
|
|
259
|
+
const references = epic_audit_logic.parse_references(draft.body, draft.repo)
|
|
260
|
+
|
|
261
|
+
return issue_scout.find_duplicates(draft.title ?? '', [...issues, ...closed], references)
|
|
262
|
+
}
|
|
263
|
+
|
|
336
264
|
function format_report(
|
|
337
265
|
draft: BacklogIssue,
|
|
338
266
|
issues: ReadonlyArray<BacklogIssue>,
|
|
339
267
|
is_membership_established: boolean,
|
|
340
268
|
closed: ReadonlyArray<ScoutIssue> = [],
|
|
341
269
|
): string {
|
|
342
|
-
const references = epic_audit_logic.parse_references(draft.body, draft.repo)
|
|
343
|
-
const duplicates = issue_scout.find_duplicates(
|
|
344
|
-
draft.title ?? '',
|
|
345
|
-
[...issues, ...closed],
|
|
346
|
-
references,
|
|
347
|
-
)
|
|
348
270
|
const decision = epic_bundle.decide_bundle(draft, issues)
|
|
349
271
|
|
|
350
272
|
return [
|
|
351
|
-
format_duplicates(
|
|
273
|
+
format_duplicates(duplicates_of(draft, issues, closed)),
|
|
352
274
|
format_epic_answer(draft, decision, issues, is_membership_established),
|
|
353
275
|
].join('\n')
|
|
354
276
|
}
|
|
355
277
|
|
|
356
278
|
type Widened = Awaited<ReturnType<typeof epic_bundle_cli.widen_with_referenced>>
|
|
357
|
-
type ClosedListing =
|
|
279
|
+
type ClosedListing = ClosedScan
|
|
358
280
|
|
|
359
281
|
function is_duplicate_scan_incomplete(widened: Widened, closed: ClosedListing): boolean {
|
|
360
282
|
const has_open_gap = widened.cutoff !== undefined && widened.cutoff !== 'none'
|
|
@@ -397,16 +319,23 @@ function linkified_report(
|
|
|
397
319
|
return issue_citation.linkify(qualified, repo)
|
|
398
320
|
}
|
|
399
321
|
|
|
322
|
+
// The scan's answer in both shapes it is read in: the report a person reads, and the candidate numbers
|
|
323
|
+
// `issue:file` holds a filing to (joshuafolkken/kit#2808).
|
|
324
|
+
interface ScoutOutcome {
|
|
325
|
+
report: string
|
|
326
|
+
candidates: ReadonlyArray<number>
|
|
327
|
+
}
|
|
328
|
+
|
|
400
329
|
// The backlog is read without its `blocked-by` relations: a draft has no number, so no recorded
|
|
401
330
|
// dependency can name it and it declares none — the reads cannot change either half of the answer,
|
|
402
331
|
// and skipping them takes one request per open issue off the command a run makes before every filing.
|
|
403
|
-
async function
|
|
332
|
+
async function scout(args: ScoutArguments, repo: string): Promise<ScoutOutcome | undefined> {
|
|
404
333
|
const backlog = await epic_bundle_cli.read_backlog(repo, { include_relations: false })
|
|
405
334
|
|
|
406
335
|
if (!backlog.is_readable) {
|
|
407
336
|
console.error(epic_bundle_cli.unreadable_backlog_message(backlog))
|
|
408
337
|
|
|
409
|
-
return
|
|
338
|
+
return undefined
|
|
410
339
|
}
|
|
411
340
|
|
|
412
341
|
const draft = draft_of(args, repo)
|
|
@@ -420,9 +349,22 @@ async function report(args: ScoutArguments, repo: string): Promise<number> {
|
|
|
420
349
|
|
|
421
350
|
epic_bundle_cli.warn_about_gaps(widened)
|
|
422
351
|
warn_about_closed(closed)
|
|
352
|
+
const duplicates = duplicates_of(draft, widened.issues, closed.rows ?? [])
|
|
353
|
+
|
|
423
354
|
// The same cut `warn_about_gaps` reports on standard error, read once more so standard output is
|
|
424
355
|
// held to it too — a ⚠ beside an executable instruction is not what stops a run acting on it.
|
|
425
|
-
|
|
356
|
+
return {
|
|
357
|
+
report: linkified_report(draft, widened, closed, repo),
|
|
358
|
+
candidates: duplicates.candidates.map((candidate) => candidate.number),
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function report(args: ScoutArguments, repo: string): Promise<number> {
|
|
363
|
+
const outcome = await scout(args, repo)
|
|
364
|
+
|
|
365
|
+
if (outcome === undefined) return FAILURE_EXIT_CODE
|
|
366
|
+
|
|
367
|
+
console.info(outcome.report)
|
|
426
368
|
|
|
427
369
|
return SUCCESS_EXIT_CODE
|
|
428
370
|
}
|
|
@@ -473,10 +415,12 @@ const issue_scout_cli = {
|
|
|
473
415
|
format_duplicates,
|
|
474
416
|
format_epic_decision,
|
|
475
417
|
format_report,
|
|
418
|
+
scout,
|
|
476
419
|
run,
|
|
477
420
|
main,
|
|
478
421
|
}
|
|
479
422
|
|
|
480
423
|
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
|
|
481
424
|
|
|
425
|
+
export type { ScoutArguments, ScoutOutcome }
|
|
482
426
|
export { issue_scout_cli }
|