@ramon-sg/issue-flow 0.0.0-stage → 0.1.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 +304 -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 +132 -0
- package/dist/cli.js +348 -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 +10 -0
- package/dist/commands/doctor.js +124 -0
- package/dist/commands/init.d.ts +27 -0
- package/dist/commands/init.js +213 -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 +115 -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/guard-config.d.ts +16 -0
- package/dist/guard-config.js +38 -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 +89 -0
- package/dist/setup-files.js +138 -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
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `issue-flow issue start <n>`: starts or resumes the work on an issue and opens
|
|
3
|
+
* Claude in the same terminal. It reads the issue and its work, picks the branch (the one
|
|
4
|
+
* it has, or a new one named like `issue branch`), shows a summary and asks, then creates
|
|
5
|
+
* what is missing (worktree or branch, start commit, push, draft PR, assignment) and opens
|
|
6
|
+
* Claude with the repo's permission mode. With `--plan` it only prints the branch and the
|
|
7
|
+
* prompt as one line of JSON. `--adopt`, `--here`, `--cloud` and `--teleport` are in
|
|
8
|
+
* `start-modes.ts`. Ported from Abastio's `scripts/start-issue.sh`.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { readWork, resolveRepo, viewIssue } from "@ramon-sg/github-rest";
|
|
12
|
+
import { ask, confirm, forgetTypedAhead } from "../ask.js";
|
|
13
|
+
import { branchType } from "../branch-name.js";
|
|
14
|
+
import { checkedOutAt, checkoutRoot, git, gitLines, locateRepo, onOrigin, ownCommits } from "../git.js";
|
|
15
|
+
import { fill, issueColor, nextBranch } from "../issue-text.js";
|
|
16
|
+
import { inPath, launchSession, permissionArgs, teleportSession } from "../launch.js";
|
|
17
|
+
import { emitJson, warn } from "../output.js";
|
|
18
|
+
import { findOpenPlan, findPlan } from "../plan-doc.js";
|
|
19
|
+
import { proposeBranches } from "../propose.js";
|
|
20
|
+
import { lockWorktree, unlockWorktree } from "../session-lock.js";
|
|
21
|
+
import { afterSession, choosePlace, copyWorktreeIncludes, prepareBranch, publish } from "../start-place.js";
|
|
22
|
+
import { chooseAdopted, fetchOrigin, finishHere, launchCloud, placeAdopted, placeCloud, placeHere, prCloudSession, prepareHere, readAdopted, renameAdopted, withoutFresh, } from "../start-modes.js";
|
|
23
|
+
import { again, StartError } from "../start-state.js";
|
|
24
|
+
import { describeBranch, stepPlaceOf, summary } from "../start-summary.js";
|
|
25
|
+
import { runSetup } from "../worktree-steps.js";
|
|
26
|
+
import { branchPr, workState } from "../work-state.js";
|
|
27
|
+
/**
|
|
28
|
+
* Adds a warning: prints it and keeps it for `--plan`'s output.
|
|
29
|
+
* @param context - The start.
|
|
30
|
+
* @param text - The warning.
|
|
31
|
+
*/
|
|
32
|
+
function note(context, text) {
|
|
33
|
+
context.warnings.push(text);
|
|
34
|
+
warn(context.out, text);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Reads the issue and its work: stops if the checkout's origin is not the repo (the branch
|
|
38
|
+
* would be pushed to one repo and the PR opened in another), or the issue is missing or
|
|
39
|
+
* closed; fetches origin
|
|
40
|
+
* (stops if it fails, because the new branch comes from it) and reads the PRs and branches
|
|
41
|
+
* with github-rest. A branch of `claude --worktree` stops it too: that session has its
|
|
42
|
+
* own flow. With `--adopt` it reads the worktree's branch, and a fresh one is not counted
|
|
43
|
+
* as the issue's work.
|
|
44
|
+
* @param options - The options.
|
|
45
|
+
* @param out - Where to write.
|
|
46
|
+
* @returns What was read.
|
|
47
|
+
* @throws StartError if something stops the start; GitHubError if GitHub fails.
|
|
48
|
+
*/
|
|
49
|
+
async function readContext(options, out) {
|
|
50
|
+
const located = locateRepo(options.cwd);
|
|
51
|
+
const startRoot = checkoutRoot(options.cwd);
|
|
52
|
+
if (located === null || startRoot === null)
|
|
53
|
+
throw new StartError("run this inside a checkout of the repo.");
|
|
54
|
+
const { number, repo, config } = options;
|
|
55
|
+
const origin = resolveRepo(undefined, located.mainRoot, {});
|
|
56
|
+
if (origin === null || origin.toLowerCase() !== repo.toLowerCase())
|
|
57
|
+
throw new StartError(`this checkout's origin is ${origin ?? "not on GitHub"}, not ${repo}: the branch would be pushed to another repo. Run it in a checkout of ${repo}.`);
|
|
58
|
+
const issue = await viewIssue(repo, number);
|
|
59
|
+
if (issue === null)
|
|
60
|
+
throw new StartError(`issue #${number} does not exist in ${repo}.`);
|
|
61
|
+
if (issue.state !== "OPEN")
|
|
62
|
+
throw new StartError(`issue #${number} is ${issue.state}; not starting it.`);
|
|
63
|
+
const warnings = [];
|
|
64
|
+
const labels = issue.labels.map(({ name }) => name);
|
|
65
|
+
if (branchType(config.branch.types, labels, issue.title) === null)
|
|
66
|
+
note({ out, warnings }, `issue #${number} has no type label.`);
|
|
67
|
+
if (!fetchOrigin(located.mainRoot, options.here))
|
|
68
|
+
throw new StartError("could not fetch origin.");
|
|
69
|
+
const read = await readWork(repo, [number], { cwd: located.mainRoot });
|
|
70
|
+
for (const text of read.warnings)
|
|
71
|
+
note({ out, warnings }, text);
|
|
72
|
+
const entry = read.output.issues[0];
|
|
73
|
+
if (entry === undefined || "error" in entry)
|
|
74
|
+
throw new StartError(`issue #${number} does not exist in ${repo}.`);
|
|
75
|
+
const claudeBranches = gitLines(located.mainRoot, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
|
76
|
+
.filter((name) => new RegExp(`^worktree-[a-z]+\\+${number}-`).test(name));
|
|
77
|
+
if (claudeBranches.length > 0)
|
|
78
|
+
throw new StartError(`the issue has a 'claude --worktree' branch (${claudeBranches.join(", ")}): open that session instead.`);
|
|
79
|
+
const base = config.baseBranch ?? read.output.defaultBranch ?? "main";
|
|
80
|
+
const adopted = options.adopt ? readAdopted(startRoot, located.mainRoot, base) : null;
|
|
81
|
+
const work = adopted === null ? workState(entry) : withoutFresh(workState(entry), adopted);
|
|
82
|
+
if (work.forks.length > 0)
|
|
83
|
+
note({ out, warnings }, `the issue has PRs from a fork (${work.forks.map((pr) => `#${pr}`).join(", ")}): they are not resumed from here.`);
|
|
84
|
+
return { options, out, ...located, startRoot, base, issue, labels, work, warnings, adopted };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The new branch: named like `issue branch` (the slug block, Claude or the title), with
|
|
88
|
+
* `-2`, `-3`… if the issue already used that name.
|
|
89
|
+
* @param context - The start.
|
|
90
|
+
* @returns The branch.
|
|
91
|
+
*/
|
|
92
|
+
function proposeNew(context) {
|
|
93
|
+
const { issue, options } = context;
|
|
94
|
+
const info = { number: issue.number, title: issue.title, body: issue.body, labels: context.labels };
|
|
95
|
+
const { proposals, warnings } = proposeBranches([info], options.config, {
|
|
96
|
+
cwd: context.mainRoot,
|
|
97
|
+
...(options.namer === undefined ? {} : { namer: options.namer }),
|
|
98
|
+
});
|
|
99
|
+
const [proposal] = proposals;
|
|
100
|
+
if (proposal === undefined)
|
|
101
|
+
throw new StartError("could not propose a branch.");
|
|
102
|
+
for (const text of [...warnings, ...proposal.warnings])
|
|
103
|
+
note(context, text);
|
|
104
|
+
return nextBranch(proposal.branch, context.work.known);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Asks in the terminal which live branch to continue, or whether to make another.
|
|
108
|
+
* @param context - The start.
|
|
109
|
+
* @param describe - Describes a branch.
|
|
110
|
+
* @param proposed - The new branch.
|
|
111
|
+
* @returns The chosen branch, or null for a new one.
|
|
112
|
+
* @throws StartError if the answer is none of the options.
|
|
113
|
+
*/
|
|
114
|
+
async function pickBranch(context, describe, proposed) {
|
|
115
|
+
const { candidates } = context.work;
|
|
116
|
+
const lines = candidates.map((branch, index) => ` ${index + 1}) ${describe(branch)}\n`);
|
|
117
|
+
context.out.stderr(`Issue #${context.issue.number} already has ${candidates.length} branches:\n${lines.join("")} n) another new branch: ${proposed}\n`);
|
|
118
|
+
const answer = await ask(context.out, `Which one? [1-${candidates.length}/n] `);
|
|
119
|
+
if (/^n$/i.test(answer))
|
|
120
|
+
return null;
|
|
121
|
+
const chosen = /^\d+$/.test(answer) ? candidates[Number(answer) - 1] : undefined;
|
|
122
|
+
if (chosen === undefined)
|
|
123
|
+
throw new StartError("did nothing.");
|
|
124
|
+
return chosen;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The live branch to resume, or null to create a new one. `--new` creates; `--resume`
|
|
128
|
+
* takes that one if it is the issue's. No branches creates and one is resumed. With
|
|
129
|
+
* several: `--dry-run` and `--plan` propose a new one and warn, `--yes` stops, and the
|
|
130
|
+
* terminal asks.
|
|
131
|
+
* @param context - The start.
|
|
132
|
+
* @param proposed - Gives the new branch, only when it is needed.
|
|
133
|
+
* @returns The branch, or null.
|
|
134
|
+
* @throws StartError if `--resume` is not the issue's, or there are several with `--yes`.
|
|
135
|
+
*/
|
|
136
|
+
async function chooseExisting(context, proposed) {
|
|
137
|
+
const { options, work } = context;
|
|
138
|
+
const list = work.candidates.join(", ");
|
|
139
|
+
if (options.forceNew)
|
|
140
|
+
return null;
|
|
141
|
+
if (options.resume !== null) {
|
|
142
|
+
if (!work.candidates.includes(options.resume))
|
|
143
|
+
throw new StartError(`branch ${options.resume} is not one of issue #${options.number}'s. Its branches: ${list || "none"}`);
|
|
144
|
+
return options.resume;
|
|
145
|
+
}
|
|
146
|
+
if (work.candidates.length <= 1)
|
|
147
|
+
return work.candidates[0] ?? null;
|
|
148
|
+
const count = work.candidates.length;
|
|
149
|
+
if (options.plan || options.dryRun) {
|
|
150
|
+
note(context, `the issue already has ${count} branches (${list}): proposing a new one. To resume one: ${again(options.number, "--resume <branch>")}.`);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (options.yes)
|
|
154
|
+
throw new StartError(`the issue has ${count} branches (${list}): choose with --resume <branch> or --new.`);
|
|
155
|
+
const partial = { ...context, found: null, branch: "", pr: null, kind: "closes", mode: "worktree", workDir: "", plan: null, cloudSession: null };
|
|
156
|
+
return pickBranch(context, (branch) => describeBranch(partial, branch, true), proposed());
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The PR of the branch being resumed. Stops if the branch is neither local nor in origin:
|
|
160
|
+
* with an open PR, it says how to recreate it; otherwise the local refs of origin are
|
|
161
|
+
* stale.
|
|
162
|
+
* @param context - The start.
|
|
163
|
+
* @param branch - The branch.
|
|
164
|
+
* @returns The PR, or null.
|
|
165
|
+
* @throws StartError if the branch is gone.
|
|
166
|
+
*/
|
|
167
|
+
function resumedPr(context, branch) {
|
|
168
|
+
const pr = branchPr(context.work.prs, branch);
|
|
169
|
+
const exists = git(context.mainRoot, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]).ok || onOrigin(context.mainRoot, branch);
|
|
170
|
+
if (exists)
|
|
171
|
+
return pr;
|
|
172
|
+
if (pr?.state === "OPEN")
|
|
173
|
+
throw new StartError(`PR #${pr.number} is still open and points to branch ${branch}, which is gone locally and in origin. Recreate it with 'git fetch origin pull/${pr.number}/head:${branch}' or close that PR and start again.`);
|
|
174
|
+
throw new StartError(`branch ${branch} is on GitHub but not in the local refs of origin, which look stale: run 'git fetch origin' and try again.`);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Where plans are looked for.
|
|
178
|
+
* @param context - The start.
|
|
179
|
+
* @returns The query.
|
|
180
|
+
*/
|
|
181
|
+
function planQuery(context) {
|
|
182
|
+
return { cwd: context.mainRoot, base: context.base, repo: context.options.repo, dirs: context.options.config.plans.dirs };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The prompt Claude opens with, or `--plan` gives.
|
|
186
|
+
* @param context - The start.
|
|
187
|
+
* @param plan - The issue's plan, or null.
|
|
188
|
+
* @param onlySummary - Whether it only summarizes and waits (`--plan`).
|
|
189
|
+
* @returns The prompt.
|
|
190
|
+
*/
|
|
191
|
+
function promptFor(context, plan, onlySummary) {
|
|
192
|
+
const { start } = context.options.config;
|
|
193
|
+
const template = onlySummary
|
|
194
|
+
? plan === null ? start.summaryPrompt : start.summaryPlanPrompt
|
|
195
|
+
: plan === null ? start.prompt : start.resumePrompt;
|
|
196
|
+
return fill(template, { number: String(context.issue.number), title: context.issue.title, plan: plan ?? "" });
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* `--plan`: prints the branch and the prompt as one line of JSON, without changing
|
|
200
|
+
* anything. A branch already checked out stops it, because the caller would make a second
|
|
201
|
+
* checkout. A local branch with no commits, PR nor push is what an earlier attempt left:
|
|
202
|
+
* it counts as `new`.
|
|
203
|
+
* @param context - The start.
|
|
204
|
+
* @param found - The branch being resumed, or null.
|
|
205
|
+
* @param branch - The branch.
|
|
206
|
+
* @param pr - Its PR.
|
|
207
|
+
* @returns The exit code.
|
|
208
|
+
* @throws StartError if the branch is checked out.
|
|
209
|
+
*/
|
|
210
|
+
function printPlan(context, found, branch, pr) {
|
|
211
|
+
const at = checkedOutAt(context.mainRoot, branch);
|
|
212
|
+
if (at !== null)
|
|
213
|
+
throw new StartError(`the issue already has branch ${branch} (at ${at}): continue there with '${again(context.options.number)}'.`);
|
|
214
|
+
const empty = found !== null && pr === null && !onOrigin(context.mainRoot, branch) && ownCommits(context.mainRoot, branch, context.base) === 0;
|
|
215
|
+
const resumed = found !== null && !empty;
|
|
216
|
+
const plan = resumed
|
|
217
|
+
? findPlan(planQuery(context), branch, pr?.number ?? null)
|
|
218
|
+
: findOpenPlan(planQuery(context), context.work.mergedPrs);
|
|
219
|
+
emitJson(context.out, {
|
|
220
|
+
action: resumed ? "resume" : "new",
|
|
221
|
+
branch,
|
|
222
|
+
prompt: promptFor(context, plan, true),
|
|
223
|
+
url: context.issue.url,
|
|
224
|
+
title: context.issue.title,
|
|
225
|
+
warnings: context.warnings,
|
|
226
|
+
claudeArgs: permissionArgs(context.options.permissions, null, context.mainRoot).args,
|
|
227
|
+
});
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The question before acting, and what to say if the answer is no.
|
|
232
|
+
* @param state - The start.
|
|
233
|
+
* @returns The question and the message.
|
|
234
|
+
*/
|
|
235
|
+
function question(state) {
|
|
236
|
+
const { work, found } = state;
|
|
237
|
+
if (found !== null)
|
|
238
|
+
return { text: "Continue with this work?", declined: "did nothing." };
|
|
239
|
+
if (work.candidates.length > 0)
|
|
240
|
+
return { text: "Create another branch for the issue?", declined: "did nothing." };
|
|
241
|
+
if (work.mergedPrs.length > 0)
|
|
242
|
+
return {
|
|
243
|
+
text: `The issue already has merged PR ${work.mergedPrs.map((pr) => `#${pr}`).join(", ")}. Start a new round?`,
|
|
244
|
+
declined: "did nothing. If the issue is done, close it.",
|
|
245
|
+
};
|
|
246
|
+
return { text: "Start the issue?", declined: "did nothing." };
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Opens Claude in the place, locking a worktree meanwhile, and then offers the cleanup.
|
|
250
|
+
* Without `claude` or with `--no-launch`, says how to open it.
|
|
251
|
+
* @param state - The start.
|
|
252
|
+
* @returns The exit code.
|
|
253
|
+
*/
|
|
254
|
+
async function openSession(state) {
|
|
255
|
+
const { options, out } = state;
|
|
256
|
+
if (!options.launch || !inPath("claude")) {
|
|
257
|
+
out.stdout(`Ready. To open the Claude session: ${again(options.number)}\n`);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
const lock = state.mode === "worktree"
|
|
261
|
+
? lockWorktree(state.mainRoot, state.workDir, (text) => warn(out, text))
|
|
262
|
+
: { mine: false, shared: false };
|
|
263
|
+
const release = () => unlockWorktree(state.mainRoot, state.workDir, lock);
|
|
264
|
+
process.once("exit", release);
|
|
265
|
+
forgetTypedAhead();
|
|
266
|
+
const permissions = permissionArgs(options.permissions, state.workDir, state.mainRoot).args;
|
|
267
|
+
if (options.teleport) {
|
|
268
|
+
out.stdout(`Bringing the cloud session${state.cloudSession === null ? "" : ` (${state.cloudSession})`}. To resume it later: claude --resume\n`);
|
|
269
|
+
teleportSession(state.workDir, permissions, state.cloudSession);
|
|
270
|
+
}
|
|
271
|
+
else
|
|
272
|
+
launchSession({
|
|
273
|
+
workDir: state.workDir,
|
|
274
|
+
commonDir: state.commonDir,
|
|
275
|
+
branch: state.branch,
|
|
276
|
+
mode: state.mode,
|
|
277
|
+
prompt: promptFor(state, state.plan, false),
|
|
278
|
+
color: issueColor(state.labels, options.config.colors)?.color ?? null,
|
|
279
|
+
permissions,
|
|
280
|
+
}, (text) => warn(out, text));
|
|
281
|
+
release();
|
|
282
|
+
process.off("exit", release);
|
|
283
|
+
await afterSession(state, lock);
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* `issue start <n>`.
|
|
288
|
+
* @param options - The options.
|
|
289
|
+
* @param out - Where to write.
|
|
290
|
+
* @returns The exit code.
|
|
291
|
+
* @throws StartError if something stops the start; GitHubError if GitHub fails.
|
|
292
|
+
* @example await issueStart(options, processOutput())
|
|
293
|
+
*/
|
|
294
|
+
export async function issueStart(options, out) {
|
|
295
|
+
const context = await readContext(options, out);
|
|
296
|
+
let proposed = null;
|
|
297
|
+
const propose = () => (proposed ??= proposeNew(context));
|
|
298
|
+
const chosen = context.adopted === null
|
|
299
|
+
? { found: await chooseExisting(context, propose), named: null }
|
|
300
|
+
: chooseAdopted(context.mainRoot, context.work, context.adopted, options.number);
|
|
301
|
+
const { found } = chosen;
|
|
302
|
+
const branch = found ?? chosen.named ?? propose();
|
|
303
|
+
const pr = found === null ? null : resumedPr(context, branch);
|
|
304
|
+
const kind = context.work.candidates.some((other) => other !== branch) ? "part" : "closes";
|
|
305
|
+
if (options.plan)
|
|
306
|
+
return printPlan(context, found, branch, pr);
|
|
307
|
+
if (options.teleport && found === null)
|
|
308
|
+
throw new StartError(`issue #${options.number} has no branch: start it with '${again(options.number, "--cloud")}' (or without flags).`);
|
|
309
|
+
const plan = found !== null
|
|
310
|
+
? findPlan(planQuery(context), branch, pr?.number ?? null)
|
|
311
|
+
: findOpenPlan(planQuery(context), context.work.mergedPrs);
|
|
312
|
+
const cloudSession = await prCloudSession(options.repo, pr?.number ?? null);
|
|
313
|
+
const state = { ...context, found, branch, pr, kind, ...placeOf(context, branch, found), plan, cloudSession };
|
|
314
|
+
out.stdout(summary(state, permissionArgs(options.permissions, existsSync(state.workDir) ? state.workDir : null, context.mainRoot)));
|
|
315
|
+
if (options.dryRun)
|
|
316
|
+
return 0;
|
|
317
|
+
const asked = question(state);
|
|
318
|
+
if (!options.yes && state.adopted === null && !(await confirm(out, asked.text)))
|
|
319
|
+
throw new StartError(asked.declined);
|
|
320
|
+
if (state.mode === "cloud")
|
|
321
|
+
return launchCloud(state, out);
|
|
322
|
+
if (state.adopted !== null)
|
|
323
|
+
renameAdopted(state);
|
|
324
|
+
else if (state.mode === "here")
|
|
325
|
+
prepareHere(state);
|
|
326
|
+
else
|
|
327
|
+
prepareBranch(state);
|
|
328
|
+
const prNumber = await publish(state);
|
|
329
|
+
if (state.mode === "here") {
|
|
330
|
+
await finishHere(state, prNumber, promptFor(state, plan, false));
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
if (state.adopted !== null) {
|
|
334
|
+
out.stdout(`Ready: issue #${options.number} is on branch ${branch}, with its draft PR.\n${plan === null ? "" : `Open plan of this issue, to resume: ${plan}\n`}`);
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
if (state.mode === "worktree") {
|
|
338
|
+
copyWorktreeIncludes(context.mainRoot, state.workDir, out);
|
|
339
|
+
await runSetup(stepPlaceOf(state), null);
|
|
340
|
+
}
|
|
341
|
+
return openSession(state);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Where the work goes, by the flags: the adopted worktree, this checkout (`--here`), the
|
|
345
|
+
* cloud (`--cloud`), or a worktree or the main checkout.
|
|
346
|
+
* @param context - The start.
|
|
347
|
+
* @param branch - The branch.
|
|
348
|
+
* @param found - The branch being resumed, or null.
|
|
349
|
+
* @returns The mode and the folder.
|
|
350
|
+
* @throws StartError if the place is taken.
|
|
351
|
+
*/
|
|
352
|
+
function placeOf(context, branch, found) {
|
|
353
|
+
const { options, mainRoot, adopted } = context;
|
|
354
|
+
if (adopted !== null)
|
|
355
|
+
return placeAdopted({ branch, startRoot: context.startRoot, adopted });
|
|
356
|
+
if (options.here)
|
|
357
|
+
return placeHere(mainRoot, context.startRoot, branch, options.number);
|
|
358
|
+
if (options.cloud)
|
|
359
|
+
return placeCloud(mainRoot, branch, found, options.number);
|
|
360
|
+
return choosePlace(mainRoot, branch, options.branchMode, options.config.worktrees.dir);
|
|
361
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { FlowConfig } from "../config.js";
|
|
2
|
+
import { type Output } from "../output.js";
|
|
3
|
+
/** Options of `issue-flow pr worktree`. */
|
|
4
|
+
export interface PrWorktreeOptions {
|
|
5
|
+
repo: string;
|
|
6
|
+
config: FlowConfig;
|
|
7
|
+
cwd: string;
|
|
8
|
+
number: number;
|
|
9
|
+
setup: boolean;
|
|
10
|
+
command: string[] | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The folder of a PR's worktree: `pr-<n>` in the config's worktrees folder.
|
|
14
|
+
* @param mainRoot - The main checkout.
|
|
15
|
+
* @param config - The config.
|
|
16
|
+
* @param number - The PR.
|
|
17
|
+
* @returns The path.
|
|
18
|
+
* @example prWorktreePath("/repo", config, 7) // "/repo/.claude/worktrees/pr-7"
|
|
19
|
+
*/
|
|
20
|
+
export declare function prWorktreePath(mainRoot: string, config: FlowConfig, number: number): string;
|
|
21
|
+
/**
|
|
22
|
+
* `pr worktree <n> [--no-setup] [-- <command>]`: makes or reuses the PR's worktree and
|
|
23
|
+
* prints where it is; with `--`, runs the command there (Claude, with the repo's permission
|
|
24
|
+
* mode, if it names none) and exits with its code.
|
|
25
|
+
* @param options - The options.
|
|
26
|
+
* @param out - Where to write.
|
|
27
|
+
* @returns The exit code.
|
|
28
|
+
* @throws StartError if the PR does not exist, comes from a fork or cannot be checked out.
|
|
29
|
+
* @example await prWorktree({ ...options, number: 7 }, out)
|
|
30
|
+
*/
|
|
31
|
+
export declare function prWorktree(options: PrWorktreeOptions, out: Output): Promise<number>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `issue-flow pr worktree <n>`: a PR in its own worktree, to review it without
|
|
3
|
+
* leaving what you are doing. The worktree is on the PR's branch, tracking origin, so what
|
|
4
|
+
* you commit and push updates the PR. It copies `.worktreeinclude` and runs the setup
|
|
5
|
+
* steps, but a `trusted` step (an install) is skipped, because the PR's code is not in the
|
|
6
|
+
* base branch yet: it says what to run once you checked it. It never touches the main
|
|
7
|
+
* checkout, and a PR from a fork is refused, since its branch is not in origin. Cleaning up
|
|
8
|
+
* is `worktree prune`, like every other worktree. Ported from Clean Queen's
|
|
9
|
+
* `scripts/pr-worktree.sh`.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { getPr } from "@ramon-sg/github-rest";
|
|
15
|
+
import { canonicalPath } from "../docker.js";
|
|
16
|
+
import { checkedOutAt, git, hasLocal, locateRepo, worktrees } from "../git.js";
|
|
17
|
+
import { permissionArgs } from "../launch.js";
|
|
18
|
+
import { warn } from "../output.js";
|
|
19
|
+
import { copyWorktreeIncludes } from "../start-place.js";
|
|
20
|
+
import { StartError } from "../start-state.js";
|
|
21
|
+
import { runSetup } from "../worktree-steps.js";
|
|
22
|
+
import { baseBranch } from "./worktree.js";
|
|
23
|
+
/**
|
|
24
|
+
* The folder of a PR's worktree: `pr-<n>` in the config's worktrees folder.
|
|
25
|
+
* @param mainRoot - The main checkout.
|
|
26
|
+
* @param config - The config.
|
|
27
|
+
* @param number - The PR.
|
|
28
|
+
* @returns The path.
|
|
29
|
+
* @example prWorktreePath("/repo", config, 7) // "/repo/.claude/worktrees/pr-7"
|
|
30
|
+
*/
|
|
31
|
+
export function prWorktreePath(mainRoot, config, number) {
|
|
32
|
+
return join(mainRoot, config.worktrees.dir, `pr-${number}`);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Runs git and stops with the message if it fails.
|
|
36
|
+
* @param cwd - Where.
|
|
37
|
+
* @param args - The arguments.
|
|
38
|
+
* @param message - What failed, for the error.
|
|
39
|
+
* @throws StartError if git fails.
|
|
40
|
+
*/
|
|
41
|
+
function mustGit(cwd, args, message) {
|
|
42
|
+
const result = git(cwd, args);
|
|
43
|
+
if (!result.ok)
|
|
44
|
+
throw new StartError(`${message}: ${result.err || result.out}`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Makes the worktree on the PR's branch: the local branch if there is one (moved forward to
|
|
48
|
+
* origin's when it can be), or a new one tracking origin's.
|
|
49
|
+
* @param mainRoot - The main checkout.
|
|
50
|
+
* @param branch - The PR's branch.
|
|
51
|
+
* @param path - The worktree.
|
|
52
|
+
* @param out - Where a warning goes.
|
|
53
|
+
* @throws StartError if git cannot make it, or the branch is checked out somewhere else.
|
|
54
|
+
*/
|
|
55
|
+
function addWorktree(mainRoot, branch, path, out) {
|
|
56
|
+
mustGit(mainRoot, ["fetch", "-q", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`], `could not fetch ${branch} from origin`);
|
|
57
|
+
const elsewhere = checkedOutAt(mainRoot, branch);
|
|
58
|
+
if (elsewhere !== null)
|
|
59
|
+
throw new StartError(`${branch} is checked out in ${elsewhere}; review it there, or switch that checkout to another branch.`);
|
|
60
|
+
if (!hasLocal(mainRoot, branch)) {
|
|
61
|
+
mustGit(mainRoot, ["worktree", "add", "-q", "--track", "-b", branch, path, `origin/${branch}`], `could not make the worktree of ${branch}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
mustGit(mainRoot, ["worktree", "add", "-q", path, branch], `could not make the worktree of ${branch}`);
|
|
65
|
+
if (!git(path, ["branch", "-q", "--set-upstream-to", `origin/${branch}`]).ok)
|
|
66
|
+
warn(out, `could not make ${branch} track origin/${branch}.`);
|
|
67
|
+
if (!git(path, ["merge", "-q", "--ff-only", `origin/${branch}`]).ok)
|
|
68
|
+
warn(out, `the local ${branch} does not move forward to origin/${branch}: you see other commits than the PR's.`);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `pr worktree <n> [--no-setup] [-- <command>]`: makes or reuses the PR's worktree and
|
|
72
|
+
* prints where it is; with `--`, runs the command there (Claude, with the repo's permission
|
|
73
|
+
* mode, if it names none) and exits with its code.
|
|
74
|
+
* @param options - The options.
|
|
75
|
+
* @param out - Where to write.
|
|
76
|
+
* @returns The exit code.
|
|
77
|
+
* @throws StartError if the PR does not exist, comes from a fork or cannot be checked out.
|
|
78
|
+
* @example await prWorktree({ ...options, number: 7 }, out)
|
|
79
|
+
*/
|
|
80
|
+
export async function prWorktree(options, out) {
|
|
81
|
+
const located = locateRepo(options.cwd);
|
|
82
|
+
if (located === null)
|
|
83
|
+
throw new StartError("run this inside a checkout of the repo.");
|
|
84
|
+
const { mainRoot } = located;
|
|
85
|
+
const pr = await getPr(options.repo, options.number);
|
|
86
|
+
if (pr === null)
|
|
87
|
+
throw new StartError(`${options.repo} has no PR #${options.number}.`);
|
|
88
|
+
if (pr.fork)
|
|
89
|
+
throw new StartError(`PR #${pr.number} comes from a fork and its branch is not in origin; check it out by hand in a worktree with 'gh pr checkout ${pr.number}'.`);
|
|
90
|
+
out.stdout(`PR #${pr.number}: ${pr.url}\n`);
|
|
91
|
+
if (pr.state !== "OPEN")
|
|
92
|
+
warn(out, `the PR is ${pr.state.toLowerCase()}.`);
|
|
93
|
+
const path = canonicalPath(prWorktreePath(mainRoot, options.config, pr.number));
|
|
94
|
+
const existing = worktrees(mainRoot).find((entry) => entry.path === path);
|
|
95
|
+
if (existing !== undefined && existing.branch !== pr.branch)
|
|
96
|
+
throw new StartError(`${path} is on ${existing.branch ?? "a detached HEAD"}, not on the PR's branch ${pr.branch}; delete it with 'git worktree remove ${path}'.`);
|
|
97
|
+
if (existing === undefined && existsSync(path))
|
|
98
|
+
throw new StartError(`${path} exists and is not a worktree of this repo.`);
|
|
99
|
+
if (existing === undefined) {
|
|
100
|
+
addWorktree(mainRoot, pr.branch, path, out);
|
|
101
|
+
copyWorktreeIncludes(mainRoot, path, out);
|
|
102
|
+
out.stdout(`Worktree on ${pr.branch}: what you commit and push updates the PR.\n`);
|
|
103
|
+
}
|
|
104
|
+
else
|
|
105
|
+
out.stdout(`The worktree already exists; reusing it.\n`);
|
|
106
|
+
const place = { root: path, mainRoot, base: baseBranch(options.config, mainRoot), config: options.config, out, remote: false };
|
|
107
|
+
if (options.setup)
|
|
108
|
+
await runSetup(place, null);
|
|
109
|
+
if (options.command === null) {
|
|
110
|
+
out.stdout(`Ready. To go in: cd ${path}\n`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
const [command, ...args] = options.command.length > 0
|
|
114
|
+
? options.command
|
|
115
|
+
: ["claude", ...permissionArgs("default", path, mainRoot).args];
|
|
116
|
+
if (command === undefined)
|
|
117
|
+
return 0;
|
|
118
|
+
const result = spawnSync(command, args, { cwd: path, stdio: "inherit" });
|
|
119
|
+
if (result.error !== undefined)
|
|
120
|
+
throw new StartError(`could not run ${command}: ${result.error.message}`);
|
|
121
|
+
return result.status ?? 1;
|
|
122
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FlowConfig } from "../config.js";
|
|
2
|
+
import type { Output } from "../output.js";
|
|
3
|
+
/** Where the cloud environment's setup leaves the `claude-cloud` command. */
|
|
4
|
+
export declare const CLAUDE_CLOUD_BIN = "/opt/claude-cloud/bin/claude-cloud";
|
|
5
|
+
/**
|
|
6
|
+
* `issue-flow session`.
|
|
7
|
+
* @param options - The config and the session's folder.
|
|
8
|
+
* @param out - Where to write; stdout reaches Claude.
|
|
9
|
+
* @returns Always 0.
|
|
10
|
+
* @example await session({ config, cwd }, out)
|
|
11
|
+
*/
|
|
12
|
+
export declare function session(options: {
|
|
13
|
+
config: FlowConfig;
|
|
14
|
+
cwd: string;
|
|
15
|
+
}, out: Output): Promise<number>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `issue-flow session`: the `SessionStart` hook of Claude Code. What it prints on
|
|
3
|
+
* stdout reaches Claude as the session's context, so every warning goes there. Its steps:
|
|
4
|
+
* in a cloud session, `claude-cloud session` (tools, Docker, the full history and the
|
|
5
|
+
* repo's session steps); in a worktree (or the cloud's checkout), the setup steps; in a
|
|
6
|
+
* local worktree whose issue branch is not published, a warning to run `start-issue`; the
|
|
7
|
+
* cleanup of the Docker of deleted worktrees (Claude Code does not call `WorktreeRemove`
|
|
8
|
+
* for worktrees made with git); and the environment of `direnv` in `CLAUDE_ENV_FILE`,
|
|
9
|
+
* which Claude Code reads before each command. It never makes the session fail. Ported
|
|
10
|
+
* from Abastio's `scripts/claude-session-env.sh`.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { appendFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { dockerCleanup } from "../docker.js";
|
|
16
|
+
import { currentBranch, onOrigin } from "../git.js";
|
|
17
|
+
import { issueOfBranch } from "../issue-text.js";
|
|
18
|
+
import { inPath } from "../launch.js";
|
|
19
|
+
import { runSetup } from "../worktree-steps.js";
|
|
20
|
+
import { stepPlace } from "./worktree.js";
|
|
21
|
+
/** Where the cloud environment's setup leaves the `claude-cloud` command. */
|
|
22
|
+
export const CLAUDE_CLOUD_BIN = "/opt/claude-cloud/bin/claude-cloud";
|
|
23
|
+
/** The package that prepares the cloud, for the message of what to set in claude.ai. */
|
|
24
|
+
const CLAUDE_CLOUD_PACKAGE = "@ramon-sg/claude-cloud";
|
|
25
|
+
/**
|
|
26
|
+
* In a cloud session, runs `claude-cloud session` (`CLAUDE_CLOUD_BIN` changes where it is
|
|
27
|
+
* looked for). Its output goes to stderr, as its steps print their own.
|
|
28
|
+
* @param root - The checkout.
|
|
29
|
+
* @param say - Where a message for Claude goes.
|
|
30
|
+
*/
|
|
31
|
+
function cloudSession(root, say) {
|
|
32
|
+
const bin = process.env.CLAUDE_CLOUD_BIN ?? CLAUDE_CLOUD_BIN;
|
|
33
|
+
if (!existsSync(bin)) {
|
|
34
|
+
say(`the environment setup did not run (there is no ${bin}). In claude.ai, set the environment's setup script to: npx -y ${CLAUDE_CLOUD_PACKAGE}@1 setup. It applies to new sessions.`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (spawnSync(bin, ["session"], { cwd: root, stdio: ["ignore", 2, 2] }).status !== 0)
|
|
38
|
+
say(`claude-cloud session failed; run '${bin} session' in ${root} to see why.`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The issue of a local worktree whose branch has the issue's number and is not in origin:
|
|
42
|
+
* it was never published, so the issue has no draft PR (a tool's setup may have failed
|
|
43
|
+
* quietly). Its upstream does not tell: a worktree made from `origin/main` tracks it.
|
|
44
|
+
* @param root - The worktree.
|
|
45
|
+
* @returns The issue, or null.
|
|
46
|
+
*/
|
|
47
|
+
function unstartedIssue(root) {
|
|
48
|
+
const branch = currentBranch(root);
|
|
49
|
+
if (branch === null || onOrigin(root, branch))
|
|
50
|
+
return null;
|
|
51
|
+
return issueOfBranch(branch);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Appends the environment of `direnv` to `CLAUDE_ENV_FILE`, so the session's commands get
|
|
55
|
+
* what the `.envrc` loads (they run in shells that do not go through direnv's hook). A
|
|
56
|
+
* failed export is not appended: with the `.envrc` blocked it still prints the unload of
|
|
57
|
+
* the earlier environment, which would take from the session what it inherited.
|
|
58
|
+
* @param root - The checkout.
|
|
59
|
+
* @param say - Where a message for Claude goes.
|
|
60
|
+
*/
|
|
61
|
+
function direnvExport(root, say) {
|
|
62
|
+
const file = process.env.CLAUDE_ENV_FILE;
|
|
63
|
+
if (file === undefined || file === "" || !existsSync(join(root, ".envrc")) || !inPath("direnv"))
|
|
64
|
+
return;
|
|
65
|
+
const result = spawnSync("direnv", ["export", "bash"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
66
|
+
if (result.status !== 0) {
|
|
67
|
+
say("could not load the .envrc with direnv; the session keeps the environment it inherited.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
appendFileSync(file, `${result.stdout}\n`);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `issue-flow session`.
|
|
74
|
+
* @param options - The config and the session's folder.
|
|
75
|
+
* @param out - Where to write; stdout reaches Claude.
|
|
76
|
+
* @returns Always 0.
|
|
77
|
+
* @example await session({ config, cwd }, out)
|
|
78
|
+
*/
|
|
79
|
+
export async function session(options, out) {
|
|
80
|
+
const say = (text) => out.stdout(`issue-flow session: ${text}\n`);
|
|
81
|
+
const toClaude = { ...out, stderr: (text) => out.stdout(text) };
|
|
82
|
+
let place;
|
|
83
|
+
try {
|
|
84
|
+
place = stepPlace({ config: options.config, cwd: options.cwd }, toClaude);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
const { root, mainRoot, remote } = place;
|
|
90
|
+
if (remote)
|
|
91
|
+
cloudSession(root, say);
|
|
92
|
+
if (root !== mainRoot || remote)
|
|
93
|
+
await runSetup(place, null);
|
|
94
|
+
const unstarted = !remote && root !== mainRoot ? unstartedIssue(root) : null;
|
|
95
|
+
if (unstarted !== null)
|
|
96
|
+
say(`issue #${unstarted} was not started: its branch is not published and has no draft PR. Before working, run the start-issue skill (${unstarted}).`);
|
|
97
|
+
const docker = dockerCleanup(options.config.worktrees.dockerProjects, null);
|
|
98
|
+
if (docker.failed.length > 0)
|
|
99
|
+
say(`could not delete the Docker of deleted worktrees (${docker.failed.join(", ")}); run 'issue-flow worktree cleanup' to see why.`);
|
|
100
|
+
direnvExport(root, say);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Output } from "../output.js";
|
|
2
|
+
/** Options of `issue-flow upgrade`. */
|
|
3
|
+
export interface UpgradeOptions {
|
|
4
|
+
cwd: string;
|
|
5
|
+
to: string | null;
|
|
6
|
+
dryRun: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Moves the repo to another version of the flow; see the file's header.
|
|
10
|
+
* @param options - The options.
|
|
11
|
+
* @param out - Where to write.
|
|
12
|
+
* @returns 0, or 1 if `skills-manager` failed.
|
|
13
|
+
* @throws SetupError outside the main checkout, if npm cannot tell the version or a file is broken.
|
|
14
|
+
* @example upgrade({ cwd, to: null, dryRun: false }, out)
|
|
15
|
+
*/
|
|
16
|
+
export declare function upgrade(options: UpgradeOptions, out: Output): number;
|