@joshuafolkken/kit 1.972.0 → 1.978.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.md +1 -0
- package/.claude/skills/workflow-commands/halfrun.md +12 -7
- package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
- package/README.md +24 -20
- package/dist/hooks/{chunk-BNWIOJFU.js → chunk-BNGGEY3Z.js} +10 -17
- package/dist/hooks/{chunk-WWTMHHZU.js → chunk-Q7LE77CO.js} +30 -1
- package/dist/hooks/codex-hook-adapter.js +2 -2
- package/dist/hooks/pretool-guard.js +2 -2
- package/dist/hooks/stop-guard.js +1 -1
- package/package.json +2 -2
- package/scripts/ci/pr-classification.ts +13 -12
- package/scripts/git/issue-labels.ts +52 -5
- package/scripts/init/init-bootstrap.ts +92 -0
- package/scripts/init/init-install.ts +4 -1
- package/scripts/init/init-logic-workspace.ts +1 -1
- package/scripts/init/init.ts +23 -5
- package/scripts/init/start-plan.ts +1 -1
- package/scripts/init/start-steps.ts +5 -28
- package/scripts/repo/repository-labels.ts +91 -0
- package/scripts/run/run-entry-cli.ts +34 -1
- package/scripts/run/run-halfrun-resume.ts +104 -0
- package/scripts/run/run-hold-cli.ts +27 -4
- package/scripts/run/run-hold.ts +11 -0
- package/scripts/security/security-audit.ts +12 -1
- package/scripts/sync/sync.ts +6 -0
|
@@ -63,6 +63,7 @@ call reports `hold`.
|
|
|
63
63
|
boundary — immediately after `pnpm josh main:merge`, before the gate — it takes the cut with
|
|
64
64
|
`pnpm josh run:cut <N>` (the ordered step in `chain-rule.md`), and it records any park on the Issue before the stop notify.
|
|
65
65
|
`pre-gate-cut.md` is the single source of both.
|
|
66
|
+
- **`resume: halfrun`** adopted a stopped `halfrun` (#2796): gate in full; `working-tree-hold.md`.
|
|
66
67
|
|
|
67
68
|
## The stop branches
|
|
68
69
|
|
|
@@ -41,12 +41,16 @@ josh latest:scope` and update dependencies only on `required` (`latest-gate.md`;
|
|
|
41
41
|
start `pnpm josh gate` beside a subagent `/code-review` with the brief `pnpm josh review:brief` prints,
|
|
42
42
|
join the gate before the stop, iterate to no high/medium findings, at most two reviews → `pnpm josh
|
|
43
43
|
test:e2e`, run by **you**, because `halfrun` opens no pull request and there is no CI E2E job; a printed
|
|
44
|
-
skip is the answer where the project has no suite) →
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
skip is the answer where the project has no suite) → **mark the stop: `pnpm josh run:hold <N>
|
|
45
|
+
--halfrun-stop`** (the record `fullrun #<N>` adopts; `new` passes the filed number) → send a
|
|
46
|
+
`confirmation` Telegram with the resume commands in the body → **stop**. Plan comments are in the session language. The `confirmation` Telegram
|
|
47
|
+
body MUST include the exact resume commands — **`fullrun #<N>` first**, since that is how a verified
|
|
48
|
+
`halfrun` ships (joshuafolkken/kit#2796): its `run:entry` adopts this stop's marked hold and resumes at
|
|
49
|
+
the gate. The direct commands follow for shipping without an agent:
|
|
50
|
+
`pnpm josh notify --task-type confirmation --issue-url "<issue-url>" --body=$'halfrun ready for manual
|
|
51
|
+
verification\nNext: fullrun #<N>\nWithout an agent: pnpm josh git -y "<title> #<N>" && pnpm josh
|
|
52
|
+
followup "<title> #<N>" --notify-message "Implemented <title>\\nCause: ...\\nFix: ...\\nResult:
|
|
53
|
+
...\\n\\nDetails:\\n- <change1>"'`. The stop report names the same two, in the same order.
|
|
50
54
|
**Invoking `halfrun` is _not_ authorization to commit, push, or merge** — do not run `pnpm josh git -y` or `pnpm josh followup` yourself. If the user comes
|
|
51
55
|
back with fixes, treat each as a new round: implement, re-run `pnpm josh gate`, send another
|
|
52
56
|
`confirmation` Telegram, stop again.
|
|
@@ -59,7 +63,8 @@ body per `prompts/collaboration-workflow/issue-template.md`) → add `in-progres
|
|
|
59
63
|
any pre-existing changes with `git stash push -m "halfrun new: pre-existing changes"`, popped by
|
|
60
64
|
message with `pnpm josh stash:pop "halfrun new: pre-existing changes"`, never a positional `git stash pop` →
|
|
61
65
|
`git switch main && git pull` → `pnpm josh latest:scope` → implement → run the gate (as above, `pnpm
|
|
62
|
-
josh test:e2e` run by **you**) → send the `confirmation`
|
|
66
|
+
josh test:e2e` run by **you**) → `pnpm josh run:hold <N> --halfrun-stop` → send the `confirmation`
|
|
67
|
+
Telegram and **stop**.
|
|
63
68
|
|
|
64
69
|
## The stop branches
|
|
65
70
|
|
|
@@ -55,7 +55,8 @@ clean releases it explicitly**: a `fullrun` / `halfrun` that stops on a split, a
|
|
|
55
55
|
third-party target ends with `pnpm josh run:release <N>` (bare where that run entered as `new`).
|
|
56
56
|
**`halfrun`'s stop before commit keeps the hold**, and so does a `needs-human-review` stop: the
|
|
57
57
|
uncommitted work still in the tree is exactly what a second run would trample, so the release command
|
|
58
|
-
goes in the stop report and the Telegram for the person to type.
|
|
58
|
+
goes in the stop report and the Telegram for the person to type. **`fullrun #<N>` of the same issue
|
|
59
|
+
adopts a `halfrun`'s kept hold** rather than being refused `busy` by it — "The halfrun resume" below. **An expired record over a tree that
|
|
59
60
|
still has uncommitted changes does not free it**: the command answers `busy` and says to commit, stash,
|
|
60
61
|
or release once the work is done; only an expired record over a clean tree is replaced.
|
|
61
62
|
|
|
@@ -64,3 +65,17 @@ call it themselves; each child runs the `fullrun` procedure, so it claims on ent
|
|
|
64
65
|
followup` releases it at that child's merge. The command's behavior and the answer table are
|
|
65
66
|
`docs/josh-commands.md` → "`josh run:hold` / `josh run:release`"; this file is the single source of
|
|
66
67
|
the procedure.
|
|
68
|
+
|
|
69
|
+
## The halfrun resume
|
|
70
|
+
|
|
71
|
+
**`fullrun #<N>` after a `halfrun` stop resumes it instead of claiming** (joshuafolkken/kit#2796). The
|
|
72
|
+
stop is **recorded, never inferred**: the `halfrun` ends with `pnpm josh run:hold <N> --halfrun-stop`,
|
|
73
|
+
which marks its own record (re-keying a `halfrun new`'s unnumbered one to the filed issue) — a
|
|
74
|
+
`halfrun` still implementing or a `backlogrun` child leaves the same hold over the same dirty tree, and
|
|
75
|
+
must not be adopted. On a marked hold for `#<N>` over a dirty tree, `run:entry` asks the session budget
|
|
76
|
+
(`over` stops as usual), adopts the hold with the `fullrun` mark and prints `entry #<N> — resume:
|
|
77
|
+
halfrun`. **Skip the title, the plan, the split assessment, `git switch main && git pull`,
|
|
78
|
+
`latest:scope` and the implementation**: the diff in the tree is what the person verified. Re-read the
|
|
79
|
+
issue (`pnpm josh issue:read <N>`), then run the gate **in full** from the refactor (`chain-rule.md` →
|
|
80
|
+
"Run the review-to-merge chain") — a fix made during the manual check has had no gate — and ship as
|
|
81
|
+
any `fullrun` does.
|
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# @joshuafolkken/kit
|
|
2
2
|
|
|
3
|
-
A development toolkit
|
|
3
|
+
A development toolkit for AI-assisted work. `josh init` gives an AI assistant (Claude Code, Codex, Gemini, Cursor) the project's rules, formatting and checks in one command. With `kickoff`, `fullrun` and `backlogrun`, the agent then takes a GitHub Issue through plan → implement → verify → PR → merge, the same way in every project. Why kit exists: [why.md](./docs/why.md) (Japanese).
|
|
4
4
|
|
|
5
5
|
## What your project gets
|
|
6
6
|
|
|
7
|
-
kit
|
|
7
|
+
kit picks a [profile](./docs/init.md#project-profiles) from one question — does the project use Node tooling?
|
|
8
8
|
|
|
9
9
|
| Your project | Profile | What you get |
|
|
10
10
|
| ---------------------------------------- | -------- | ----------------------------------------------------------------- |
|
|
@@ -12,54 +12,58 @@ kit does not branch by language. It asks one question — does the project use N
|
|
|
12
12
|
| HTML / CSS (browser JavaScript included) | `static` | Formatting (Prettier), AI assistant files, Git / VS Code settings |
|
|
13
13
|
| Another language (Python, Rust, Go, …) | `static` | AI assistant files and Git / VS Code settings only |
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
In a `static` project, `josh` skips each check that has nothing to run and says why.
|
|
16
16
|
|
|
17
17
|
## Quick start
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Requires [Node.js](https://nodejs.org/) 22.19.0+ and [pnpm](https://pnpm.io/) for every profile ([getting-started.md](./docs/getting-started.md)); the [gh CLI](https://cli.github.com/) for `josh version` and the Issue workflow is required by `josh start` and optional otherwise. Run in your project directory.
|
|
20
|
+
|
|
21
|
+
### The project is already on GitHub, or will not use GitHub
|
|
20
22
|
|
|
21
23
|
```bash
|
|
22
|
-
pnpm
|
|
23
|
-
pnpm exec josh init
|
|
24
|
+
pnpm --allow-build=esbuild dlx @joshuafolkken/kit init
|
|
24
25
|
pnpm josh gate
|
|
25
26
|
```
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
### The project has no GitHub repository yet, and will use the Issue workflow
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
```bash
|
|
31
|
+
pnpm add -D --allow-build=esbuild --allow-build=unrs-resolver @joshuafolkken/kit
|
|
32
|
+
pnpm exec josh start
|
|
33
|
+
```
|
|
30
34
|
|
|
31
|
-
|
|
35
|
+
`josh start` also runs `git init` when needed and creates and pushes the GitHub repository. Details: [init.md → `josh init` or `josh start`](./docs/init.md#josh-init-or-josh-start).
|
|
32
36
|
|
|
33
37
|
## Documentation
|
|
34
38
|
|
|
35
39
|
**Get started**
|
|
36
40
|
|
|
37
|
-
- [getting-started.md](./docs/getting-started.md) — first install for
|
|
38
|
-
- [package.md](./docs/package.md) — add kit to a Node project
|
|
41
|
+
- [getting-started.md](./docs/getting-started.md) — first install for a `static` project
|
|
42
|
+
- [package.md](./docs/package.md) — add kit to a Node project
|
|
39
43
|
- [tutorial.md](./docs/tutorial.md) — your first change with an agent, from Issue to merge
|
|
40
|
-
- [how-to.md](./docs/how-to.md) —
|
|
44
|
+
- [how-to.md](./docs/how-to.md) — guides by task
|
|
41
45
|
- [overview.md](./docs/overview.md) — what kit sets up and how it works
|
|
42
46
|
|
|
43
47
|
**Commands and configuration**
|
|
44
48
|
|
|
45
|
-
- [cli.md](./docs/cli.md) —
|
|
49
|
+
- [cli.md](./docs/cli.md) — the global `josh` command
|
|
46
50
|
- [josh-commands.md](./docs/josh-commands.md) — every `josh` command ([catalog](./docs/josh-command-catalog.md))
|
|
47
|
-
- [init.md](./docs/init.md) — what `josh init` creates
|
|
48
|
-
- [sync.md](./docs/sync.md) — what `josh sync` updates
|
|
49
|
-
- [manual-config.md](./docs/manual-config.md) —
|
|
50
|
-
- [package-api.md](./docs/package-api.md) — the
|
|
51
|
+
- [init.md](./docs/init.md) — what `josh init` creates
|
|
52
|
+
- [sync.md](./docs/sync.md) — what `josh sync` updates
|
|
53
|
+
- [manual-config.md](./docs/manual-config.md) — the presets without `josh init`
|
|
54
|
+
- [package-api.md](./docs/package-api.md) — the package's exports
|
|
51
55
|
|
|
52
56
|
**AI workflow and operations**
|
|
53
57
|
|
|
54
|
-
- [scripts-ai.md](./docs/scripts-ai.md) —
|
|
58
|
+
- [scripts-ai.md](./docs/scripts-ai.md) — Issue workflow commands and Telegram notifications
|
|
55
59
|
- [cloud-session.md](./docs/cloud-session.md) — running in an agent container
|
|
56
|
-
- [eval.md](./docs/eval.md) — measuring
|
|
60
|
+
- [eval.md](./docs/eval.md) — measuring rule adherence
|
|
57
61
|
- [troubleshooting.md](./docs/troubleshooting.md) — install and auth errors
|
|
58
62
|
- [authentication.md](./docs/authentication.md) — existing GitHub Packages installs only
|
|
59
63
|
|
|
60
64
|
## Contributing
|
|
61
65
|
|
|
62
|
-
|
|
66
|
+
Conventions are in [CLAUDE.md](./CLAUDE.md), releases in [publishing.md](./docs/publishing.md). See [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) and [SECURITY.md](./SECURITY.md); maintainer records are in [docs/maintainers/](./docs/maintainers/).
|
|
63
67
|
|
|
64
68
|
## License
|
|
65
69
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ALREADY_DONE_LABEL,
|
|
3
3
|
BREAKING_CHANGE_LABEL,
|
|
4
|
+
BUGFIX_LABEL,
|
|
4
5
|
BUG_LABEL,
|
|
5
6
|
CONTEXT_CUT_THRESHOLD,
|
|
6
7
|
ENHANCEMENT_LABEL,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
NEEDS_DECISION_LABEL,
|
|
10
11
|
NEEDS_HUMAN_REVIEW_LABEL,
|
|
11
12
|
RECENT_REQUEST_WINDOW,
|
|
13
|
+
RELEASE_CLASSIFICATION_NAMES,
|
|
12
14
|
agent_role_profile,
|
|
13
15
|
agent_session_environment,
|
|
14
16
|
bash_triggers,
|
|
@@ -35,7 +37,7 @@ import {
|
|
|
35
37
|
run_watcher_guard,
|
|
36
38
|
shell_segments,
|
|
37
39
|
tail_commands
|
|
38
|
-
} from "./chunk-
|
|
40
|
+
} from "./chunk-Q7LE77CO.js";
|
|
39
41
|
import {
|
|
40
42
|
find_local_bin_upwards
|
|
41
43
|
} from "./chunk-3V37IID6.js";
|
|
@@ -3590,16 +3592,7 @@ import { readFileSync as readFileSync6 } from "node:fs";
|
|
|
3590
3592
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
3591
3593
|
import { execaSync } from "execa";
|
|
3592
3594
|
import { z as z8 } from "zod";
|
|
3593
|
-
var
|
|
3594
|
-
var BREAKING = "breaking-change";
|
|
3595
|
-
var CLASSIFICATION_LABELS2 = [
|
|
3596
|
-
BREAKING,
|
|
3597
|
-
"enhancement",
|
|
3598
|
-
BUGFIX,
|
|
3599
|
-
"other-change",
|
|
3600
|
-
"ignore-for-release"
|
|
3601
|
-
];
|
|
3602
|
-
var CLASSIFICATION_SET = new Set(CLASSIFICATION_LABELS2);
|
|
3595
|
+
var CLASSIFICATION_SET = new Set(RELEASE_CLASSIFICATION_NAMES);
|
|
3603
3596
|
var ISSUE_BUG_LABEL = "bug";
|
|
3604
3597
|
var DECLARATION = /^- リリース分類: (.+)$/gmu;
|
|
3605
3598
|
var LEGACY_LABELS = /* @__PURE__ */ new Set(["semver-major", "semver-minor"]);
|
|
@@ -3618,14 +3611,14 @@ function classification_error(labels, author) {
|
|
|
3618
3611
|
return `Choose exactly one release classification; found: ${selected.join(", ")}`;
|
|
3619
3612
|
}
|
|
3620
3613
|
if (selected.length === 1 || author.endsWith("[bot]")) return void 0;
|
|
3621
|
-
return `Choose one release classification: ${
|
|
3614
|
+
return `Choose one release classification: ${RELEASE_CLASSIFICATION_NAMES.join(", ")}`;
|
|
3622
3615
|
}
|
|
3623
3616
|
function is_classification(value) {
|
|
3624
3617
|
return CLASSIFICATION_SET.has(value);
|
|
3625
3618
|
}
|
|
3626
3619
|
function release_candidates(candidates) {
|
|
3627
|
-
if (!candidates.includes(
|
|
3628
|
-
return candidates.filter((candidate) => candidate !==
|
|
3620
|
+
if (!candidates.includes(BREAKING_CHANGE_LABEL)) return candidates;
|
|
3621
|
+
return candidates.filter((candidate) => candidate !== ENHANCEMENT_LABEL);
|
|
3629
3622
|
}
|
|
3630
3623
|
function issue_candidates(issue_json) {
|
|
3631
3624
|
const issue = z8.object({ labels: z8.array(LABEL), body: z8.string().nullable() }).parse(JSON.parse(issue_json));
|
|
@@ -3634,8 +3627,8 @@ function issue_candidates(issue_json) {
|
|
|
3634
3627
|
const candidates = [...release_candidates([...names, ...declared].filter(is_classification))];
|
|
3635
3628
|
const unknown = declared.filter((value) => !is_classification(value));
|
|
3636
3629
|
if (unknown.length > 0) throw new Error(`Unknown release classification: ${unknown.join(", ")}`);
|
|
3637
|
-
if (names.includes(ISSUE_BUG_LABEL) && !candidates.includes(
|
|
3638
|
-
candidates.push(
|
|
3630
|
+
if (names.includes(ISSUE_BUG_LABEL) && !candidates.includes(BREAKING_CHANGE_LABEL)) {
|
|
3631
|
+
candidates.push(BUGFIX_LABEL);
|
|
3639
3632
|
}
|
|
3640
3633
|
return candidates;
|
|
3641
3634
|
}
|
|
@@ -3645,7 +3638,7 @@ function select_issue_classification(issue_json) {
|
|
|
3645
3638
|
const [label] = selected;
|
|
3646
3639
|
if (label === void 0 || selected.length > 1) {
|
|
3647
3640
|
throw new Error(
|
|
3648
|
-
`Choose one release classification before opening the PR: ${
|
|
3641
|
+
`Choose one release classification before opening the PR: ${RELEASE_CLASSIFICATION_NAMES.join(", ")}; found: ${selected.join(", ") || "none"}. Add "- \u30EA\u30EA\u30FC\u30B9\u5206\u985E: <label>" to the Issue body.`
|
|
3649
3642
|
);
|
|
3650
3643
|
}
|
|
3651
3644
|
return label;
|
|
@@ -547,7 +547,8 @@ var run_hold_schema = z.object({
|
|
|
547
547
|
issue: z.string(),
|
|
548
548
|
taken_at: z.string(),
|
|
549
549
|
pid: z.number(),
|
|
550
|
-
is_fullrun: z.boolean().optional()
|
|
550
|
+
is_fullrun: z.boolean().optional(),
|
|
551
|
+
is_halfrun_stop: z.boolean().optional()
|
|
551
552
|
});
|
|
552
553
|
function parse_hold(raw) {
|
|
553
554
|
try {
|
|
@@ -582,6 +583,9 @@ function write_hold(target, issue, now = /* @__PURE__ */ new Date()) {
|
|
|
582
583
|
function create_hold(target, issue, now = /* @__PURE__ */ new Date(), is_fullrun) {
|
|
583
584
|
return stamp_file.create_stamp(target, build_hold(issue, now, is_fullrun));
|
|
584
585
|
}
|
|
586
|
+
function create_halfrun_stop_hold(target, issue, now = /* @__PURE__ */ new Date()) {
|
|
587
|
+
return stamp_file.create_stamp(target, { ...build_hold(issue, now), is_halfrun_stop: true });
|
|
588
|
+
}
|
|
585
589
|
async function is_tree_dirty() {
|
|
586
590
|
try {
|
|
587
591
|
const status2 = await git_command.status();
|
|
@@ -635,6 +639,7 @@ var run_hold = {
|
|
|
635
639
|
RELEASE_COMMAND,
|
|
636
640
|
UNNUMBERED_ISSUE,
|
|
637
641
|
classify,
|
|
642
|
+
create_halfrun_stop_hold,
|
|
638
643
|
create_hold,
|
|
639
644
|
describe_holder,
|
|
640
645
|
foreign_release_message,
|
|
@@ -1732,6 +1737,28 @@ var WORKFLOW_LABELS = [
|
|
|
1732
1737
|
...FILING_ROUTE_LABELS,
|
|
1733
1738
|
...DEPTH_LABELS
|
|
1734
1739
|
];
|
|
1740
|
+
var BUGFIX_LABEL = "bugfix";
|
|
1741
|
+
var OTHER_CHANGE_LABEL = "other-change";
|
|
1742
|
+
var IGNORE_FOR_RELEASE_LABEL = "ignore-for-release";
|
|
1743
|
+
var RELEASE_CLASSIFICATION_NAMES = [
|
|
1744
|
+
BREAKING_CHANGE_LABEL,
|
|
1745
|
+
ENHANCEMENT_LABEL,
|
|
1746
|
+
BUGFIX_LABEL,
|
|
1747
|
+
OTHER_CHANGE_LABEL,
|
|
1748
|
+
IGNORE_FOR_RELEASE_LABEL
|
|
1749
|
+
];
|
|
1750
|
+
var RELEASE_CLASSIFICATION_METADATA = {
|
|
1751
|
+
[BREAKING_CHANGE_LABEL]: { color: "d93f0b", description: "Release: a breaking change (major)" },
|
|
1752
|
+
[ENHANCEMENT_LABEL]: { color: "84b6eb", description: "Release: a new feature (minor)" },
|
|
1753
|
+
[BUGFIX_LABEL]: { color: "d73a4a", description: "Release: a bug fix (patch)" },
|
|
1754
|
+
[OTHER_CHANGE_LABEL]: { color: "c2e0c6", description: "Release: any other change (patch)" },
|
|
1755
|
+
[IGNORE_FOR_RELEASE_LABEL]: { color: "cfd3d7", description: "Release: left out of the notes" }
|
|
1756
|
+
};
|
|
1757
|
+
var RELEASE_CLASSIFICATION_LABELS = RELEASE_CLASSIFICATION_NAMES.map((name) => ({ name, ...RELEASE_CLASSIFICATION_METADATA[name] }));
|
|
1758
|
+
var REPOSITORY_LABELS = [
|
|
1759
|
+
...WORKFLOW_LABELS,
|
|
1760
|
+
...RELEASE_CLASSIFICATION_LABELS
|
|
1761
|
+
];
|
|
1735
1762
|
function has_any_label(labels, wanted) {
|
|
1736
1763
|
return (labels ?? []).some((label) => wanted.has(label.name.toLowerCase()));
|
|
1737
1764
|
}
|
|
@@ -3624,6 +3651,8 @@ export {
|
|
|
3624
3651
|
RUN_LANE_LABEL,
|
|
3625
3652
|
NEEDS_HUMAN_REVIEW_LABEL,
|
|
3626
3653
|
ALREADY_DONE_LABEL,
|
|
3654
|
+
BUGFIX_LABEL,
|
|
3655
|
+
RELEASE_CLASSIFICATION_NAMES,
|
|
3627
3656
|
has_any_label,
|
|
3628
3657
|
has_label_name,
|
|
3629
3658
|
run_tail,
|
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
pretool_guard,
|
|
4
4
|
pretool_outcome,
|
|
5
5
|
pretool_outcome_async
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
6
|
+
} from "./chunk-BNGGEY3Z.js";
|
|
7
|
+
import "./chunk-Q7LE77CO.js";
|
|
8
8
|
import "./chunk-3V37IID6.js";
|
|
9
9
|
import "./chunk-NXL3SBXY.js";
|
|
10
10
|
import "./chunk-BVMSPG3M.js";
|
package/dist/hooks/stop-guard.js
CHANGED
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
2
|
+
"version": "1.978.0",
|
|
3
3
|
"name": "@joshuafolkken/kit",
|
|
4
4
|
"description": "Development toolkit for AI-assisted projects: formatting, AI assistant rules and a Git/GitHub Issue workflow for any repository, plus lint, type check, tests and hooks for Node projects",
|
|
5
5
|
"keywords": [
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
194
194
|
"@types/node": "^26.6.3",
|
|
195
195
|
"@types/semver": "^7.8.0",
|
|
196
|
-
"cspell": "^10.3.
|
|
196
|
+
"cspell": "^10.3.6",
|
|
197
197
|
"esbuild": "^0.28.2",
|
|
198
198
|
"eslint": "^10.11.0",
|
|
199
199
|
"eslint-config-prettier": "^10.1.8",
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import {
|
|
4
|
+
BREAKING_CHANGE_LABEL as BREAKING,
|
|
5
|
+
BUGFIX_LABEL as BUGFIX,
|
|
6
|
+
RELEASE_CLASSIFICATION_NAMES as CLASSIFICATION_LABELS,
|
|
7
|
+
ENHANCEMENT_LABEL,
|
|
8
|
+
type ReleaseClassification,
|
|
9
|
+
} from '#scripts/git/issue-labels'
|
|
3
10
|
import { execaSync } from 'execa'
|
|
4
11
|
import { z } from 'zod'
|
|
5
12
|
|
|
6
|
-
const BUGFIX = 'bugfix'
|
|
7
|
-
const BREAKING = 'breaking-change'
|
|
8
|
-
const CLASSIFICATION_LABELS = [
|
|
9
|
-
BREAKING,
|
|
10
|
-
'enhancement',
|
|
11
|
-
BUGFIX,
|
|
12
|
-
'other-change',
|
|
13
|
-
'ignore-for-release',
|
|
14
|
-
] as const
|
|
15
|
-
type ReleaseClassification = (typeof CLASSIFICATION_LABELS)[number]
|
|
16
13
|
const CLASSIFICATION_SET: ReadonlySet<string> = new Set(CLASSIFICATION_LABELS)
|
|
17
14
|
const ISSUE_BUG_LABEL = 'bug'
|
|
18
15
|
const DECLARATION = /^- リリース分類: (.+)$/gmu
|
|
@@ -50,7 +47,7 @@ function release_candidates(
|
|
|
50
47
|
): ReadonlyArray<ReleaseClassification> {
|
|
51
48
|
if (!candidates.includes(BREAKING)) return candidates
|
|
52
49
|
|
|
53
|
-
return candidates.filter((candidate) => candidate !==
|
|
50
|
+
return candidates.filter((candidate) => candidate !== ENHANCEMENT_LABEL)
|
|
54
51
|
}
|
|
55
52
|
|
|
56
53
|
function issue_candidates(issue_json: string): ReadonlyArray<ReleaseClassification> {
|
|
@@ -132,4 +129,8 @@ const pr_classification = {
|
|
|
132
129
|
select_issue_classification,
|
|
133
130
|
}
|
|
134
131
|
|
|
135
|
-
export { pr_classification
|
|
132
|
+
export { pr_classification }
|
|
133
|
+
export {
|
|
134
|
+
RELEASE_CLASSIFICATION_NAMES as CLASSIFICATION_LABELS,
|
|
135
|
+
type ReleaseClassification,
|
|
136
|
+
} from '#scripts/git/issue-labels'
|
|
@@ -186,14 +186,16 @@ const DEPTH_LABELS: ReadonlyArray<{
|
|
|
186
186
|
},
|
|
187
187
|
]
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
// applies with its intended color (joshuafolkken/kit#2197). The `epic` and `in-progress` metadata is
|
|
191
|
-
// the one the workflow documents create them with; the labels only a person applies are left out.
|
|
192
|
-
const WORKFLOW_LABELS: ReadonlyArray<{
|
|
189
|
+
interface LabelDefinition {
|
|
193
190
|
name: string
|
|
194
191
|
color: string
|
|
195
192
|
description: string
|
|
196
|
-
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// What `josh start` provisions on a new repository so the first `kickoff` finds every label a run
|
|
196
|
+
// applies with its intended color (joshuafolkken/kit#2197). The `epic` and `in-progress` metadata is
|
|
197
|
+
// the one the workflow documents create them with; the labels only a person applies are left out.
|
|
198
|
+
const WORKFLOW_LABELS: ReadonlyArray<LabelDefinition> = [
|
|
197
199
|
{
|
|
198
200
|
name: EPIC_LABEL,
|
|
199
201
|
color: '5319e7',
|
|
@@ -214,6 +216,41 @@ const WORKFLOW_LABELS: ReadonlyArray<{
|
|
|
214
216
|
...DEPTH_LABELS,
|
|
215
217
|
]
|
|
216
218
|
|
|
219
|
+
// The pull request release classifications `pr-classification.yml` requires exactly one of
|
|
220
|
+
// (joshuafolkken/kit#2797). The check is distributed by `josh sync`, so the labels it asks for are
|
|
221
|
+
// provisioned from this same list — a repository that never had them would otherwise fail the check
|
|
222
|
+
// on every pull request that is not an `enhancement`.
|
|
223
|
+
const BUGFIX_LABEL = 'bugfix'
|
|
224
|
+
const OTHER_CHANGE_LABEL = 'other-change'
|
|
225
|
+
const IGNORE_FOR_RELEASE_LABEL = 'ignore-for-release'
|
|
226
|
+
const RELEASE_CLASSIFICATION_NAMES = [
|
|
227
|
+
BREAKING_CHANGE_LABEL,
|
|
228
|
+
ENHANCEMENT_LABEL,
|
|
229
|
+
BUGFIX_LABEL,
|
|
230
|
+
OTHER_CHANGE_LABEL,
|
|
231
|
+
IGNORE_FOR_RELEASE_LABEL,
|
|
232
|
+
] as const
|
|
233
|
+
type ReleaseClassification = (typeof RELEASE_CLASSIFICATION_NAMES)[number]
|
|
234
|
+
|
|
235
|
+
const RELEASE_CLASSIFICATION_METADATA: Readonly<
|
|
236
|
+
Record<ReleaseClassification, Omit<LabelDefinition, 'name'>>
|
|
237
|
+
> = {
|
|
238
|
+
[BREAKING_CHANGE_LABEL]: { color: 'd93f0b', description: 'Release: a breaking change (major)' },
|
|
239
|
+
[ENHANCEMENT_LABEL]: { color: '84b6eb', description: 'Release: a new feature (minor)' },
|
|
240
|
+
[BUGFIX_LABEL]: { color: 'd73a4a', description: 'Release: a bug fix (patch)' },
|
|
241
|
+
[OTHER_CHANGE_LABEL]: { color: 'c2e0c6', description: 'Release: any other change (patch)' },
|
|
242
|
+
[IGNORE_FOR_RELEASE_LABEL]: { color: 'cfd3d7', description: 'Release: left out of the notes' },
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const RELEASE_CLASSIFICATION_LABELS: ReadonlyArray<LabelDefinition> =
|
|
246
|
+
RELEASE_CLASSIFICATION_NAMES.map((name) => ({ name, ...RELEASE_CLASSIFICATION_METADATA[name] }))
|
|
247
|
+
|
|
248
|
+
// Every label kit provisions on a repository — `josh start` on a new one, `josh sync` on every run.
|
|
249
|
+
const REPOSITORY_LABELS: ReadonlyArray<LabelDefinition> = [
|
|
250
|
+
...WORKFLOW_LABELS,
|
|
251
|
+
...RELEASE_CLASSIFICATION_LABELS,
|
|
252
|
+
]
|
|
253
|
+
|
|
217
254
|
// The shape `gh issue list --json labels` returns; narrowed here so the predicate below takes any
|
|
218
255
|
// listing row without importing a schema.
|
|
219
256
|
interface LabelReference {
|
|
@@ -295,13 +332,18 @@ const ALL_LABELS: ReadonlySet<string> = new Set([
|
|
|
295
332
|
DEPTH_0_LABEL,
|
|
296
333
|
DEPTH_1_LABEL,
|
|
297
334
|
DEPTH_2_LABEL,
|
|
335
|
+
BUGFIX_LABEL,
|
|
336
|
+
OTHER_CHANGE_LABEL,
|
|
337
|
+
IGNORE_FOR_RELEASE_LABEL,
|
|
298
338
|
])
|
|
299
339
|
|
|
340
|
+
export type { LabelDefinition, ReleaseClassification }
|
|
300
341
|
export {
|
|
301
342
|
ALL_LABELS,
|
|
302
343
|
ALREADY_DONE_LABEL,
|
|
303
344
|
AUTO_OK_LABEL,
|
|
304
345
|
BUG_LABEL,
|
|
346
|
+
BUGFIX_LABEL,
|
|
305
347
|
BREAKING_CHANGE_LABEL,
|
|
306
348
|
depth_label_of,
|
|
307
349
|
DEPTH_0_LABEL,
|
|
@@ -314,12 +356,17 @@ export {
|
|
|
314
356
|
FILING_ROUTE_LABELS,
|
|
315
357
|
has_any_label,
|
|
316
358
|
has_label_name,
|
|
359
|
+
IGNORE_FOR_RELEASE_LABEL,
|
|
317
360
|
IN_PROGRESS_LABEL,
|
|
318
361
|
INTERRUPT_ROUTE_LABEL,
|
|
319
362
|
label_name_of,
|
|
320
363
|
NEEDS_DECISION_LABEL,
|
|
321
364
|
NEEDS_HUMAN_REVIEW_LABEL,
|
|
322
365
|
NOT_DIRECTLY_RUNNABLE_LABELS,
|
|
366
|
+
OTHER_CHANGE_LABEL,
|
|
367
|
+
RELEASE_CLASSIFICATION_LABELS,
|
|
368
|
+
RELEASE_CLASSIFICATION_NAMES,
|
|
369
|
+
REPOSITORY_LABELS,
|
|
323
370
|
REVIEW_CAP_ROUTE_LABEL,
|
|
324
371
|
REVIEW_ROUND2_SKIPPED_LABEL,
|
|
325
372
|
RUN_LANE_LABEL,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { init_install, type InstallStep } from './init-install'
|
|
5
|
+
import { init_logic_workspace } from './init-logic-workspace'
|
|
6
|
+
|
|
7
|
+
const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
|
|
8
|
+
const NODE_MODULES = 'node_modules'
|
|
9
|
+
const PACKAGE_JSON = 'package.json'
|
|
10
|
+
// The static template lists exactly the build scripts kit's own dependency tree carries, which is
|
|
11
|
+
// what installing kit alone needs approved (joshuafolkken/kit#2785).
|
|
12
|
+
const KIT_BUILDS_TEMPLATE = 'templates/pnpm-workspace.static.yaml'
|
|
13
|
+
const HANDOFF_FAILURE = 'josh init from the project-installed kit failed — see the output above'
|
|
14
|
+
// Set on the handed-off run, so a kit that still does not find itself installed in the project — pnpm
|
|
15
|
+
// put it somewhere else — stops instead of installing and handing off again without end.
|
|
16
|
+
const HANDOFF_ENV = 'JOSH_INIT_HANDED_OFF'
|
|
17
|
+
const REPEATED_HANDOFF = `the kit this project installed is not at ${NODE_MODULES}/${KIT_PACKAGE_NAME} — run \`pnpm exec josh init\` where it is`
|
|
18
|
+
|
|
19
|
+
const dependency_fields_schema = z.looseObject({
|
|
20
|
+
dependencies: z.record(z.string(), z.string()).optional(),
|
|
21
|
+
devDependencies: z.record(z.string(), z.string()).optional(),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function real_directory(directory: string): string | undefined {
|
|
25
|
+
return existsSync(directory) ? realpathSync(directory) : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A `pnpm dlx` (or global) run executes whatever kit its cache held, which may be days behind the
|
|
29
|
+
// registry — and `init` pins the kit it runs as, so a stale cache used to leave its version and its
|
|
30
|
+
// templates in the project for good (joshuafolkken/kit#2794). Only the kit the project itself installs
|
|
31
|
+
// does the setup. Compared by real path, so a global virtual store shared with the project still
|
|
32
|
+
// reads as the project's own kit.
|
|
33
|
+
function is_project_kit(package_directory: string, project_root: string): boolean {
|
|
34
|
+
const installed = real_directory(path.join(project_root, NODE_MODULES, KIT_PACKAGE_NAME))
|
|
35
|
+
|
|
36
|
+
return installed !== undefined && installed === real_directory(package_directory)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function kit_build_flags(template: string): Array<string> {
|
|
40
|
+
return [...init_logic_workspace.template_build_values(template)]
|
|
41
|
+
.filter(([, value]) => value === 'true')
|
|
42
|
+
.map(([name]) => `--allow-build=${name}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function has_kit_dependency(project_root: string): boolean {
|
|
46
|
+
const manifest_path = path.join(project_root, PACKAGE_JSON)
|
|
47
|
+
if (!existsSync(manifest_path)) return false
|
|
48
|
+
const parsed: unknown = JSON.parse(readFileSync(manifest_path, 'utf8'))
|
|
49
|
+
const manifest = dependency_fields_schema.parse(parsed)
|
|
50
|
+
const names = Object.keys({ ...manifest.dependencies, ...manifest.devDependencies })
|
|
51
|
+
|
|
52
|
+
return names.includes(KIT_PACKAGE_NAME)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A project that already lists kit keeps the version it chose; otherwise pnpm picks it, under the
|
|
56
|
+
// project's own `minimumReleaseAge`, so the version installed is never the cache's decision.
|
|
57
|
+
function kit_install_step(has_kit: boolean, template: string): InstallStep {
|
|
58
|
+
if (has_kit) return { label: 'pnpm install', command: 'pnpm', args: ['install'] }
|
|
59
|
+
const args = ['add', '-D', ...kit_build_flags(template), KIT_PACKAGE_NAME]
|
|
60
|
+
|
|
61
|
+
return { label: `pnpm ${args.join(' ')}`, command: 'pnpm', args }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function handoff_step(args: ReadonlyArray<string>): InstallStep {
|
|
65
|
+
const handoff_args = ['exec', 'josh', 'init', ...args]
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
label: `pnpm ${handoff_args.join(' ')}`,
|
|
69
|
+
command: 'pnpm',
|
|
70
|
+
args: handoff_args,
|
|
71
|
+
env: { [HANDOFF_ENV]: '1' },
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Returns the failure to report, or undefined once the project-installed kit finished the setup.
|
|
76
|
+
function hand_off(
|
|
77
|
+
package_directory: string,
|
|
78
|
+
project_root: string,
|
|
79
|
+
args: ReadonlyArray<string>,
|
|
80
|
+
): string | undefined {
|
|
81
|
+
if (process.env[HANDOFF_ENV] !== undefined) return REPEATED_HANDOFF
|
|
82
|
+
console.info('\n🚀 Installing @joshuafolkken/kit into the project, then running its josh init')
|
|
83
|
+
const template = readFileSync(path.join(package_directory, KIT_BUILDS_TEMPLATE), 'utf8')
|
|
84
|
+
const install = kit_install_step(has_kit_dependency(project_root), template)
|
|
85
|
+
if (!init_install.did_step_succeed(install, project_root)) return `${install.label} failed`
|
|
86
|
+
if (!init_install.did_step_succeed(handoff_step(args), project_root)) return HANDOFF_FAILURE
|
|
87
|
+
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const init_bootstrap = { is_project_kit, kit_build_flags, hand_off }
|
|
92
|
+
export { init_bootstrap }
|
|
@@ -4,6 +4,7 @@ interface InstallStep {
|
|
|
4
4
|
label: string
|
|
5
5
|
command: string
|
|
6
6
|
args: ReadonlyArray<string>
|
|
7
|
+
env?: Readonly<Record<string, string>>
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
interface InstallRequest {
|
|
@@ -40,6 +41,7 @@ function did_step_succeed(step: InstallStep, project_root: string): boolean {
|
|
|
40
41
|
console.info(`\n$ ${step.label}`)
|
|
41
42
|
const result = execaSync(step.command, step.args, {
|
|
42
43
|
cwd: project_root,
|
|
44
|
+
env: step.env ?? {},
|
|
43
45
|
stdio: 'inherit',
|
|
44
46
|
reject: false,
|
|
45
47
|
})
|
|
@@ -60,5 +62,6 @@ function run_post_init_steps(project_root: string): string | undefined {
|
|
|
60
62
|
return undefined
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
const init_install = { NO_INSTALL_FLAG, split_install_flag, run_post_init_steps }
|
|
65
|
+
const init_install = { NO_INSTALL_FLAG, split_install_flag, did_step_succeed, run_post_init_steps }
|
|
64
66
|
export { init_install }
|
|
67
|
+
export type { InstallStep }
|
|
@@ -138,6 +138,6 @@ function merge_workspace_yaml(existing: string, template: string): string {
|
|
|
138
138
|
return append_user_blocks(cleaned, new_keys, template)
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
const init_logic_workspace = { merge_workspace_yaml }
|
|
141
|
+
const init_logic_workspace = { merge_workspace_yaml, template_build_values }
|
|
142
142
|
|
|
143
143
|
export { init_logic_workspace }
|
package/scripts/init/init.ts
CHANGED
|
@@ -15,12 +15,13 @@ import { execaSync } from 'execa'
|
|
|
15
15
|
import { z } from 'zod'
|
|
16
16
|
import { init_actions, PRETTIER_CONFIG_JS, type FileAction } from './init-actions'
|
|
17
17
|
import { init_ai_copy } from './init-ai-copy'
|
|
18
|
+
import { init_bootstrap } from './init-bootstrap'
|
|
18
19
|
import { init_install } from './init-install'
|
|
19
20
|
import { init_logic } from './init-logic'
|
|
20
21
|
import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
|
|
21
22
|
import { init_static } from './init-static'
|
|
22
23
|
import { plugin_install_hint_module } from './plugin-install-hint'
|
|
23
|
-
import { project_profile, type ProjectShape } from './project-profile'
|
|
24
|
+
import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
|
|
24
25
|
|
|
25
26
|
const PACKAGE_JSON = 'package.json'
|
|
26
27
|
const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
|
|
@@ -320,10 +321,18 @@ function finish_dependencies(shape: ProjectShape, is_install: boolean): void {
|
|
|
320
321
|
if (failure !== undefined) throw new Error(failure)
|
|
321
322
|
}
|
|
322
323
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
324
|
+
// A kit run from outside the project — `pnpm dlx`, a global install — sets nothing up itself: it
|
|
325
|
+
// installs the project's kit and hands the whole run to that one (joshuafolkken/kit#2794). Under
|
|
326
|
+
// `--no-install` nothing is installed, so the running kit does the setup as before.
|
|
327
|
+
function did_hand_off(args: ReadonlyArray<string>): boolean {
|
|
328
|
+
if (init_bootstrap.is_project_kit(PACKAGE_DIR, PROJECT_ROOT)) return false
|
|
329
|
+
const failure = init_bootstrap.hand_off(PACKAGE_DIR, PROJECT_ROOT, args)
|
|
330
|
+
if (failure !== undefined) throw new Error(failure)
|
|
331
|
+
|
|
332
|
+
return true
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function set_up_project(requested: ProjectProfile | undefined, is_install: boolean): void {
|
|
327
336
|
const shape = project_profile.inspect_project(PROJECT_ROOT, requested)
|
|
328
337
|
|
|
329
338
|
initialize_project(shape)
|
|
@@ -331,6 +340,15 @@ function main(args: ReadonlyArray<string> = []): void {
|
|
|
331
340
|
console.info('\n✅ Done.\n')
|
|
332
341
|
}
|
|
333
342
|
|
|
343
|
+
function main(args: ReadonlyArray<string> = []): void {
|
|
344
|
+
if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return
|
|
345
|
+
const { is_install, rest } = init_install.split_install_flag(args)
|
|
346
|
+
// Validated before the hand-off, so a malformed invocation is refused before kit is installed.
|
|
347
|
+
const requested = project_profile.requested_profile(rest, INIT_USAGE)
|
|
348
|
+
|
|
349
|
+
if (!is_install || !did_hand_off(args)) set_up_project(requested, is_install)
|
|
350
|
+
}
|
|
351
|
+
|
|
334
352
|
function run_cli(args: ReadonlyArray<string>): void {
|
|
335
353
|
try {
|
|
336
354
|
main(args)
|
|
@@ -46,7 +46,7 @@ const STEP_LABELS: Readonly<Record<StartStep, string>> = {
|
|
|
46
46
|
initialize: 'Initialize kit (the same setup as josh init)',
|
|
47
47
|
commit: 'Commit every file as the initial commit',
|
|
48
48
|
repository: 'Create the GitHub repository and push main',
|
|
49
|
-
labels: 'Add the workflow labels the repository is missing',
|
|
49
|
+
labels: 'Add the workflow and release-classification labels the repository is missing',
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function refused(refusal: string): StepPlan {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { git_gh_exec } from '#scripts/git/git-gh-exec'
|
|
3
|
-
import {
|
|
3
|
+
import { repository_labels } from '#scripts/repo/repository-labels'
|
|
4
4
|
import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
|
|
5
5
|
import { execaSync } from 'execa'
|
|
6
6
|
import { main as init_main } from './init'
|
|
@@ -14,10 +14,7 @@ interface StepContext {
|
|
|
14
14
|
visibility: Visibility
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
type WorkflowLabel = (typeof WORKFLOW_LABELS)[number]
|
|
18
|
-
|
|
19
17
|
const DEFAULT_BRANCH = 'main'
|
|
20
|
-
const LABELS_ENDPOINT = 'repos/{owner}/{repo}/labels'
|
|
21
18
|
const GH_INSTALL_HINT =
|
|
22
19
|
'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, run gh auth login, then run josh start again. Nothing was changed.'
|
|
23
20
|
const GH_AUTH_HINT =
|
|
@@ -100,29 +97,10 @@ function create_github_repository(context: StepContext): void {
|
|
|
100
97
|
run('gh', ['repo', 'create', name, visibility, ...source], context.root)
|
|
101
98
|
}
|
|
102
99
|
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return WORKFLOW_LABELS.filter((label) => !names.has(label.name))
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function create_label(label: WorkflowLabel, root: string): void {
|
|
112
|
-
const fields = [`name=${label.name}`, `color=${label.color}`, `description=${label.description}`]
|
|
113
|
-
|
|
114
|
-
run('gh', ['api', LABELS_ENDPOINT, ...fields.flatMap((field) => ['-f', field]), '--silent'], root)
|
|
115
|
-
console.info(` ✔ label ${label.name}`)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function add_missing_labels(context: StepContext): void {
|
|
119
|
-
const listing = execaSync('gh', ['api', '--paginate', LABELS_ENDPOINT, '--jq', '.[].name'], {
|
|
120
|
-
...git_gh_exec.direct_environment(),
|
|
121
|
-
cwd: context.root,
|
|
122
|
-
})
|
|
123
|
-
const existing = listing.stdout.split('\n').filter((name) => name.length > 0)
|
|
124
|
-
|
|
125
|
-
for (const label of missing_workflow_labels(existing)) create_label(label, context.root)
|
|
100
|
+
// The repository was created by the step before, so `gh` resolves the placeholder from the
|
|
101
|
+
// directory `josh start` runs in, which is the root.
|
|
102
|
+
function add_missing_labels(): void {
|
|
103
|
+
repository_labels.ensure_labels('{owner}/{repo}')
|
|
126
104
|
}
|
|
127
105
|
|
|
128
106
|
const STEP_ACTIONS: Readonly<Record<StartStep, (context: StepContext) => void>> = {
|
|
@@ -160,7 +138,6 @@ const start_steps = {
|
|
|
160
138
|
read_git_state,
|
|
161
139
|
github_cli_refusal,
|
|
162
140
|
self_run_refusal,
|
|
163
|
-
missing_workflow_labels,
|
|
164
141
|
run_steps,
|
|
165
142
|
}
|
|
166
143
|
export { start_steps }
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { git_gh_exec } from '#scripts/git/git-gh-exec'
|
|
2
|
+
import { REPOSITORY_LABELS, type LabelDefinition } from '#scripts/git/issue-labels'
|
|
3
|
+
|
|
4
|
+
// Creates the labels kit's workflow and the distributed `pr-classification.yml` rely on, and only
|
|
5
|
+
// those the repository is missing (joshuafolkken/kit#2797). The one place labels are created: `josh
|
|
6
|
+
// start` provisions a new repository through it and `josh sync` brings every existing one up to date.
|
|
7
|
+
//
|
|
8
|
+
// Labels already present are left as they are, so a repository that recolored one keeps its choice
|
|
9
|
+
// and a second run changes nothing. GitHub compares label names case-insensitively.
|
|
10
|
+
//
|
|
11
|
+
// **Never throws.** A token without the scope or no network must not stop a sync halfway; the names
|
|
12
|
+
// still missing are printed instead, so the person can create them by hand.
|
|
13
|
+
|
|
14
|
+
function labels_endpoint(repo: string): string {
|
|
15
|
+
return `repos/${repo}/labels`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A failed request is an answer to report, never an exception to propagate (see above).
|
|
19
|
+
function request(api: Parameters<typeof git_gh_exec.exec_gh_api_sync>[0]): string | undefined {
|
|
20
|
+
try {
|
|
21
|
+
return git_gh_exec.exec_gh_api_sync(api)
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function read_existing(repo: string): ReadonlyArray<string> | undefined {
|
|
28
|
+
const path = labels_endpoint(repo)
|
|
29
|
+
const listing = request({ path, should_paginate: true, jq_filter: '.[].name' })
|
|
30
|
+
|
|
31
|
+
return listing?.split('\n').filter((name) => name.length > 0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function missing_labels(existing: ReadonlyArray<string>): ReadonlyArray<LabelDefinition> {
|
|
35
|
+
const names = new Set(existing.map((name) => name.toLowerCase()))
|
|
36
|
+
|
|
37
|
+
return REPOSITORY_LABELS.filter((label) => !names.has(label.name.toLowerCase()))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function create_label(label: LabelDefinition, repo: string): boolean {
|
|
41
|
+
const body = JSON.stringify(label)
|
|
42
|
+
const is_created = request({ path: labels_endpoint(repo), method: 'POST', body }) !== undefined
|
|
43
|
+
|
|
44
|
+
if (is_created) console.info(` ✔ label ${label.name}`)
|
|
45
|
+
|
|
46
|
+
return is_created
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function label_names(labels: ReadonlyArray<LabelDefinition>): ReadonlyArray<string> {
|
|
50
|
+
return labels.map((label) => label.name)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// An unreadable listing is not an empty one: nothing is created, and every label is reported as
|
|
54
|
+
// unconfirmed rather than as missing. An unresolved repository (no GitHub remote) is the same case.
|
|
55
|
+
function unreadable(repo: string | undefined): ReadonlyArray<string> {
|
|
56
|
+
const names = label_names(REPOSITORY_LABELS)
|
|
57
|
+
const target = repo ?? 'this repository'
|
|
58
|
+
|
|
59
|
+
console.warn(
|
|
60
|
+
` ⚠ could not read the labels of ${target}; make sure these exist: ${names.join(', ')}`,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return names
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function create_missing(
|
|
67
|
+
missing: ReadonlyArray<LabelDefinition>,
|
|
68
|
+
repo: string,
|
|
69
|
+
): ReadonlyArray<string> {
|
|
70
|
+
const names = label_names(missing.filter((label) => !create_label(label, repo)))
|
|
71
|
+
|
|
72
|
+
if (names.length > 0) {
|
|
73
|
+
console.warn(` ⚠ could not create these labels; create them by hand: ${names.join(', ')}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return names
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The label names not confirmed present afterwards — empty when the repository has every one.
|
|
80
|
+
function ensure_labels(repo: string | undefined): ReadonlyArray<string> {
|
|
81
|
+
if (repo === undefined) return unreadable(repo)
|
|
82
|
+
|
|
83
|
+
const existing = read_existing(repo)
|
|
84
|
+
if (existing === undefined) return unreadable(repo)
|
|
85
|
+
|
|
86
|
+
return create_missing(missing_labels(existing), repo)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const repository_labels = { missing_labels, ensure_labels }
|
|
90
|
+
|
|
91
|
+
export { repository_labels }
|
|
@@ -4,6 +4,7 @@ import { josh_command } from '#scripts/josh/josh-run'
|
|
|
4
4
|
import { lane_child_marker } from '#scripts/lane/lane-child-marker'
|
|
5
5
|
import { run_cut_report } from './run-cut-report'
|
|
6
6
|
import { run_entry, type EntryParts } from './run-entry'
|
|
7
|
+
import { run_halfrun_resume } from './run-halfrun-resume'
|
|
7
8
|
import { run_hold_cli } from './run-hold-cli'
|
|
8
9
|
import { run_next } from './run-next'
|
|
9
10
|
import { run_prep } from './run-prep'
|
|
@@ -33,6 +34,7 @@ const NEWLINE = '\n'
|
|
|
33
34
|
const HOLD_STOP_NOTE =
|
|
34
35
|
'(tree not held — see the reason above; clean up and retry, or release with `pnpm josh run:release`)'
|
|
35
36
|
const COST_STOP_NOTE = '(session budget spent — see the figure above; resume in a fresh session)'
|
|
37
|
+
const HALFRUN_RESUME_TOKEN = 'halfrun'
|
|
36
38
|
|
|
37
39
|
// Exactly one issue number, or the call is refused — `run:entry` opens one run, and a second number
|
|
38
40
|
// would claim and read a second issue a caller reads as this one's.
|
|
@@ -126,11 +128,33 @@ function resume_report(issue_number: string, resume: Resume): number {
|
|
|
126
128
|
return resume.code
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
// **A `halfrun` stopped before its commit is resumed, not claimed** (joshuafolkken/kit#2796): its hold
|
|
132
|
+
// is kept over the verified diff, so the claim below would answer `busy` against it. The budget is asked
|
|
133
|
+
// first, as for any run; then the hold is adopted and the run goes to the gate, skipping everything up
|
|
134
|
+
// to and including implementation.
|
|
135
|
+
async function resume_halfrun(issue_number: string): Promise<number> {
|
|
136
|
+
const cost = await check_cost()
|
|
137
|
+
|
|
138
|
+
if (cost === run_entry.COST_OVER) {
|
|
139
|
+
return stop_report(issue_number, HALFRUN_RESUME_TOKEN, cost, COST_STOP_NOTE)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!(await run_halfrun_resume.adopt(issue_number))) {
|
|
143
|
+
return stop_report(issue_number, run_hold_cli.BUSY_VERDICT, cost, HOLD_STOP_NOTE)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.info(`entry #${issue_number} — resume: ${HALFRUN_RESUME_TOKEN}`)
|
|
147
|
+
|
|
148
|
+
return SUCCESS_EXIT_CODE
|
|
149
|
+
}
|
|
150
|
+
|
|
129
151
|
async function open_run(issue_number: string): Promise<number> {
|
|
130
152
|
const resume = await ask_resume(issue_number)
|
|
131
153
|
|
|
132
154
|
if (resume.token !== run_cut_report.FRESH_VERDICT) return resume_report(issue_number, resume)
|
|
133
155
|
|
|
156
|
+
if (await run_halfrun_resume.is_pending(issue_number)) return await resume_halfrun(issue_number)
|
|
157
|
+
|
|
134
158
|
const hold = await claim_hold(issue_number)
|
|
135
159
|
|
|
136
160
|
if (hold !== run_hold_cli.HOLD_VERDICT) {
|
|
@@ -162,7 +186,16 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
|
|
|
162
186
|
process.exitCode = await run(argv)
|
|
163
187
|
}
|
|
164
188
|
|
|
165
|
-
const run_entry_cli = {
|
|
189
|
+
const run_entry_cli = {
|
|
190
|
+
HALFRUN_RESUME_TOKEN,
|
|
191
|
+
SUCCESS_EXIT_CODE,
|
|
192
|
+
USAGE,
|
|
193
|
+
check_cost,
|
|
194
|
+
claim_hold,
|
|
195
|
+
main,
|
|
196
|
+
parse_number,
|
|
197
|
+
run,
|
|
198
|
+
}
|
|
166
199
|
|
|
167
200
|
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
|
|
168
201
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { run_hold, type HoldRead, type RunHold } from './run-hold'
|
|
2
|
+
|
|
3
|
+
// joshuafolkken/kit#2796: a `halfrun` stops before its commit and **keeps** its hold, because the
|
|
4
|
+
// uncommitted, hand-verified work is exactly what a second run would trample. The person then types
|
|
5
|
+
// `fullrun #N` to ship it — and `run:entry`'s claim answered `busy` against that very hold, or, once the
|
|
6
|
+
// hold had been cleared, the preflight advised stashing the verified diff away. The hand-off the
|
|
7
|
+
// `halfrun` stop promised could not land, and each run improvised its own way past the guard.
|
|
8
|
+
//
|
|
9
|
+
// **The stop is recorded, never inferred.** A hold naming #N over a dirty tree is also what a `halfrun`
|
|
10
|
+
// still implementing and a `backlogrun` child leave, and adopting either hands a half-written diff to
|
|
11
|
+
// the gate and the merge. So the `halfrun` stop re-writes its own record with a stop mark
|
|
12
|
+
// (`run:hold <N> --halfrun-stop`) — re-keying a `halfrun new`'s unnumbered record to the issue it filed —
|
|
13
|
+
// and only a marked record is adopted. An expired one qualifies too, since a person may verify the next
|
|
14
|
+
// day. The adopted record carries the `fullrun` mark instead, so the implementation-cut guard and
|
|
15
|
+
// `run:cut --resume` read the tree as this `fullrun`'s from here on.
|
|
16
|
+
|
|
17
|
+
const MARKED = 'marked'
|
|
18
|
+
const FOREIGN = 'foreign'
|
|
19
|
+
const UNREADABLE = 'unreadable'
|
|
20
|
+
|
|
21
|
+
type StopMark = typeof FOREIGN | typeof MARKED | typeof UNREADABLE
|
|
22
|
+
|
|
23
|
+
function hold_of(read: HoldRead): RunHold | undefined {
|
|
24
|
+
return read.kind === 'held' || read.kind === 'stale' ? read.hold : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function is_halfrun_stop(read: HoldRead, issue: string, is_dirty: boolean): boolean {
|
|
28
|
+
const hold = hold_of(read)
|
|
29
|
+
|
|
30
|
+
return is_dirty && hold?.issue === issue && hold.is_halfrun_stop === true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The stopping run's own record names its issue, or `new` when it entered as `halfrun new`; a free tree
|
|
34
|
+
// is marked as well, since the work to protect is sitting in it either way.
|
|
35
|
+
function is_own_record(read: HoldRead, issue: string): boolean {
|
|
36
|
+
const hold = hold_of(read)
|
|
37
|
+
|
|
38
|
+
return hold === undefined || hold.issue === issue || hold.issue === run_hold.UNNUMBERED_ISSUE
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mark_stop_at(target: string, issue: string): StopMark {
|
|
42
|
+
const read = run_hold.read_hold(target)
|
|
43
|
+
|
|
44
|
+
if (read.kind === 'unreadable') return UNREADABLE
|
|
45
|
+
|
|
46
|
+
if (!is_own_record(read, issue)) return FOREIGN
|
|
47
|
+
|
|
48
|
+
run_hold.release_hold(target)
|
|
49
|
+
|
|
50
|
+
return run_hold.create_halfrun_stop_hold(target, issue) ? MARKED : FOREIGN
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `false` leaves the record untouched, so the ordinary claim that follows decides the tree exactly as
|
|
54
|
+
// it did before this resume existed.
|
|
55
|
+
function adopt_at(target: string, issue: string, is_dirty: boolean): boolean {
|
|
56
|
+
if (!is_halfrun_stop(run_hold.read_hold(target), issue, is_dirty)) return false
|
|
57
|
+
|
|
58
|
+
run_hold.release_hold(target)
|
|
59
|
+
|
|
60
|
+
return run_hold.create_hold(target, issue, new Date(), true)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// **An unreadable git directory is no resume**, never a crash: `run:entry` must still print its one
|
|
64
|
+
// summary line, and the ordinary claim that follows answers `unknown` for the same tree.
|
|
65
|
+
async function hold_target(): Promise<string | undefined> {
|
|
66
|
+
try {
|
|
67
|
+
const directory = await run_hold.worktree_directory()
|
|
68
|
+
|
|
69
|
+
return directory === undefined ? undefined : run_hold.hold_path(directory)
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Read-only, so `run:entry` can ask the session budget before it takes anything over.
|
|
76
|
+
async function is_pending(issue: string): Promise<boolean> {
|
|
77
|
+
const target = await hold_target()
|
|
78
|
+
|
|
79
|
+
if (target === undefined) return false
|
|
80
|
+
|
|
81
|
+
return is_halfrun_stop(run_hold.read_hold(target), issue, await run_hold.is_tree_dirty())
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function adopt(issue: string): Promise<boolean> {
|
|
85
|
+
const target = await hold_target()
|
|
86
|
+
|
|
87
|
+
if (target === undefined) return false
|
|
88
|
+
|
|
89
|
+
return adopt_at(target, issue, await run_hold.is_tree_dirty())
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const run_halfrun_resume = {
|
|
93
|
+
FOREIGN,
|
|
94
|
+
MARKED,
|
|
95
|
+
UNREADABLE,
|
|
96
|
+
adopt,
|
|
97
|
+
adopt_at,
|
|
98
|
+
is_halfrun_stop,
|
|
99
|
+
is_pending,
|
|
100
|
+
mark_stop_at,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type { StopMark }
|
|
104
|
+
export { run_halfrun_resume }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { run_halfrun_resume } from './run-halfrun-resume'
|
|
3
4
|
import { run_hold, type HoldRead, type RunHold } from './run-hold'
|
|
4
5
|
import { run_preflight, type PreflightDecision } from './run-preflight'
|
|
5
6
|
import { run_tidy_cli } from './run-tidy-cli'
|
|
@@ -46,11 +47,12 @@ const FORCE_FLAG = '--force'
|
|
|
46
47
|
// What `fullrun #N`'s entry claims with, so the record says the run is a `fullrun` — the one run whose
|
|
47
48
|
// implementation cut outside a lane resumes as `fullrun #N` (joshuafolkken/kit#2760).
|
|
48
49
|
const FULLRUN_FLAG = '--fullrun'
|
|
49
|
-
|
|
50
|
+
const HALFRUN_STOP_FLAG = '--halfrun-stop'
|
|
51
|
+
// The most a claim reads: the issue number and the one flag after it.
|
|
50
52
|
const MAX_CLAIM_ARGUMENTS = 2
|
|
51
53
|
const ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/u
|
|
52
54
|
const USAGE =
|
|
53
|
-
'Usage: josh run:hold [<issue-number> [--fullrun]] | josh run:release [<issue-number> | --force]'
|
|
55
|
+
'Usage: josh run:hold [<issue-number> [--fullrun | --halfrun-stop]] | josh run:release [<issue-number> | --force]'
|
|
54
56
|
|
|
55
57
|
const HOLD_VERDICT = 'hold'
|
|
56
58
|
const BUSY_VERDICT = 'busy'
|
|
@@ -62,6 +64,7 @@ const NONE_VERDICT = 'none'
|
|
|
62
64
|
const HELD_VERDICT = 'held'
|
|
63
65
|
|
|
64
66
|
const FORCE_RELEASE_KIND = 'force-release'
|
|
67
|
+
const HALFRUN_STOP_KIND = 'halfrun-stop'
|
|
65
68
|
|
|
66
69
|
interface ClaimRequest {
|
|
67
70
|
kind: 'claim'
|
|
@@ -70,7 +73,10 @@ interface ClaimRequest {
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
type HoldRequest =
|
|
73
|
-
|
|
76
|
+
| ClaimRequest
|
|
77
|
+
| { kind: 'release'; claimant: string }
|
|
78
|
+
| { kind: typeof FORCE_RELEASE_KIND }
|
|
79
|
+
| { kind: typeof HALFRUN_STOP_KIND; issue: string }
|
|
74
80
|
|
|
75
81
|
const FORCE_RELEASE_REQUEST: HoldRequest = { kind: FORCE_RELEASE_KIND }
|
|
76
82
|
|
|
@@ -78,10 +84,13 @@ function release_request(claimant: string): HoldRequest {
|
|
|
78
84
|
return { kind: 'release', claimant }
|
|
79
85
|
}
|
|
80
86
|
|
|
81
|
-
// The flag after a numbered claim: absent, or `--
|
|
87
|
+
// The flag after a numbered claim: absent, `--fullrun` or `--halfrun-stop`. Anything else is a usage
|
|
88
|
+
// error.
|
|
82
89
|
function numbered_claim(issue: string, flag: string | undefined): HoldRequest | undefined {
|
|
83
90
|
if (flag === undefined) return { kind: 'claim', issue }
|
|
84
91
|
|
|
92
|
+
if (flag === HALFRUN_STOP_FLAG) return { kind: HALFRUN_STOP_KIND, issue }
|
|
93
|
+
|
|
85
94
|
return flag === FULLRUN_FLAG ? { kind: 'claim', issue, is_fullrun: true } : undefined
|
|
86
95
|
}
|
|
87
96
|
|
|
@@ -282,9 +291,23 @@ async function read_worktree(): Promise<string | undefined> {
|
|
|
282
291
|
}
|
|
283
292
|
}
|
|
284
293
|
|
|
294
|
+
// **A `halfrun` stop marks its own record** (joshuafolkken/kit#2796) — the positive "this run has ended
|
|
295
|
+
// over its verified diff" that `run:entry` adopts on `fullrun #N`. Another run's record is left alone.
|
|
296
|
+
function mark_halfrun_stop(target: string, issue: string): number {
|
|
297
|
+
const mark = run_halfrun_resume.mark_stop_at(target, issue)
|
|
298
|
+
|
|
299
|
+
if (mark === run_halfrun_resume.MARKED) return report_hold()
|
|
300
|
+
|
|
301
|
+
if (mark === run_halfrun_resume.UNREADABLE) return report_unknown()
|
|
302
|
+
|
|
303
|
+
return report_busy(run_hold.race_message(run_hold.read_hold(target), target))
|
|
304
|
+
}
|
|
305
|
+
|
|
285
306
|
async function dispatch(request: HoldRequest, target: string, is_linked: boolean): Promise<number> {
|
|
286
307
|
if (request.kind === 'claim') return await claim(target, request, is_linked)
|
|
287
308
|
|
|
309
|
+
if (request.kind === HALFRUN_STOP_KIND) return mark_halfrun_stop(target, request.issue)
|
|
310
|
+
|
|
288
311
|
if (request.kind === FORCE_RELEASE_KIND) return force_release(target)
|
|
289
312
|
|
|
290
313
|
return release(target, request.claimant)
|
package/scripts/run/run-hold.ts
CHANGED
|
@@ -48,6 +48,10 @@ interface RunHold {
|
|
|
48
48
|
// `backlogrun` child — which hold the tree just the same — must not be read as one. Optional and
|
|
49
49
|
// `| undefined` because a record written before this field existed is simply not a `fullrun`'s.
|
|
50
50
|
is_fullrun?: boolean | undefined
|
|
51
|
+
// Set only at a `halfrun`'s stop before commit (`run:hold <N> --halfrun-stop`, joshuafolkken/kit#2796):
|
|
52
|
+
// the positive record that the run has *ended* over its verified diff, which is what lets
|
|
53
|
+
// `fullrun #N` adopt the hold. A running `halfrun` or a `backlogrun` child never carries it.
|
|
54
|
+
is_halfrun_stop?: boolean | undefined
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
type HoldRead =
|
|
@@ -87,6 +91,7 @@ const run_hold_schema = z.object({
|
|
|
87
91
|
taken_at: z.string(),
|
|
88
92
|
pid: z.number(),
|
|
89
93
|
is_fullrun: z.boolean().optional(),
|
|
94
|
+
is_halfrun_stop: z.boolean().optional(),
|
|
90
95
|
})
|
|
91
96
|
|
|
92
97
|
// `undefined` for anything that is not a well-formed record, so the caller decides what a broken one
|
|
@@ -156,6 +161,11 @@ function create_hold(
|
|
|
156
161
|
return stamp_file.create_stamp(target, build_hold(issue, now, is_fullrun))
|
|
157
162
|
}
|
|
158
163
|
|
|
164
|
+
// The claim a `halfrun` stop leaves behind: the same exclusive create, carrying the stop mark.
|
|
165
|
+
function create_halfrun_stop_hold(target: string, issue: string, now: Date = new Date()): boolean {
|
|
166
|
+
return stamp_file.create_stamp(target, { ...build_hold(issue, now), is_halfrun_stop: true })
|
|
167
|
+
}
|
|
168
|
+
|
|
159
169
|
// **A tree with uncommitted work in it is never handed over, however old its record is.** No age can
|
|
160
170
|
// be chosen that covers a `halfrun` stop or a `needs-human-review` stop: those are held across a
|
|
161
171
|
// person's latency rather than a run's, and the thing that must not be trampled is sitting in the
|
|
@@ -249,6 +259,7 @@ const run_hold = {
|
|
|
249
259
|
RELEASE_COMMAND,
|
|
250
260
|
UNNUMBERED_ISSUE,
|
|
251
261
|
classify,
|
|
262
|
+
create_halfrun_stop_hold,
|
|
252
263
|
create_hold,
|
|
253
264
|
describe_holder,
|
|
254
265
|
foreign_release_message,
|
|
@@ -86,10 +86,21 @@ function resolve_scanner(project_root: string, platform: string): ScannerChoice
|
|
|
86
86
|
return has_path_binary ? { path: BINARY_NAME, is_below_floor: true } : undefined
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// **Output is forwarded, never handed the terminal itself.** osv-scanner is a Go binary that probes a
|
|
90
|
+
// terminal on its stdout for its background color (an OSC 11 query followed by a cursor-position
|
|
91
|
+
// request) and never reads the answers, so they sat in the terminal's input queue and surfaced at the
|
|
92
|
+
// next shell prompt as `11;rgb:0101/0404/0909;1R` after a push ran this audit from the pre-push hook.
|
|
93
|
+
// A pipe means the scanner sees no terminal and asks nothing; the `inherit` half still prints its report.
|
|
94
|
+
// The fix belongs here, at the one command that asks, rather than on `git push`: piping the push would
|
|
95
|
+
// make its timeout wait on any process a hook left running (joshuafolkken/kit#2801).
|
|
96
|
+
const FORWARDED_OUTPUT = ['inherit', 'pipe'] as const
|
|
97
|
+
|
|
89
98
|
function run_scanner(scanner_path: string): number {
|
|
90
99
|
const { LOCKFILE_PATH, build_scanner_arguments } = security_audit_logic
|
|
91
100
|
const result = execaSync(scanner_path, [...build_scanner_arguments(LOCKFILE_PATH)], {
|
|
92
|
-
|
|
101
|
+
stdin: 'inherit',
|
|
102
|
+
stdout: FORWARDED_OUTPUT,
|
|
103
|
+
stderr: FORWARDED_OUTPUT,
|
|
93
104
|
reject: false,
|
|
94
105
|
})
|
|
95
106
|
|
package/scripts/sync/sync.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { init_logic } from '#scripts/init/init-logic'
|
|
|
8
8
|
import { PACKAGE_DIR, PROJECT_ROOT } from '#scripts/init/init-paths'
|
|
9
9
|
import { plugin_install_hint_module } from '#scripts/init/plugin-install-hint'
|
|
10
10
|
import { auto_merge_setting } from '#scripts/repo/auto-merge-setting'
|
|
11
|
+
import { repository_labels } from '#scripts/repo/repository-labels'
|
|
11
12
|
import { project_config } from '#scripts/safe-chain/project-config'
|
|
12
13
|
import { security_updates } from '#scripts/security/security-updates'
|
|
13
14
|
import { sonar_file } from '#scripts/security/sonar-file'
|
|
@@ -364,9 +365,14 @@ function sync_package_json_migrations(destination_path: string): void {
|
|
|
364
365
|
// unless the repository allows auto-merge (joshuafolkken/kit#834). Neither ever fails the sync: both
|
|
365
366
|
// are GitHub-side state, not synced artifacts. Unconditional, unlike `init`, because `sync`
|
|
366
367
|
// overwrites both files on every run.
|
|
368
|
+
//
|
|
369
|
+
// The labels are the same kind of GitHub-side prerequisite, and one kit *can* write: the synced
|
|
370
|
+
// `pr-classification.yml` requires one of the release classification labels, so the missing ones are
|
|
371
|
+
// created here rather than reported (joshuafolkken/kit#2797).
|
|
367
372
|
function report_repository_settings(name_with_owner: string | undefined): void {
|
|
368
373
|
security_updates.report_security_updates_section(name_with_owner)
|
|
369
374
|
auto_merge_setting.report_auto_merge_section(name_with_owner)
|
|
375
|
+
repository_labels.ensure_labels(name_with_owner)
|
|
370
376
|
}
|
|
371
377
|
|
|
372
378
|
function sync_project_artifacts(is_force: boolean): void {
|