@joshuafolkken/kit 1.978.0 → 1.983.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/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/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-ZPLXLJ7O.js → chunk-47SPMGOD.js} +16 -1
- package/dist/hooks/{chunk-BVMSPG3M.js → chunk-5JX6HXDX.js} +3 -3
- package/dist/hooks/{chunk-KQG4776P.js → chunk-APVOACVT.js} +1 -1
- package/dist/hooks/{chunk-2K52BRWV.js → chunk-ASUZGZFT.js} +2 -2
- package/dist/hooks/{chunk-CRGJTD6E.js → chunk-ERXMJJOV.js} +1 -1
- package/dist/hooks/{chunk-Q7LE77CO.js → chunk-IM3KM25Y.js} +31 -29
- package/dist/hooks/{chunk-BNGGEY3Z.js → chunk-LBXQHXMC.js} +129 -342
- package/dist/hooks/{chunk-NXL3SBXY.js → chunk-P3UX7SFN.js} +2 -2
- package/dist/hooks/{chunk-F6OM6SIG.js → chunk-RFPHKU7C.js} +12 -12
- package/dist/hooks/{chunk-ZYYSBNR6.js → chunk-ZT65LR3U.js} +4 -4
- 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 +4 -4
- package/dist/josh.js +16 -1
- package/package.json +1 -1
- 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/scripts/delegation/delegation-policy.ts +9 -9
- package/scripts/document/document-byte-budget.ts +1 -1
- 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 +9 -1
- package/scripts/josh/josh-command-map.ts +1 -0
- package/scripts/josh/josh-commands-issue.ts +11 -0
- package/scripts/josh/josh-commands-project.ts +5 -1
- 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 +11 -8
- package/scripts/rules/issue-fold.ts +4 -4
- 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 +3 -2
- package/scripts/test/pilot-files.ts +0 -1
- package/scripts/version/fresh-metadata-cache.ts +36 -0
- package/scripts/version/latest-update.ts +2 -1
- 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
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
1. **現在の作業を退避する**: `git stash push -u -m "upstream-interrupt: paused #<N>"`(WIP コミットでも可)。退避したことを忘れないよう、この時点で stash を作る。メッセージ付きで積むのは、復元をメッセージで特定するため(下記ステップ 5)
|
|
18
18
|
2. **進行中の Issue に状況を明記する**: `pnpm josh issue:comment <N> --body-file <path>` で、(a) 作業を stash したこと、(b) 中断理由(どの別パッケージの・どんな問題で中断したか)、(c) 対象パッケージに作成した新 Issue へのリンクを `## Upstream issues` 見出しの下に `owner/repo#N` 形式で、を記載する。これにより「なぜこの Issue が一時停止しているか」と「何を待っているのか」が後から監査できる(→「起票元へのバックリンク」)
|
|
19
|
-
3. **対象パッケージのリポジトリに新しい Issue を作成する(確認なし)**: `
|
|
19
|
+
3. **対象パッケージのリポジトリに新しい Issue を作成する(確認なし)**: `pnpm josh issue:file "<root-cause title>" --body-file <body-file> --depth <n> --route tier-a --repo <owner>/<repo>`(`route:tier-a` は実装中の Tier A 起票を経路として集計するためのラベル — joshuafolkken/kit#1083。付与するのは first-party の場合だけで、third-party はコマンド自身が拒否し起票しない)。本文の検査、本文が宣言する分類ラベルの付与、`## Origin` 節の確認、起票先での重複探し、`epic:bundle` はこのコマンドが実行する(`docs/josh-commands.md` → `josh issue:file`)。分類の単一ソースは `prompts/collaboration-workflow/issue-template.md`。根本原因・再現・期待結果を Step 1 のテンプレに沿って記載し、**本文に `## Origin` 節を置いて起票元 Issue を `owner/repo#N` 形式で書く**(→「起票元へのバックリンク」)。上流 Issue は欠陥を、起票元 Issue は証拠を持つため、リンクがないと上流 Issue は後から解釈できなくなる。ここで「起票してよいか」を尋ねて停止してはならない。**ただし確認なしで起票してよいのは対象が first-party のときだけ**で、third-party(owner が自リポジトリと一致しないリポジトリ)なら起票せずに停止する(→「第三者リポジトリへの書き込みは Tier C(明示指示が必要)」)
|
|
20
20
|
4. **`confirmation` Telegram を送って停止する**: 上流 Issue の URL と、何が止まっているかを本文に書く(→「確認待ちで停止するときの Telegram 通知(`confirmation`)」)。無人実行でも画面外でユーザーが気付ける。停止は Issue が既に存在する状態で行うので、ユーザーは「待つ / 先送りする」を Issue を見ながら 1 語で答えられる
|
|
21
21
|
5. **元の作業を再開する**: 上流の修正がマージされた、または**ユーザーが先送りを明示判断した**後に `pnpm josh stash:pop "upstream-interrupt: paused #<N>"`(位置指定や引数なしの `git stash pop` は使わない — stash は全 work tree が共有する 1 本のスタックで、別のレーンの stash を取り込む恐れがある)して、退避していた元タスクを続行する。上流 Issue を割り込みで実装するかどうかもユーザーの判断(上流パッケージの実装・PR・マージはそれぞれのワークフロー規則に従う)
|
|
22
22
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**単一ソースはこのファイルである。**
|
|
4
4
|
|
|
5
|
-
**この規則は常駐していない — 引き金つき配送に移った**(joshuafolkken/kit#1524)。`pnpm josh rule:guard` が Issue を作成する `Bash` 呼び出し(`gh issue create
|
|
5
|
+
**この規則は常駐していない — 引き金つき配送に移った**(joshuafolkken/kit#1524)。`pnpm josh rule:guard` が Issue を作成する `Bash` 呼び出し(`pnpm josh issue:file`。`gh issue create` や `…/issues` への `title` 付き POST による直接起票は `direct-filing` が毎回拒否する)を拒否し、そこで数え方・拒否・2 つの免除・免除を決める 3 条件を突きつける。起票が無いターンでは何も起きず、それは上限に触れる行為が無いということである。配送は**ラン 1 回につき 1 度**なので、数えたうえで同じ呼び出しをもう一度出せばよい。機構と列挙表は `rule-delivery.md`、配送文の実体は `scripts/rules/delivered-rules.ts` の `WIP_CAP_REASON` にあり、`scripts/backlog/backlog-manufacturing-rule.test.ts` がその中身を固定している。
|
|
6
6
|
|
|
7
7
|
## 規則
|
|
8
8
|
|
|
@@ -69,10 +69,10 @@ gh api "repos/{owner}/{repo}/issues?state=open&per_page=100" --paginate \
|
|
|
69
69
|
|
|
70
70
|
#### 該当したときの手順
|
|
71
71
|
|
|
72
|
-
1. **上限に関係なく起票する。** 1
|
|
72
|
+
1. **上限に関係なく起票する。** 1 件閉じることを条件にしない。起票は `pnpm josh issue:file` で行う。本文の検査、本文が宣言する分類ラベルの付与、重複探し、`epic:bundle` はこのコマンドが実行する(`docs/josh-commands.md` → `josh issue:file`)。
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
|
-
|
|
75
|
+
pnpm josh issue:file "<title>" --body-file <body-file> --depth <n> --route interrupt
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
2. **超過している事実と、当たった条件の番号を本文に 1 行書く**(例:「オープン 37 件で起票。3 条件のうち 2(文書化された手順が完了できない)に該当」)。**どちらか一方では足りない** — 件数だけでは判定が再現できず、条件だけでは上限の可視化が効かない。
|
package/prompts/review.md
CHANGED
|
@@ -183,7 +183,7 @@ Only branch 2 files an Issue. What follows applies to that branch.
|
|
|
183
183
|
than being deferred — see below.)
|
|
184
184
|
- **Filing does not end at the Issue.** `epic:next` only ever offers a child the task list of an epic
|
|
185
185
|
names, so an Issue in no epic is never handed to a running `backlogrun` — the deferred finding is parked
|
|
186
|
-
forever, which reads the same from the backlog. **The
|
|
186
|
+
forever, which reads the same from the backlog. **The steps run inside the CI wait, not before
|
|
187
187
|
the commit.** Where the run opens a pull request — `fullrun`, `backlogrun` — they go after
|
|
188
188
|
`pnpm josh git -y` and before `pnpm josh followup`; where it does not — `halfrun`, or a standalone
|
|
189
189
|
pre-commit self-review — they run as soon as the disposition is decided. **The chain may run in a
|
|
@@ -191,14 +191,16 @@ Only branch 2 files an Issue. What follows applies to that branch.
|
|
|
191
191
|
- **When the round files a second follow-up, run `pnpm josh issue:fold` first.** Several findings from
|
|
192
192
|
one review fold into one Issue by default — the filing-time counterpart to the split assessment,
|
|
193
193
|
reading the same two questions (`.claude/skills/workflow-commands/split-assessment.md` → "The same
|
|
194
|
-
two questions decide the filing-time fold"). `pnpm josh rule:guard` refuses the second
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
1. File the follow-up Issue referencing the current one, tagged `route:review-cap
|
|
198
|
-
`
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
194
|
+
two questions decide the filing-time fold"). `pnpm josh rule:guard` refuses the second
|
|
195
|
+
`pnpm josh issue:file` call until it is folded; the first filing asks nothing.
|
|
196
|
+
|
|
197
|
+
1. File the follow-up Issue referencing the current one, tagged `route:review-cap` — **before the current Issue closes.**
|
|
198
|
+
`pnpm josh issue:file "<title>" --body-file <body-file> --depth <n> --route review-cap`.
|
|
199
|
+
The command lints the body, applies the classification labels it declares, runs the duplicate scan
|
|
200
|
+
and then `epic:bundle` on the new Issue (`docs/josh-commands.md` → `josh issue:file`). The
|
|
201
|
+
`epic:bundle` candidate search reads open issues only, so once the parent has closed it answers
|
|
202
|
+
`none` permanently.
|
|
203
|
+
2. Act on the `epic:bundle` answer it prints. **`epic:bundle` recommends and writes nothing**, so acting means running the
|
|
202
204
|
write command yourself — never a hand edit of the epic body, which leaves the task list and the
|
|
203
205
|
`blocked-by` relations disagreeing and `epic:next` returning `error`.
|
|
204
206
|
|
|
@@ -53,16 +53,16 @@ const DELEGATABLE_STEPS: ReadonlyArray<DelegatableStep> = [
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
name: 'followup-filing',
|
|
56
|
-
// The late-run follow-up filing chain — issue:
|
|
57
|
-
// — is a dependency chain `batch:guard`
|
|
58
|
-
//
|
|
59
|
-
// (joshuafolkken/kit#1892). A fresh unit runs the same
|
|
60
|
-
// round cap already composed the finding text, so what
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
does: 'run the late-run follow-up filing chain in an isolated unit — issue:scout,
|
|
56
|
+
// The late-run follow-up filing chain — `issue:file` (which scouts, lints, labels and runs
|
|
57
|
+
// epic:bundle itself, joshuafolkken/kit#2808) → epic --add — is a dependency chain `batch:guard`
|
|
58
|
+
// correctly keeps one call per step, and what makes it expensive is the ~340k context it runs in
|
|
59
|
+
// at the tail of a run, not the step count (joshuafolkken/kit#1892). A fresh unit runs the same
|
|
60
|
+
// chain at a small context, and the review round cap already composed the finding text, so what
|
|
61
|
+
// the unit is handed is mechanical: run the commands and act on their deterministic answers. This
|
|
62
|
+
// is not `batch:guard`'s business — delegation only moves the chain to a cheaper context.
|
|
63
|
+
does: 'run the late-run follow-up filing chain in an isolated unit — issue:file (scout, lint, labels, epic:bundle), then epic --add — given the finding text the parent already composed, and return the new Issue number and the epic:bundle answer',
|
|
64
64
|
verifier:
|
|
65
|
-
"the parent reads the filed Issue from GitHub with `pnpm josh issue:state <new>`, not the unit's summary; a follow-up reported filed but not created is still absent, the way `epic-child`'s reported-done-but-not-merged child is still open. The finding text came from the parent and `issue:
|
|
65
|
+
"the parent reads the filed Issue from GitHub with `pnpm josh issue:state <new>`, not the unit's summary; a follow-up reported filed but not created is still absent, the way `epic-child`'s reported-done-but-not-merged child is still open. The finding text came from the parent and `issue:file`'s steps are deterministic, so the unit's work is the mechanical execution `issue:state` confirms",
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
name: 'implementation-unit',
|
|
@@ -82,7 +82,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
|
|
|
82
82
|
{ path: '.claude/skills/workflow-commands/rule-residency.md', bytes: 24_576 },
|
|
83
83
|
{ path: '.claude/skills/workflow-commands/target-repository.md', bytes: 8192 },
|
|
84
84
|
{ path: '.claude/skills/workflow-commands/working-tree-hold.md', bytes: 8192 },
|
|
85
|
-
{ path: 'docs/josh-commands.md', bytes:
|
|
85
|
+
{ path: 'docs/josh-commands.md', bytes: 176_128 },
|
|
86
86
|
{ path: 'prompts/agent-rules.md', bytes: 4096 },
|
|
87
87
|
{ path: 'prompts/coding-standards.md', bytes: 16_384 },
|
|
88
88
|
{ path: 'prompts/collaboration-workflow.md', bytes: 8192 },
|
|
@@ -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
|
|