@ramon-sg/issue-flow 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +297 -2
- package/dist/ask.d.ts +28 -0
- package/dist/ask.js +73 -0
- package/dist/branch-name.d.ts +113 -0
- package/dist/branch-name.js +226 -0
- package/dist/claude-namer.d.ts +51 -0
- package/dist/claude-namer.js +140 -0
- package/dist/cli-setup.d.ts +22 -0
- package/dist/cli-setup.js +26 -0
- package/dist/cli-worktree.d.ts +74 -0
- package/dist/cli-worktree.js +123 -0
- package/dist/cli.d.ts +120 -0
- package/dist/cli.js +344 -0
- package/dist/commands/adapter.d.ts +49 -0
- package/dist/commands/adapter.js +231 -0
- package/dist/commands/claude-hooks.d.ts +37 -0
- package/dist/commands/claude-hooks.js +162 -0
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.js +90 -0
- package/dist/commands/init.d.ts +24 -0
- package/dist/commands/init.js +136 -0
- package/dist/commands/issue-branch.d.ts +36 -0
- package/dist/commands/issue-branch.js +182 -0
- package/dist/commands/issue-start.d.ts +11 -0
- package/dist/commands/issue-start.js +361 -0
- package/dist/commands/pr-worktree.d.ts +31 -0
- package/dist/commands/pr-worktree.js +122 -0
- package/dist/commands/session.d.ts +15 -0
- package/dist/commands/session.js +102 -0
- package/dist/commands/upgrade.d.ts +16 -0
- package/dist/commands/upgrade.js +85 -0
- package/dist/commands/worktree.d.ts +82 -0
- package/dist/commands/worktree.js +244 -0
- package/dist/config-base.d.ts +32 -0
- package/dist/config-base.js +47 -0
- package/dist/config.d.ts +63 -0
- package/dist/config.js +230 -0
- package/dist/docker.d.ts +53 -0
- package/dist/docker.js +153 -0
- package/dist/git.d.ts +130 -0
- package/dist/git.js +192 -0
- package/dist/issue-text.d.ts +128 -0
- package/dist/issue-text.js +210 -0
- package/dist/launch.d.ts +90 -0
- package/dist/launch.js +158 -0
- package/dist/output.d.ts +33 -0
- package/dist/output.js +37 -0
- package/dist/package-edits.d.ts +35 -0
- package/dist/package-edits.js +78 -0
- package/dist/plan-doc.d.ts +26 -0
- package/dist/plan-doc.js +93 -0
- package/dist/ports.d.ts +90 -0
- package/dist/ports.js +257 -0
- package/dist/propose.d.ts +42 -0
- package/dist/propose.js +111 -0
- package/dist/recipes.d.ts +43 -0
- package/dist/recipes.js +123 -0
- package/dist/session-lock.d.ts +32 -0
- package/dist/session-lock.js +69 -0
- package/dist/settings-hooks.d.ts +31 -0
- package/dist/settings-hooks.js +133 -0
- package/dist/setup-files.d.ts +48 -0
- package/dist/setup-files.js +91 -0
- package/dist/start-modes.d.ts +162 -0
- package/dist/start-modes.js +277 -0
- package/dist/start-place.d.ts +61 -0
- package/dist/start-place.js +301 -0
- package/dist/start-state.d.ts +72 -0
- package/dist/start-state.js +13 -0
- package/dist/start-summary.d.ts +42 -0
- package/dist/start-summary.js +217 -0
- package/dist/stop-apps.d.ts +14 -0
- package/dist/stop-apps.js +127 -0
- package/dist/usage.d.ts +5 -0
- package/dist/usage.js +54 -0
- package/dist/work-state.d.ts +51 -0
- package/dist/work-state.js +58 -0
- package/dist/worktree-config.d.ts +35 -0
- package/dist/worktree-config.js +139 -0
- package/dist/worktree-steps.d.ts +53 -0
- package/dist/worktree-steps.js +210 -0
- package/package.json +20 -4
- package/recipes/bun.json +5 -0
- package/recipes/direnv.json +17 -0
- package/recipes/docker-compose.json +30 -0
- package/recipes/npm.json +5 -0
- package/recipes/pnpm.json +5 -0
- package/recipes/staging-main.json +5 -0
- package/schema.json +281 -0
package/dist/git.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Small git queries over `spawnSync`, for starting an issue: refs, commits that are
|
|
3
|
+
* not in the base, where a branch is checked out and worktree locks.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { realpathSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
/**
|
|
9
|
+
* Runs git and returns whether it worked, with its output trimmed at the end.
|
|
10
|
+
* @param cwd - Where to run it.
|
|
11
|
+
* @param args - The arguments.
|
|
12
|
+
* @returns The result.
|
|
13
|
+
* @example git("/repo", ["rev-parse", "HEAD"]).out
|
|
14
|
+
*/
|
|
15
|
+
export function git(cwd, args) {
|
|
16
|
+
const run = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
17
|
+
return {
|
|
18
|
+
ok: run.status === 0,
|
|
19
|
+
out: (run.stdout ?? "").trimEnd(),
|
|
20
|
+
err: (run.stderr ?? "").trim(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The non-empty lines of a git command's output (none if it failed).
|
|
25
|
+
* @param cwd - Where to run it.
|
|
26
|
+
* @param args - The arguments.
|
|
27
|
+
* @returns The lines.
|
|
28
|
+
* @example gitLines("/repo", ["branch", "--format=%(refname:short)"])
|
|
29
|
+
*/
|
|
30
|
+
export function gitLines(cwd, args) {
|
|
31
|
+
const result = git(cwd, args);
|
|
32
|
+
return result.ok ? result.out.split("\n").filter((line) => line !== "") : [];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether a ref exists.
|
|
36
|
+
* @param cwd - The checkout.
|
|
37
|
+
* @param ref - The full ref (`refs/heads/x`).
|
|
38
|
+
* @returns True if it does.
|
|
39
|
+
* @example hasRef("/repo", "refs/heads/main")
|
|
40
|
+
*/
|
|
41
|
+
export function hasRef(cwd, ref) {
|
|
42
|
+
return git(cwd, ["rev-parse", "-q", "--verify", ref]).ok;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether the branch exists locally.
|
|
46
|
+
* @param cwd - The checkout.
|
|
47
|
+
* @param branch - The branch.
|
|
48
|
+
* @returns True if it does.
|
|
49
|
+
* @example hasLocal("/repo", "feat/42-x")
|
|
50
|
+
*/
|
|
51
|
+
export const hasLocal = (cwd, branch) => hasRef(cwd, `refs/heads/${branch}`);
|
|
52
|
+
/**
|
|
53
|
+
* Whether the branch exists in origin, as of the last fetch.
|
|
54
|
+
* @param cwd - The checkout.
|
|
55
|
+
* @param branch - The branch.
|
|
56
|
+
* @returns True if it does.
|
|
57
|
+
* @example onOrigin("/repo", "feat/42-x")
|
|
58
|
+
*/
|
|
59
|
+
export const onOrigin = (cwd, branch) => hasRef(cwd, `refs/remotes/origin/${branch}`);
|
|
60
|
+
/**
|
|
61
|
+
* Commits of the branch (local, or else in origin) that are not in `origin/<base>`.
|
|
62
|
+
* @param cwd - The checkout.
|
|
63
|
+
* @param branch - The branch.
|
|
64
|
+
* @param base - The base branch.
|
|
65
|
+
* @returns How many, 0 if the branch does not exist.
|
|
66
|
+
* @example ownCommits("/repo", "feat/42-x", "main") // 0
|
|
67
|
+
*/
|
|
68
|
+
export function ownCommits(cwd, branch, base) {
|
|
69
|
+
const ref = hasLocal(cwd, branch) ? branch : `origin/${branch}`;
|
|
70
|
+
const result = git(cwd, ["rev-list", "--count", `origin/${base}..${ref}`]);
|
|
71
|
+
return result.ok ? Number(result.out) : 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A path with its symlinks resolved, so paths from git and from the system compare
|
|
75
|
+
* equal (on macOS `/var` is `/private/var`).
|
|
76
|
+
* @param path - The path.
|
|
77
|
+
* @returns The real path, or the path itself if it does not exist.
|
|
78
|
+
*/
|
|
79
|
+
function real(path) {
|
|
80
|
+
try {
|
|
81
|
+
return realpathSync(path);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return path;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The worktrees of the repo, the main checkout first.
|
|
89
|
+
* @param cwd - Any checkout of the repo.
|
|
90
|
+
* @returns Path, branch and lock reason of each.
|
|
91
|
+
* @example worktrees("/repo")[0].path // "/repo"
|
|
92
|
+
*/
|
|
93
|
+
export function worktrees(cwd) {
|
|
94
|
+
const found = [];
|
|
95
|
+
for (const line of git(cwd, ["worktree", "list", "--porcelain"]).out.split("\n")) {
|
|
96
|
+
if (line.startsWith("worktree "))
|
|
97
|
+
found.push({ path: real(line.slice(9)), branch: null, lock: null });
|
|
98
|
+
const current = found.at(-1);
|
|
99
|
+
if (current === undefined)
|
|
100
|
+
continue;
|
|
101
|
+
if (line.startsWith("branch refs/heads/"))
|
|
102
|
+
current.branch = line.slice(18);
|
|
103
|
+
if (line === "locked")
|
|
104
|
+
current.lock = "";
|
|
105
|
+
if (line.startsWith("locked "))
|
|
106
|
+
current.lock = line.slice(7);
|
|
107
|
+
}
|
|
108
|
+
return found;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Where the branch is checked out: the main checkout or a worktree.
|
|
112
|
+
* @param cwd - Any checkout of the repo.
|
|
113
|
+
* @param branch - The branch.
|
|
114
|
+
* @returns The path, or null if it is not checked out.
|
|
115
|
+
* @example checkedOutAt("/repo", "feat/42-x")
|
|
116
|
+
*/
|
|
117
|
+
export function checkedOutAt(cwd, branch) {
|
|
118
|
+
return worktrees(cwd).find((entry) => entry.branch === branch)?.path ?? null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Why the worktree is locked.
|
|
122
|
+
* @param cwd - Any checkout of the repo.
|
|
123
|
+
* @param path - The worktree.
|
|
124
|
+
* @returns The reason (empty if it has none), or null if it is not locked.
|
|
125
|
+
* @example worktreeLock("/repo", "/repo/.claude/worktrees/feat+42-x")
|
|
126
|
+
*/
|
|
127
|
+
export function worktreeLock(cwd, path) {
|
|
128
|
+
return worktrees(cwd).find((entry) => entry.path === real(path))?.lock ?? null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Whether the checkout has no changes. Untracked files count unless told otherwise.
|
|
132
|
+
* @param cwd - The checkout.
|
|
133
|
+
* @param untracked - Whether untracked files count.
|
|
134
|
+
* @returns True if it is clean.
|
|
135
|
+
* @example isClean("/repo")
|
|
136
|
+
*/
|
|
137
|
+
export function isClean(cwd, untracked = true) {
|
|
138
|
+
const args = ["status", "--porcelain", ...(untracked ? [] : ["--untracked-files=no"])];
|
|
139
|
+
const result = git(cwd, args);
|
|
140
|
+
return result.ok && result.out === "";
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The branch the checkout is on.
|
|
144
|
+
* @param cwd - The checkout.
|
|
145
|
+
* @returns The branch, or null on a detached HEAD.
|
|
146
|
+
* @example currentBranch("/repo") // "main"
|
|
147
|
+
*/
|
|
148
|
+
export function currentBranch(cwd) {
|
|
149
|
+
const result = git(cwd, ["symbolic-ref", "-q", "--short", "HEAD"]);
|
|
150
|
+
return result.ok ? result.out : null;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Where a directory's repo lives: its shared `.git` and the main checkout, the same from
|
|
154
|
+
* any worktree. The main checkout is the first one git lists, which also holds in a
|
|
155
|
+
* submodule or with `--separate-git-dir`, where the shared `.git` is not inside it.
|
|
156
|
+
* @param cwd - A directory inside the repo.
|
|
157
|
+
* @returns The common git dir and the main checkout, or null outside git.
|
|
158
|
+
* @example locateRepo(process.cwd())?.mainRoot
|
|
159
|
+
*/
|
|
160
|
+
export function locateRepo(cwd) {
|
|
161
|
+
const result = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
162
|
+
if (!result.ok)
|
|
163
|
+
return null;
|
|
164
|
+
const commonDir = real(result.out);
|
|
165
|
+
return { commonDir, mainRoot: worktrees(cwd)[0]?.path ?? dirname(commonDir) };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The root of the checkout a directory is in (the main one or a worktree), with its
|
|
169
|
+
* symlinks resolved like the other paths here.
|
|
170
|
+
* @param cwd - A directory inside the checkout.
|
|
171
|
+
* @returns The root, or null outside git.
|
|
172
|
+
* @example checkoutRoot("/repo/.claude/worktrees/feat+42-x/src") // "/repo/.claude/worktrees/feat+42-x"
|
|
173
|
+
*/
|
|
174
|
+
export function checkoutRoot(cwd) {
|
|
175
|
+
const result = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
176
|
+
return result.ok ? real(result.out) : null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Whether deleting the branch loses no work: all its commits are in some branch of
|
|
180
|
+
* origin, or it does not go past the PR's head (the branch already deleted on GitHub, or
|
|
181
|
+
* a squash merge).
|
|
182
|
+
* @param cwd - The checkout.
|
|
183
|
+
* @param branch - The branch.
|
|
184
|
+
* @param head - The merged PR's head.
|
|
185
|
+
* @returns True if it is safe.
|
|
186
|
+
* @example safeToDelete("/repo", "feat/42-x", "")
|
|
187
|
+
*/
|
|
188
|
+
export function safeToDelete(cwd, branch, head) {
|
|
189
|
+
if (git(cwd, ["rev-list", "--count", branch, "--not", "--remotes=origin"]).out === "0")
|
|
190
|
+
return true;
|
|
191
|
+
return head !== "" && git(cwd, ["merge-base", "--is-ancestor", branch, head]).ok;
|
|
192
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Lines of the issue's header shown before confirming. */
|
|
2
|
+
export declare const HEADER_MAX_LINES = 6;
|
|
3
|
+
/** Width the header is wrapped to. */
|
|
4
|
+
export declare const HEADER_WIDTH = 80;
|
|
5
|
+
/** Prefix of the session's name when it works in a worktree. */
|
|
6
|
+
export declare const WORKTREE_SESSION_PREFIX = "w-";
|
|
7
|
+
/**
|
|
8
|
+
* Where the work happens: a worktree of its own, the main checkout, the checkout where it
|
|
9
|
+
* runs inside a cloud session (`--here`), or a cloud session it opens (`--cloud`).
|
|
10
|
+
*/
|
|
11
|
+
export type StartMode = "worktree" | "branch" | "here" | "cloud";
|
|
12
|
+
/** Label of the PR body's line with the link of its cloud session. */
|
|
13
|
+
export declare const CLOUD_SESSION_LABEL = "Cloud session:";
|
|
14
|
+
/** Whether the PR closes the issue, or is only part of it (the issue has another branch). */
|
|
15
|
+
export type PrKind = "closes" | "part";
|
|
16
|
+
/**
|
|
17
|
+
* The issue's header for the summary: its first paragraph, before any branch block,
|
|
18
|
+
* wrapped and cut to `HEADER_MAX_LINES` lines.
|
|
19
|
+
* @param body - The issue body.
|
|
20
|
+
* @returns The lines, maybe none.
|
|
21
|
+
* @example issueHeader("Qué se pide.\n\nDetalle.") // ["Qué se pide."]
|
|
22
|
+
*/
|
|
23
|
+
export declare function issueHeader(body: string): string[];
|
|
24
|
+
/**
|
|
25
|
+
* The session's color and the label that gives it: the first entry of the config's map
|
|
26
|
+
* whose key starts some label, in the map's order (so `critical-path` can win over `P3:`).
|
|
27
|
+
* @param labels - The issue's labels.
|
|
28
|
+
* @param colors - Label prefix to color.
|
|
29
|
+
* @returns The label and color, or null if no label matches.
|
|
30
|
+
* @example issueColor(["P1: urgent"], { "P1:": "red" }) // { label: "P1: urgent", color: "red" }
|
|
31
|
+
*/
|
|
32
|
+
export declare function issueColor(labels: string[], colors: Record<string, string>): {
|
|
33
|
+
label: string;
|
|
34
|
+
color: string;
|
|
35
|
+
} | null;
|
|
36
|
+
/**
|
|
37
|
+
* The Claude session's name: the branch, with `w-` in front in a worktree.
|
|
38
|
+
* @param branch - The branch.
|
|
39
|
+
* @param mode - Where the work happens.
|
|
40
|
+
* @returns The name.
|
|
41
|
+
* @example sessionName("feat/42-x", "worktree") // "w-feat/42-x"
|
|
42
|
+
*/
|
|
43
|
+
export declare function sessionName(branch: string, mode: StartMode): string;
|
|
44
|
+
/**
|
|
45
|
+
* The folder name of a branch's worktree and session file: the branch with every `/` as
|
|
46
|
+
* `+`, like `claude --worktree`, so it is one folder.
|
|
47
|
+
* @param branch - The branch.
|
|
48
|
+
* @returns The name.
|
|
49
|
+
* @example dirName("feat/42-x") // "feat+42-x"
|
|
50
|
+
*/
|
|
51
|
+
export declare function dirName(branch: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* The branch for a new round of the issue: the proposed one if the issue never used it,
|
|
54
|
+
* or with `-2`, `-3`… until one is free.
|
|
55
|
+
* @param proposed - The proposed branch.
|
|
56
|
+
* @param used - Every branch the issue already used.
|
|
57
|
+
* @returns The branch.
|
|
58
|
+
* @example nextBranch("feat/42-x", ["feat/42-x"]) // "feat/42-x-2"
|
|
59
|
+
*/
|
|
60
|
+
export declare function nextBranch(proposed: string, used: string[]): string;
|
|
61
|
+
/**
|
|
62
|
+
* Fills `{name}` placeholders in one pass, so a value that holds `{plan}` is not filled
|
|
63
|
+
* again. Unknown placeholders are left as they are.
|
|
64
|
+
* @param template - The text.
|
|
65
|
+
* @param values - The values.
|
|
66
|
+
* @returns The text.
|
|
67
|
+
* @example fill("#{number}", { number: "4" }) // "#4"
|
|
68
|
+
*/
|
|
69
|
+
export declare function fill(template: string, values: Record<string, string>): string;
|
|
70
|
+
/**
|
|
71
|
+
* The draft PR's body from the config's template.
|
|
72
|
+
* @param template - The template, with `{keyword}` and `{number}`.
|
|
73
|
+
* @param number - The issue.
|
|
74
|
+
* @param kind - Whether the PR closes the issue or is part of it.
|
|
75
|
+
* @returns The body.
|
|
76
|
+
* @example prBody("{keyword} #{number}", 4, "part") // "Part of #4"
|
|
77
|
+
*/
|
|
78
|
+
export declare function prBody(template: string, number: number, kind: PrKind): string;
|
|
79
|
+
/**
|
|
80
|
+
* The body of a PR with the words that close the issue (`Closes`, `Fixes`, `Resolves` and
|
|
81
|
+
* their forms, with or without `:`) changed to `Part of`, because the issue now has more
|
|
82
|
+
* than one branch. Other numbers that start the same (`#2140`) are left alone.
|
|
83
|
+
* @param body - The PR body.
|
|
84
|
+
* @param number - The issue.
|
|
85
|
+
* @returns The new body.
|
|
86
|
+
* @example bodyToPartOf("Closes #4\n\nx", 4) // "Part of #4\n\nx"
|
|
87
|
+
*/
|
|
88
|
+
export declare function bodyToPartOf(body: string, number: number): string;
|
|
89
|
+
/**
|
|
90
|
+
* The claude.ai link of a cloud session. The link uses `session_` even when the id comes
|
|
91
|
+
* as `cse_`.
|
|
92
|
+
* @param id - The id.
|
|
93
|
+
* @returns The link.
|
|
94
|
+
* @example cloudSessionUrl("cse_01A") // "https://claude.ai/code/session_01A"
|
|
95
|
+
*/
|
|
96
|
+
export declare function cloudSessionUrl(id: string): string;
|
|
97
|
+
/**
|
|
98
|
+
* Whether two ids are the same cloud session (`cse_…` and `session_…` lead to one link).
|
|
99
|
+
* @param one - An id, or null.
|
|
100
|
+
* @param other - The other id.
|
|
101
|
+
* @returns True if they are.
|
|
102
|
+
* @example sameCloudSession("cse_01A", "session_01A") // true
|
|
103
|
+
*/
|
|
104
|
+
export declare function sameCloudSession(one: string | null, other: string): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* The cloud session noted in a PR body, so `--teleport` finds it from any machine.
|
|
107
|
+
* @param body - The PR body.
|
|
108
|
+
* @returns The id, or null.
|
|
109
|
+
* @example cloudSessionOf("Closes #4\n\nCloud session: https://claude.ai/code/session_01A") // "session_01A"
|
|
110
|
+
*/
|
|
111
|
+
export declare function cloudSessionOf(body: string): string | null;
|
|
112
|
+
/**
|
|
113
|
+
* The PR body with the line of a cloud session: it replaces the line of an earlier
|
|
114
|
+
* session (one line stays, the last session that took the issue) or goes at the end.
|
|
115
|
+
* @param body - The PR body.
|
|
116
|
+
* @param id - The session.
|
|
117
|
+
* @returns The new body.
|
|
118
|
+
* @example withCloudSession("Closes #4", "cse_01A") // "Closes #4\n\nCloud session: https://claude.ai/code/session_01A"
|
|
119
|
+
*/
|
|
120
|
+
export declare function withCloudSession(body: string, id: string): string;
|
|
121
|
+
/**
|
|
122
|
+
* The issue a branch belongs to, by its `<n>-` at the start or after a `/` (`feat/214-x`,
|
|
123
|
+
* `214-x`, `ramon/sub/214-x`).
|
|
124
|
+
* @param branch - The branch.
|
|
125
|
+
* @returns The number, or null.
|
|
126
|
+
* @example issueOfBranch("feat/214-x") // 214
|
|
127
|
+
*/
|
|
128
|
+
export declare function issueOfBranch(branch: string): number | null;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Text about an issue for starting it, with no git, GitHub nor Claude: its header for
|
|
3
|
+
* the summary, the session's color and name, the next free branch, the PR body and the
|
|
4
|
+
* prompt Claude opens with. Ported from Abastio's `scripts/lib/issue.sh`.
|
|
5
|
+
*/
|
|
6
|
+
import { BRANCH_HEADING, SLUG_HEADING } from "./branch-name.js";
|
|
7
|
+
/** Lines of the issue's header shown before confirming. */
|
|
8
|
+
export const HEADER_MAX_LINES = 6;
|
|
9
|
+
/** Width the header is wrapped to. */
|
|
10
|
+
export const HEADER_WIDTH = 80;
|
|
11
|
+
/** Prefix of the session's name when it works in a worktree. */
|
|
12
|
+
export const WORKTREE_SESSION_PREFIX = "w-";
|
|
13
|
+
/** Label of the PR body's line with the link of its cloud session. */
|
|
14
|
+
export const CLOUD_SESSION_LABEL = "Cloud session:";
|
|
15
|
+
/** Labels of that line that are still read (Abastio's), never written. */
|
|
16
|
+
const LEGACY_CLOUD_SESSION_LABELS = ["Sesión en la nube:"];
|
|
17
|
+
/** A cloud session id: `session_…`, or `cse_…` as the session sees itself. */
|
|
18
|
+
const CLOUD_SESSION_ID = /\b(?:session|cse)_[A-Za-z0-9]+/;
|
|
19
|
+
/** The issue number in a branch: `<n>-` at the start or after a `/`. */
|
|
20
|
+
const BRANCH_NUMBER = /(?:^|\/)(\d+)-/;
|
|
21
|
+
/**
|
|
22
|
+
* Wraps a line at spaces, like `fold -s`.
|
|
23
|
+
* @param line - The line.
|
|
24
|
+
* @param width - The width.
|
|
25
|
+
* @returns The wrapped lines.
|
|
26
|
+
*/
|
|
27
|
+
function wrap(line, width) {
|
|
28
|
+
const out = [];
|
|
29
|
+
let current = "";
|
|
30
|
+
for (const word of line.split(" ")) {
|
|
31
|
+
const next = current === "" ? word : `${current} ${word}`;
|
|
32
|
+
if (next.length > width && current !== "") {
|
|
33
|
+
out.push(current);
|
|
34
|
+
current = word;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
current = next;
|
|
38
|
+
}
|
|
39
|
+
out.push(current);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The issue's header for the summary: its first paragraph, before any branch block,
|
|
44
|
+
* wrapped and cut to `HEADER_MAX_LINES` lines.
|
|
45
|
+
* @param body - The issue body.
|
|
46
|
+
* @returns The lines, maybe none.
|
|
47
|
+
* @example issueHeader("Qué se pide.\n\nDetalle.") // ["Qué se pide."]
|
|
48
|
+
*/
|
|
49
|
+
export function issueHeader(body) {
|
|
50
|
+
const lines = [];
|
|
51
|
+
for (const line of body.replace(/\r/g, "").split("\n")) {
|
|
52
|
+
if (line.startsWith(SLUG_HEADING) || line.startsWith(BRANCH_HEADING))
|
|
53
|
+
break;
|
|
54
|
+
if (line.trim() === "" && lines.length > 0)
|
|
55
|
+
break;
|
|
56
|
+
if (line.trim() !== "")
|
|
57
|
+
lines.push(...wrap(line.trimEnd(), HEADER_WIDTH));
|
|
58
|
+
}
|
|
59
|
+
return lines.slice(0, HEADER_MAX_LINES);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The session's color and the label that gives it: the first entry of the config's map
|
|
63
|
+
* whose key starts some label, in the map's order (so `critical-path` can win over `P3:`).
|
|
64
|
+
* @param labels - The issue's labels.
|
|
65
|
+
* @param colors - Label prefix to color.
|
|
66
|
+
* @returns The label and color, or null if no label matches.
|
|
67
|
+
* @example issueColor(["P1: urgent"], { "P1:": "red" }) // { label: "P1: urgent", color: "red" }
|
|
68
|
+
*/
|
|
69
|
+
export function issueColor(labels, colors) {
|
|
70
|
+
for (const [prefix, color] of Object.entries(colors)) {
|
|
71
|
+
const label = labels.find((name) => name.startsWith(prefix));
|
|
72
|
+
if (label !== undefined)
|
|
73
|
+
return { label, color };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The Claude session's name: the branch, with `w-` in front in a worktree.
|
|
79
|
+
* @param branch - The branch.
|
|
80
|
+
* @param mode - Where the work happens.
|
|
81
|
+
* @returns The name.
|
|
82
|
+
* @example sessionName("feat/42-x", "worktree") // "w-feat/42-x"
|
|
83
|
+
*/
|
|
84
|
+
export function sessionName(branch, mode) {
|
|
85
|
+
return mode === "worktree" ? `${WORKTREE_SESSION_PREFIX}${branch}` : branch;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The folder name of a branch's worktree and session file: the branch with every `/` as
|
|
89
|
+
* `+`, like `claude --worktree`, so it is one folder.
|
|
90
|
+
* @param branch - The branch.
|
|
91
|
+
* @returns The name.
|
|
92
|
+
* @example dirName("feat/42-x") // "feat+42-x"
|
|
93
|
+
*/
|
|
94
|
+
export function dirName(branch) {
|
|
95
|
+
return branch.replaceAll("/", "+");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The branch for a new round of the issue: the proposed one if the issue never used it,
|
|
99
|
+
* or with `-2`, `-3`… until one is free.
|
|
100
|
+
* @param proposed - The proposed branch.
|
|
101
|
+
* @param used - Every branch the issue already used.
|
|
102
|
+
* @returns The branch.
|
|
103
|
+
* @example nextBranch("feat/42-x", ["feat/42-x"]) // "feat/42-x-2"
|
|
104
|
+
*/
|
|
105
|
+
export function nextBranch(proposed, used) {
|
|
106
|
+
let candidate = proposed;
|
|
107
|
+
for (let round = 2; used.includes(candidate); round += 1)
|
|
108
|
+
candidate = `${proposed}-${round}`;
|
|
109
|
+
return candidate;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Fills `{name}` placeholders in one pass, so a value that holds `{plan}` is not filled
|
|
113
|
+
* again. Unknown placeholders are left as they are.
|
|
114
|
+
* @param template - The text.
|
|
115
|
+
* @param values - The values.
|
|
116
|
+
* @returns The text.
|
|
117
|
+
* @example fill("#{number}", { number: "4" }) // "#4"
|
|
118
|
+
*/
|
|
119
|
+
export function fill(template, values) {
|
|
120
|
+
return template.replace(/\{(\w+)\}/g, (whole, name) => values[name] ?? whole);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The draft PR's body from the config's template.
|
|
124
|
+
* @param template - The template, with `{keyword}` and `{number}`.
|
|
125
|
+
* @param number - The issue.
|
|
126
|
+
* @param kind - Whether the PR closes the issue or is part of it.
|
|
127
|
+
* @returns The body.
|
|
128
|
+
* @example prBody("{keyword} #{number}", 4, "part") // "Part of #4"
|
|
129
|
+
*/
|
|
130
|
+
export function prBody(template, number, kind) {
|
|
131
|
+
return fill(template, { keyword: kind === "part" ? "Part of" : "Closes", number: String(number) });
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The body of a PR with the words that close the issue (`Closes`, `Fixes`, `Resolves` and
|
|
135
|
+
* their forms, with or without `:`) changed to `Part of`, because the issue now has more
|
|
136
|
+
* than one branch. Other numbers that start the same (`#2140`) are left alone.
|
|
137
|
+
* @param body - The PR body.
|
|
138
|
+
* @param number - The issue.
|
|
139
|
+
* @returns The new body.
|
|
140
|
+
* @example bodyToPartOf("Closes #4\n\nx", 4) // "Part of #4\n\nx"
|
|
141
|
+
*/
|
|
142
|
+
export function bodyToPartOf(body, number) {
|
|
143
|
+
const closing = new RegExp(`(^|[^\\w])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\\s+#${number}(?!\\d)`, "gi");
|
|
144
|
+
return body.replace(closing, `$1Part of #${number}`);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The claude.ai link of a cloud session. The link uses `session_` even when the id comes
|
|
148
|
+
* as `cse_`.
|
|
149
|
+
* @param id - The id.
|
|
150
|
+
* @returns The link.
|
|
151
|
+
* @example cloudSessionUrl("cse_01A") // "https://claude.ai/code/session_01A"
|
|
152
|
+
*/
|
|
153
|
+
export function cloudSessionUrl(id) {
|
|
154
|
+
return `https://claude.ai/code/${id.replace(/^cse_/, "session_")}`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Whether two ids are the same cloud session (`cse_…` and `session_…` lead to one link).
|
|
158
|
+
* @param one - An id, or null.
|
|
159
|
+
* @param other - The other id.
|
|
160
|
+
* @returns True if they are.
|
|
161
|
+
* @example sameCloudSession("cse_01A", "session_01A") // true
|
|
162
|
+
*/
|
|
163
|
+
export function sameCloudSession(one, other) {
|
|
164
|
+
return one !== null && cloudSessionUrl(one) === cloudSessionUrl(other);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Whether a line of a PR body is the one with the cloud session.
|
|
168
|
+
* @param text - The line.
|
|
169
|
+
* @returns True if it is.
|
|
170
|
+
*/
|
|
171
|
+
function isSessionLine(text) {
|
|
172
|
+
return [CLOUD_SESSION_LABEL, ...LEGACY_CLOUD_SESSION_LABELS].some((label) => text.includes(label));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The cloud session noted in a PR body, so `--teleport` finds it from any machine.
|
|
176
|
+
* @param body - The PR body.
|
|
177
|
+
* @returns The id, or null.
|
|
178
|
+
* @example cloudSessionOf("Closes #4\n\nCloud session: https://claude.ai/code/session_01A") // "session_01A"
|
|
179
|
+
*/
|
|
180
|
+
export function cloudSessionOf(body) {
|
|
181
|
+
const found = body.split("\n").find(isSessionLine);
|
|
182
|
+
return found === undefined ? null : (CLOUD_SESSION_ID.exec(found)?.[0] ?? null);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The PR body with the line of a cloud session: it replaces the line of an earlier
|
|
186
|
+
* session (one line stays, the last session that took the issue) or goes at the end.
|
|
187
|
+
* @param body - The PR body.
|
|
188
|
+
* @param id - The session.
|
|
189
|
+
* @returns The new body.
|
|
190
|
+
* @example withCloudSession("Closes #4", "cse_01A") // "Closes #4\n\nCloud session: https://claude.ai/code/session_01A"
|
|
191
|
+
*/
|
|
192
|
+
export function withCloudSession(body, id) {
|
|
193
|
+
const note = `${CLOUD_SESSION_LABEL} ${cloudSessionUrl(id)}`;
|
|
194
|
+
const lines = body.split("\n");
|
|
195
|
+
const first = lines.findIndex(isSessionLine);
|
|
196
|
+
if (first === -1)
|
|
197
|
+
return body === "" ? note : `${body.replace(/\n+$/, "")}\n\n${note}`;
|
|
198
|
+
return lines.flatMap((text, index) => (index === first ? [note] : isSessionLine(text) ? [] : [text])).join("\n");
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The issue a branch belongs to, by its `<n>-` at the start or after a `/` (`feat/214-x`,
|
|
202
|
+
* `214-x`, `ramon/sub/214-x`).
|
|
203
|
+
* @param branch - The branch.
|
|
204
|
+
* @returns The number, or null.
|
|
205
|
+
* @example issueOfBranch("feat/214-x") // 214
|
|
206
|
+
*/
|
|
207
|
+
export function issueOfBranch(branch) {
|
|
208
|
+
const found = BRANCH_NUMBER.exec(branch);
|
|
209
|
+
return found?.[1] === undefined ? null : Number(found[1]);
|
|
210
|
+
}
|
package/dist/launch.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type StartMode } from "./issue-text.js";
|
|
2
|
+
/** The file `claude-repo-guard` writes with the auto mode's classifier context. */
|
|
3
|
+
export declare const AUTO_MODE_FILE = ".claude/repo-guard.auto-mode.json";
|
|
4
|
+
/** Folder, inside the shared `.git`, with one file per branch holding its session id. */
|
|
5
|
+
export declare const SESSIONS_DIR = "issue-flow-sessions";
|
|
6
|
+
/** How Claude asks for permissions: chosen by the flags, or the default. */
|
|
7
|
+
export type Permissions = "default" | "ask" | "free";
|
|
8
|
+
/** The arguments of the permission mode, and how the summary names it. */
|
|
9
|
+
export interface PermissionArgs {
|
|
10
|
+
args: string[];
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The arguments for the permission mode. By default, auto mode with the classifier context
|
|
15
|
+
* of `claude-repo-guard` if the repo has it (relative, from the folder where Claude runs,
|
|
16
|
+
* if it is there; or else by its full path in the main checkout), and plain `claude` if it
|
|
17
|
+
* does not.
|
|
18
|
+
* @param permissions - What the flags asked for.
|
|
19
|
+
* @param workDir - Where Claude runs, or null if that folder does not exist yet.
|
|
20
|
+
* @param mainRoot - The main checkout.
|
|
21
|
+
* @returns The arguments and their label.
|
|
22
|
+
* @example permissionArgs("default", wt, root).args // ["--settings", ".claude/repo-guard.auto-mode.json", "--permission-mode", "auto"]
|
|
23
|
+
*/
|
|
24
|
+
export declare function permissionArgs(permissions: Permissions, workDir: string | null, mainRoot: string): PermissionArgs;
|
|
25
|
+
/**
|
|
26
|
+
* Whether a program is in the PATH, without running it.
|
|
27
|
+
* @param name - The program.
|
|
28
|
+
* @returns True if it is.
|
|
29
|
+
* @example inPath("claude")
|
|
30
|
+
*/
|
|
31
|
+
export declare function inPath(name: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The file that holds a branch's session id.
|
|
34
|
+
* @param commonDir - The shared `.git`.
|
|
35
|
+
* @param branch - The branch.
|
|
36
|
+
* @returns The path.
|
|
37
|
+
* @example sessionFile("/repo/.git", "feat/42-x") // "/repo/.git/issue-flow-sessions/feat+42-x"
|
|
38
|
+
*/
|
|
39
|
+
export declare function sessionFile(commonDir: string, branch: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Whether Claude Code has a saved session with that id.
|
|
42
|
+
* @param id - The id.
|
|
43
|
+
* @returns True if it does.
|
|
44
|
+
* @example sessionExists("11111111-2222-3333-4444-555555555555")
|
|
45
|
+
*/
|
|
46
|
+
export declare function sessionExists(id: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The saved session of a branch, if Claude still has it.
|
|
49
|
+
* @param commonDir - The shared `.git`.
|
|
50
|
+
* @param branch - The branch.
|
|
51
|
+
* @returns The id, or null.
|
|
52
|
+
* @example savedSession("/repo/.git", "feat/42-x")
|
|
53
|
+
*/
|
|
54
|
+
export declare function savedSession(commonDir: string, branch: string): string | null;
|
|
55
|
+
/**
|
|
56
|
+
* Forgets a branch's session id, once its work is gone.
|
|
57
|
+
* @param commonDir - The shared `.git`.
|
|
58
|
+
* @param branch - The branch.
|
|
59
|
+
* @example forgetSession("/repo/.git", "feat/42-x")
|
|
60
|
+
*/
|
|
61
|
+
export declare function forgetSession(commonDir: string, branch: string): void;
|
|
62
|
+
/** What opening Claude needs. */
|
|
63
|
+
export interface LaunchPlan {
|
|
64
|
+
workDir: string;
|
|
65
|
+
commonDir: string;
|
|
66
|
+
branch: string;
|
|
67
|
+
mode: StartMode;
|
|
68
|
+
prompt: string;
|
|
69
|
+
color: string | null;
|
|
70
|
+
permissions: string[];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Opens Claude: resumes the branch's saved session or creates one with its name, its color
|
|
74
|
+
* and the prompt. The color is set first with a `claude -p "/color …"` on the new session,
|
|
75
|
+
* because a session only takes it from a command.
|
|
76
|
+
* @param plan - Where, which branch and with what.
|
|
77
|
+
* @param warn - Where a warning goes.
|
|
78
|
+
* @example launchSession(plan, (text) => console.error(text))
|
|
79
|
+
*/
|
|
80
|
+
export declare function launchSession(plan: LaunchPlan, warn: (text: string) => void): void;
|
|
81
|
+
/**
|
|
82
|
+
* Brings a cloud session to the folder with `claude --teleport`: the one noted in the PR,
|
|
83
|
+
* or the list of sessions to choose from if there is none. It keeps no session id: once
|
|
84
|
+
* brought, `claude --resume` finds it.
|
|
85
|
+
* @param workDir - Where.
|
|
86
|
+
* @param permissions - The arguments of the permission mode.
|
|
87
|
+
* @param id - The cloud session, or null for the list.
|
|
88
|
+
* @example teleportSession(wt, [], "session_01A")
|
|
89
|
+
*/
|
|
90
|
+
export declare function teleportSession(workDir: string, permissions: string[], id: string | null): void;
|