mintree 0.1.4 → 0.1.6
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/dist/commands/dashboard.js +61 -35
- package/dist/commands/doctor.js +33 -6
- package/dist/commands/init.js +1 -1
- package/dist/commands/worktree/clean.js +9 -6
- package/dist/commands/worktree/create.js +53 -8
- package/dist/commands/worktree/list.js +6 -6
- package/dist/commands/worktree/remove.js +1 -1
- package/dist/commands/worktree/work.js +1 -3
- package/dist/lib/dashboard.js +1 -1
- package/dist/lib/git.js +2 -4
- package/dist/lib/githubProject.d.ts +55 -0
- package/dist/lib/githubProject.js +277 -0
- package/dist/lib/metadata.d.ts +7 -0
- package/dist/lib/metadata.js +22 -0
- package/dist/lib/session-signal.js +3 -3
- package/package.json +1 -1
|
@@ -12,7 +12,8 @@ import { ALLOWED_TYPES } from "../lib/branch.js";
|
|
|
12
12
|
import { runCreate, runCreateDetached } from "../lib/worktreeCreate.js";
|
|
13
13
|
import { runRemove, runRemoveByPath } from "../lib/worktreeRemove.js";
|
|
14
14
|
import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
|
|
15
|
-
import {
|
|
15
|
+
import { transitionIssueToInProgress } from "../lib/githubProject.js";
|
|
16
|
+
import { loadDashboard } from "../lib/dashboard.js";
|
|
16
17
|
const require = createRequire(import.meta.url);
|
|
17
18
|
const { version: mintreeVersion } = require("../../package.json");
|
|
18
19
|
export const description = "Interactive dashboard listing open issues assigned to you with worktree + session state";
|
|
@@ -105,6 +106,17 @@ function kebabize(title) {
|
|
|
105
106
|
.replace(/-+/g, "-")
|
|
106
107
|
.replace(/^-+|-+$/g, "");
|
|
107
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Default prompt seeded into the overlay's Prompt field when the user opens
|
|
111
|
+
* `w` for an issue. Single-line on purpose — `ink-text-input` is one-line,
|
|
112
|
+
* so multi-line templates render weirdly when the user tabs in to edit.
|
|
113
|
+
* Points the agent at `gh issue view` for the full body rather than dumping
|
|
114
|
+
* the body inline (issue bodies can be long and contain markdown that
|
|
115
|
+
* doesn't survive argv). User can clear or rewrite freely before Enter.
|
|
116
|
+
*/
|
|
117
|
+
function defaultPromptForIssue(number, title) {
|
|
118
|
+
return `Empezá a trabajar el issue #${number} (${title}). Usá \`gh issue view ${number}\` para leer el contexto completo y seguí las convenciones del repo.`;
|
|
119
|
+
}
|
|
108
120
|
/**
|
|
109
121
|
* Sanitises whatever the user typed into the desc field on every keystroke.
|
|
110
122
|
* Same rules as kebabize but without the word cap — this is for live input.
|
|
@@ -120,11 +132,7 @@ function sanitizeDesc(value) {
|
|
|
120
132
|
}
|
|
121
133
|
function openInBrowser(url) {
|
|
122
134
|
try {
|
|
123
|
-
const cmd = process.platform === "darwin"
|
|
124
|
-
? "open"
|
|
125
|
-
: process.platform === "win32"
|
|
126
|
-
? "start"
|
|
127
|
-
: "xdg-open";
|
|
135
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
128
136
|
execSync(`${cmd} ${shQuote(url)}`, { stdio: "ignore" });
|
|
129
137
|
return true;
|
|
130
138
|
}
|
|
@@ -160,15 +168,15 @@ function FooterRow({ phase, overlayKind, }) {
|
|
|
160
168
|
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
|
|
161
169
|
}
|
|
162
170
|
if (overlayKind === "create") {
|
|
163
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Tab" }), _jsx(Text, { dimColor: true, children: " switch field
|
|
171
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Tab" }), _jsx(Text, { dimColor: true, children: " switch field " }), _jsx(Text, { bold: true, children: "\u2190/\u2192" }), _jsx(Text, { dimColor: true, children: " toggle branch / cycle type " }), _jsx(Text, { bold: true, children: "Enter" }), _jsx(Text, { dimColor: true, children: " create + work" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] })] }));
|
|
164
172
|
}
|
|
165
173
|
if (overlayKind === "remove") {
|
|
166
|
-
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "y/Y" }), _jsx(Text, { dimColor: true, children: " confirm
|
|
174
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "y/Y" }), _jsx(Text, { dimColor: true, children: " confirm " }), _jsx(Text, { bold: true, children: "n/Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] }));
|
|
167
175
|
}
|
|
168
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav
|
|
176
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav " }), _jsx(Text, { bold: true, children: "\u21B5" }), _jsx(Text, { dimColor: true, children: " work (resume / create) " }), _jsx(Text, { bold: true, children: "w" }), _jsx(Text, { dimColor: true, children: " work (always create) " }), _jsx(Text, { bold: true, children: "d" }), _jsx(Text, { dimColor: true, children: " remove" })] }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: " refresh " }), _jsx(Text, { bold: true, children: "o" }), _jsx(Text, { dimColor: true, children: " open in browser " }), _jsx(Text, { bold: true, children: "PgUp/PgDn" }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { bold: true, children: "wheel" }), _jsx(Text, { dimColor: true, children: " scroll detail " }), _jsx(Text, { bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] })] }));
|
|
169
177
|
}
|
|
170
178
|
function RemoveOverlayView({ overlay }) {
|
|
171
|
-
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch ?? `(detached) ${overlay.worktreePath}` })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State:
|
|
179
|
+
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch ?? `(detached) ${overlay.worktreePath}` })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State: " }), overlay.dirty ? (_jsx(Text, { color: "yellow", children: "dirty (uncommitted changes will be lost)" })) : (_jsx(Text, { color: "green", children: "clean" }))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Removing the worktree leaves the branch and the issue's session_id in place. You can re-attach later with `mintree worktree create`." }) }), _jsx(Box, { marginTop: 1, children: overlay.dirty ? (_jsxs(Text, { children: ["This worktree is dirty. Press", " ", _jsx(Text, { bold: true, color: "red", children: "Y" }), " ", "to force-remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) : (_jsxs(Text, { children: ["Press", " ", _jsx(Text, { bold: true, color: "green", children: "y" }), " ", "to remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
|
|
172
180
|
}
|
|
173
181
|
function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
|
|
174
182
|
const labelWidth = 14;
|
|
@@ -180,9 +188,7 @@ function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
|
|
|
180
188
|
const dirPreview = isNewBranch
|
|
181
189
|
? `${overlay.issue.issue.number}-${overlay.desc}`
|
|
182
190
|
: `${overlay.issue.issue.number}-${detachedDesc}`;
|
|
183
|
-
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch
|
|
184
|
-
? "new"
|
|
185
|
-
: `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && (_jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" }))] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && (_jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" }))] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(optional) initial message for Claude" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(optional — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
|
|
191
|
+
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.pending && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
|
|
186
192
|
}
|
|
187
193
|
function stateChar(state) {
|
|
188
194
|
if (!state)
|
|
@@ -231,7 +237,10 @@ function wrapLine(s, width) {
|
|
|
231
237
|
// (consecutive empty lines collapse to one) and trimming leading/trailing
|
|
232
238
|
// blank lines. Used to feed the description into the flat-line renderer.
|
|
233
239
|
function wrapBody(body, width) {
|
|
234
|
-
const raw = body
|
|
240
|
+
const raw = body
|
|
241
|
+
.replace(/\r\n/g, "\n")
|
|
242
|
+
.split("\n")
|
|
243
|
+
.map((l) => l.trimEnd());
|
|
235
244
|
while (raw.length > 0 && raw[0] === "")
|
|
236
245
|
raw.shift();
|
|
237
246
|
while (raw.length > 0 && raw[raw.length - 1] === "")
|
|
@@ -275,14 +284,17 @@ function buildDetailLines(d, width) {
|
|
|
275
284
|
const titleWrapped = wrapLine(d.issue.title, Math.max(8, w - titlePrefix.length));
|
|
276
285
|
titleWrapped.forEach((chunk, i) => {
|
|
277
286
|
if (i === 0) {
|
|
278
|
-
lines.push([
|
|
287
|
+
lines.push([
|
|
288
|
+
{ text: titlePrefix, bold: true },
|
|
289
|
+
{ text: chunk, bold: true },
|
|
290
|
+
]);
|
|
279
291
|
}
|
|
280
292
|
else {
|
|
281
293
|
lines.push([{ text: " ".repeat(titlePrefix.length) + chunk, bold: true }]);
|
|
282
294
|
}
|
|
283
295
|
});
|
|
284
|
-
const labels = d.issue.labels.map(l => l.name);
|
|
285
|
-
const labelText = labels.length > 0 ? labels.map(l => `[${l}]`).join(" ") : "(no labels)";
|
|
296
|
+
const labels = d.issue.labels.map((l) => l.name);
|
|
297
|
+
const labelText = labels.length > 0 ? labels.map((l) => `[${l}]`).join(" ") : "(no labels)";
|
|
286
298
|
for (const w2 of wrapLine(labelText, w))
|
|
287
299
|
lines.push([{ text: w2, dim: true }]);
|
|
288
300
|
lines.push([
|
|
@@ -310,9 +322,7 @@ function buildDetailLines(d, width) {
|
|
|
310
322
|
for (const w2 of wrapLine(` path: ${d.worktree.path}`, w))
|
|
311
323
|
lines.push([{ text: w2, dim: true }]);
|
|
312
324
|
const statusLine = [{ text: ` status: `, dim: true }];
|
|
313
|
-
statusLine.push(d.worktree.dirty
|
|
314
|
-
? { text: "dirty", color: "yellow" }
|
|
315
|
-
: { text: "clean", color: "green" });
|
|
325
|
+
statusLine.push(d.worktree.dirty ? { text: "dirty", color: "yellow" } : { text: "clean", color: "green" });
|
|
316
326
|
if (d.worktree.ab) {
|
|
317
327
|
statusLine.push({
|
|
318
328
|
text: ` +${d.worktree.ab.ahead} / -${d.worktree.ab.behind}`,
|
|
@@ -435,7 +445,7 @@ export default function Dashboard() {
|
|
|
435
445
|
});
|
|
436
446
|
return;
|
|
437
447
|
}
|
|
438
|
-
setState(prev => {
|
|
448
|
+
setState((prev) => {
|
|
439
449
|
const previousIndex = prev.phase === "ready" ? prev.selectedIndex : 0;
|
|
440
450
|
const previousOverlay = prev.phase === "ready" ? prev.overlay : null;
|
|
441
451
|
const previousToast = prev.phase === "ready" ? prev.toast : null;
|
|
@@ -494,7 +504,7 @@ export default function Dashboard() {
|
|
|
494
504
|
const inLeftPane = col <= lw;
|
|
495
505
|
// Functional setState so a fast wheel doesn't read stale
|
|
496
506
|
// scroll/selection through the ref between dispatches.
|
|
497
|
-
setState(prev => {
|
|
507
|
+
setState((prev) => {
|
|
498
508
|
if (prev.phase !== "ready")
|
|
499
509
|
return prev;
|
|
500
510
|
if (prev.overlay)
|
|
@@ -627,10 +637,7 @@ export default function Dashboard() {
|
|
|
627
637
|
// as `worktree create --work`, minus the create. The wrapper
|
|
628
638
|
// will cd + run `mintree worktree work`, which itself sees the
|
|
629
639
|
// session_id in metadata and uses --resume.
|
|
630
|
-
emitMarkers([
|
|
631
|
-
`MINTREE_CD:${issue.worktree.path}`,
|
|
632
|
-
"MINTREE_WORK:1",
|
|
633
|
-
]);
|
|
640
|
+
emitMarkers([`MINTREE_CD:${issue.worktree.path}`, "MINTREE_WORK:1"]);
|
|
634
641
|
exit();
|
|
635
642
|
return;
|
|
636
643
|
}
|
|
@@ -669,10 +676,11 @@ export default function Dashboard() {
|
|
|
669
676
|
currentBranch: root ? getCurrentBranch(root) : null,
|
|
670
677
|
type: "feat",
|
|
671
678
|
desc: kebabize(issue.issue.title) || `issue-${issue.issue.number}`,
|
|
672
|
-
prompt:
|
|
679
|
+
prompt: defaultPromptForIssue(issue.issue.number, issue.issue.title),
|
|
673
680
|
field: "branchMode",
|
|
674
681
|
error: null,
|
|
675
682
|
conventionDoc: root ? findBranchConventionDoc(root) : null,
|
|
683
|
+
pending: null,
|
|
676
684
|
},
|
|
677
685
|
toast: null,
|
|
678
686
|
});
|
|
@@ -681,6 +689,12 @@ export default function Dashboard() {
|
|
|
681
689
|
if (state.phase !== "ready" || !state.overlay)
|
|
682
690
|
return;
|
|
683
691
|
const overlay = state.overlay;
|
|
692
|
+
// When a create overlay is finishing its post-create transition the
|
|
693
|
+
// worktree is already on disk and we're about to exit() — freeze the
|
|
694
|
+
// overlay so escape / stray keys don't dismiss it mid-flight.
|
|
695
|
+
if (overlay.kind === "create" && overlay.pending) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
684
698
|
if (key.escape || (input === "c" && key.ctrl)) {
|
|
685
699
|
setState({ ...state, overlay: null });
|
|
686
700
|
return;
|
|
@@ -689,7 +703,6 @@ export default function Dashboard() {
|
|
|
689
703
|
handleRemoveOverlayInput(input, key, overlay);
|
|
690
704
|
return;
|
|
691
705
|
}
|
|
692
|
-
// Create overlay from here on.
|
|
693
706
|
// In "current" branch mode we skip type+desc fields entirely — they have
|
|
694
707
|
// no meaning when the worktree is going to be detached. Tab cycles
|
|
695
708
|
// branchMode ⇄ prompt only.
|
|
@@ -793,6 +806,23 @@ export default function Dashboard() {
|
|
|
793
806
|
});
|
|
794
807
|
return;
|
|
795
808
|
}
|
|
809
|
+
// Worktree's on disk — keep the overlay visible while we move the issue
|
|
810
|
+
// to In Progress on its project. Errors from the GraphQL call don't
|
|
811
|
+
// block the worktree hand-off; we swallow them and let `mintree doctor`
|
|
812
|
+
// surface persistent issues (missing `project` scope, etc.).
|
|
813
|
+
setState({
|
|
814
|
+
...state,
|
|
815
|
+
overlay: { ...overlay, error: null, pending: "Updating issue status..." },
|
|
816
|
+
});
|
|
817
|
+
const repoRoot = findMainRepoRoot();
|
|
818
|
+
if (repoRoot) {
|
|
819
|
+
try {
|
|
820
|
+
await transitionIssueToInProgress(repoRoot, issueNumber);
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
// best effort — surface via doctor / next dashboard refresh
|
|
824
|
+
}
|
|
825
|
+
}
|
|
796
826
|
emitMarkers(buildCreateMarkers({
|
|
797
827
|
worktreePath: result.worktreePath,
|
|
798
828
|
work: result.work,
|
|
@@ -859,10 +889,10 @@ export default function Dashboard() {
|
|
|
859
889
|
// Left pane is the issue list — it only needs room for "#N ICON title".
|
|
860
890
|
// We give it ~40% of the width so the detail pane (URLs, descriptions,
|
|
861
891
|
// branch paths) has the room it actually needs.
|
|
862
|
-
const listWidthPct = 0.
|
|
892
|
+
const listWidthPct = 0.4;
|
|
863
893
|
const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
|
|
864
894
|
const detailWidth = columns - listWidth - 2; // border slack
|
|
865
|
-
const identifierWidth = Math.max(3, ...issues.map(d => `#${d.issue.number}`.length));
|
|
895
|
+
const identifierWidth = Math.max(3, ...issues.map((d) => `#${d.issue.number}`.length));
|
|
866
896
|
// Lista ocupa todo menos: " #N ICON " (id + 4 cols of pad/icon).
|
|
867
897
|
const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 8);
|
|
868
898
|
// Reserve rows: header (2), top borders (1), footer (3).
|
|
@@ -881,9 +911,5 @@ export default function Dashboard() {
|
|
|
881
911
|
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { paddingX: 1, paddingTop: 0, flexDirection: "column", children: _jsx(HeaderRow, { repoName: repoName, claudeVersion: claudeVersion, issueCount: issues.length }) }), overlay ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", borderStyle: "round", borderColor: overlay.kind === "remove" ? "yellow" : "cyan", children: overlay.kind === "create" ? (_jsx(CreateOverlayView, { overlay: overlay, onDescChange: onOverlayDescChange, onPromptChange: onOverlayPromptChange })) : (_jsx(RemoveOverlayView, { overlay: overlay })) })) : (_jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsx(Box, { width: listWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No open issues assigned to you in this repo." })) : (_jsxs(_Fragment, { children: [slice.map((d, i) => {
|
|
882
912
|
const absoluteIdx = startIdx + i;
|
|
883
913
|
return (_jsx(IssueListRow, { d: d, selected: absoluteIdx === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }, d.issue.number));
|
|
884
|
-
}), startIdx > 0 &&
|
|
885
|
-
? "green"
|
|
886
|
-
: toast.kind === "error"
|
|
887
|
-
? "red"
|
|
888
|
-
: "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind })] })] }));
|
|
914
|
+
}), startIdx > 0 && _jsxs(Text, { dimColor: true, children: ["\u2191 ", startIdx, " more above"] }), endIdx < issues.length && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", issues.length - endIdx, " more below"] }))] })) }), _jsx(Box, { width: detailWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(DetailPane, { d: selected, contentWidth: detailWidth - 4, contentHeight: detailContentHeight, scrollOffset: state.detailScrollOffset }) })] })), _jsxs(Box, { paddingX: 1, flexDirection: "column", children: [toast && (_jsx(Box, { children: _jsxs(Text, { color: toast.kind === "success" ? "green" : toast.kind === "error" ? "red" : "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind })] })] }));
|
|
889
915
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -7,6 +7,7 @@ import * as path from "path";
|
|
|
7
7
|
import { createRequire } from "module";
|
|
8
8
|
import { tryExec, getPath } from "../lib/exec.js";
|
|
9
9
|
import { ghCliAvailable, getGhUserLogin, getRepoFullName } from "../lib/github.js";
|
|
10
|
+
import { getGhTokenScopes, hasProjectScope } from "../lib/githubProject.js";
|
|
10
11
|
import { resolveClaudeBinary } from "../lib/claude.js";
|
|
11
12
|
import { findMainRepoRoot, getMintreeDir, getInitScriptPath, isGitIgnored, isExecutable, pathExists, } from "../lib/git.js";
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
@@ -110,6 +111,19 @@ async function checkGithubIssues() {
|
|
|
110
111
|
: undefined,
|
|
111
112
|
};
|
|
112
113
|
}
|
|
114
|
+
async function checkProjectScope() {
|
|
115
|
+
const scopes = await getGhTokenScopes();
|
|
116
|
+
if (scopes === null) {
|
|
117
|
+
// Auth/install issue — surfaced by the gh row already.
|
|
118
|
+
return { scopes: null, hasScope: false };
|
|
119
|
+
}
|
|
120
|
+
const ok = hasProjectScope(scopes);
|
|
121
|
+
return {
|
|
122
|
+
scopes,
|
|
123
|
+
hasScope: ok,
|
|
124
|
+
hint: ok ? undefined : "Run: gh auth refresh -s project",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
113
127
|
function checkRemoteControl() {
|
|
114
128
|
const home = process.env["HOME"] || "";
|
|
115
129
|
const configPath = path.join(home, ".claude.json");
|
|
@@ -153,7 +167,7 @@ function checkSessionSignalHooks() {
|
|
|
153
167
|
}
|
|
154
168
|
const found = eventHooks.some((entry) => {
|
|
155
169
|
const inner = entry.hooks || [];
|
|
156
|
-
return inner.some(h => typeof h.command === "string" && h.command.includes("mintree helpers session-signal"));
|
|
170
|
+
return inner.some((h) => typeof h.command === "string" && h.command.includes("mintree helpers session-signal"));
|
|
157
171
|
});
|
|
158
172
|
if (!found)
|
|
159
173
|
missing.push(event);
|
|
@@ -239,13 +253,23 @@ function StatusIcon({ ok, required }) {
|
|
|
239
253
|
function ToolRow({ tool }) {
|
|
240
254
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: tool.installed && !tool.hint, required: tool.required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: tool.name }), _jsxs(Text, { dimColor: true, children: [" - ", tool.description] }), !tool.required && _jsx(Text, { dimColor: true, children: " (optional)" })] }), tool.installed ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Version: ", tool.version] }), tool.path && _jsxs(Text, { dimColor: true, children: ["Path: ", tool.path] }), tool.authStatus && _jsxs(Text, { dimColor: true, children: ["Auth: ", tool.authStatus] }), tool.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] })] })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] }) }))] }));
|
|
241
255
|
}
|
|
256
|
+
function ProjectScopeRow({ status }) {
|
|
257
|
+
// Optional — auto-discovery still works for the "list issues" path even
|
|
258
|
+
// without the `project` scope; the scope only matters when we need to
|
|
259
|
+
// write status back to a Project v2 board (the `w` flow does this).
|
|
260
|
+
if (status.scopes === null) {
|
|
261
|
+
// gh not installed / not authenticated — handled by the gh row.
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.hasScope, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "GitHub Project v2 Scope" }), _jsx(Text, { dimColor: true, children: " - lets `w` move the issue to In Progress" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Token scopes: ", status.scopes.join(", ") || "(none)"] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })] }));
|
|
265
|
+
}
|
|
242
266
|
function GithubIssuesRow({ gh }) {
|
|
243
267
|
// Required only when we're inside a git repo. Outside one, the row is
|
|
244
268
|
// purely informational (auth check) so doctor can stay green when run
|
|
245
269
|
// from $HOME or any non-repo directory.
|
|
246
270
|
const required = gh.inGitRepo;
|
|
247
271
|
const ok = required ? gh.authenticated && !!gh.repoName : gh.authenticated;
|
|
248
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: ok, required: required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "GitHub Issues" }), _jsx(Text, { dimColor: true, children: " - issue listing + PR ops" }), !required && _jsx(Text, { dimColor: true, children: " (no repo here)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [gh.authenticated ? (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["User: ", gh.accountName] }), required &&
|
|
272
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: ok, required: required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "GitHub Issues" }), _jsx(Text, { dimColor: true, children: " - issue listing + PR ops" }), !required && _jsx(Text, { dimColor: true, children: " (no repo here)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [gh.authenticated ? (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["User: ", gh.accountName] }), required && _jsxs(Text, { dimColor: true, children: ["Repo: ", gh.repoName ?? "(not a GitHub repo)"] })] })) : (_jsx(Text, { dimColor: true, children: "Not authenticated" })), gh.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", gh.hint] })] })] }));
|
|
249
273
|
}
|
|
250
274
|
function ShellRow({ status }) {
|
|
251
275
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.configured, required: true }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Shell Integration" }), _jsx(Text, { dimColor: true, children: " - enables `cd` into worktrees" })] }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: status.configured ? (_jsxs(Text, { dimColor: true, children: ["Shell: ", status.shell ?? "unknown", " (MINTREE_SHELL_INTEGRATION=1)"] })) : status.shell ? (_jsx(Text, { color: "yellow", children: `↳ Add to ~/.${status.shell}rc: eval "$(mintree helpers shell-init ${status.shell})"` })) : (_jsx(Text, { color: "yellow", children: "\u21B3 Unsupported shell. mintree shell integration supports zsh and bash." })) })] }));
|
|
@@ -274,6 +298,7 @@ function MintreeSetupRow({ status }) {
|
|
|
274
298
|
export default function Doctor() {
|
|
275
299
|
const [tools, setTools] = useState(null);
|
|
276
300
|
const [gh, setGh] = useState(null);
|
|
301
|
+
const [projectScope, setProjectScope] = useState(null);
|
|
277
302
|
const [rc, setRc] = useState(null);
|
|
278
303
|
const [hooks, setHooks] = useState(null);
|
|
279
304
|
const [setup, setSetup] = useState(null);
|
|
@@ -303,25 +328,27 @@ export default function Doctor() {
|
|
|
303
328
|
};
|
|
304
329
|
toolResults.unshift(nodeRow);
|
|
305
330
|
const ghRes = await checkGithubIssues();
|
|
331
|
+
const projectScopeRes = await checkProjectScope();
|
|
306
332
|
setTools(toolResults);
|
|
307
333
|
setGh(ghRes);
|
|
334
|
+
setProjectScope(projectScopeRes);
|
|
308
335
|
setRc(checkRemoteControl());
|
|
309
336
|
setHooks(checkSessionSignalHooks());
|
|
310
337
|
setSetup(checkMintreeSetup());
|
|
311
338
|
setShell(checkShellIntegration());
|
|
312
339
|
})();
|
|
313
340
|
}, []);
|
|
314
|
-
const loading = !tools || !gh || !rc || !hooks || !setup || !shell;
|
|
341
|
+
const loading = !tools || !gh || !projectScope || !rc || !hooks || !setup || !shell;
|
|
315
342
|
if (loading) {
|
|
316
343
|
return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Checking system requirements..." })] }));
|
|
317
344
|
}
|
|
318
|
-
const requiredMissing = tools.filter(t => t.required && (!t.installed || t.hint));
|
|
319
|
-
const optionalMissing = tools.filter(t => !t.required && !t.installed);
|
|
345
|
+
const requiredMissing = tools.filter((t) => t.required && (!t.installed || t.hint));
|
|
346
|
+
const optionalMissing = tools.filter((t) => !t.required && !t.installed);
|
|
320
347
|
// GitHub Issues only counts toward the required tally when we're inside a
|
|
321
348
|
// git repo; otherwise the auth-only check is purely informational.
|
|
322
349
|
const ghOk = gh.inGitRepo ? gh.authenticated && !!gh.repoName : true;
|
|
323
350
|
const shellOk = shell.configured;
|
|
324
351
|
const allRequired = requiredMissing.length === 0 && ghOk && shellOk;
|
|
325
352
|
const requiredFailing = requiredMissing.length + (ghOk ? 0 : 1) + (shellOk ? 0 : 1);
|
|
326
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Mintree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map(t => (_jsx(ToolRow, { tool: t }, t.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), _jsx(GithubIssuesRow, { gh: gh }), _jsx(ShellRow, { status: shell }), _jsx(MintreeSetupRow, { status: setup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), _jsx(RemoteControlRow, { status: rc }), _jsx(SessionSignalRow, { status: hooks }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All required checks pass. mintree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredFailing, " required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
|
|
353
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Mintree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((t) => (_jsx(ToolRow, { tool: t }, t.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), _jsx(GithubIssuesRow, { gh: gh }), _jsx(ProjectScopeRow, { status: projectScope }), _jsx(ShellRow, { status: shell }), _jsx(MintreeSetupRow, { status: setup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), _jsx(RemoteControlRow, { status: rc }), _jsx(SessionSignalRow, { status: hooks }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All required checks pass. mintree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredFailing, " required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
|
|
327
354
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -121,7 +121,7 @@ export default function Init() {
|
|
|
121
121
|
if (!result.ok) {
|
|
122
122
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", result.message] }), result.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", result.hint] }) }))] }));
|
|
123
123
|
}
|
|
124
|
-
const anyChange = result.steps.some(s => s.kind === "created" || s.kind === "added");
|
|
124
|
+
const anyChange = result.steps.some((s) => s.kind === "created" || s.kind === "added");
|
|
125
125
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree init" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.repoRoot] })] }), result.steps.map((step, i) => {
|
|
126
126
|
const detail = stepDetail(step.kind);
|
|
127
127
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), detail && _jsxs(Text, { dimColor: true, children: [" (", detail, ")"] })] }), step.hint && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", step.hint] }) }))] }, i));
|
|
@@ -67,7 +67,7 @@ async function loadCandidates(force) {
|
|
|
67
67
|
if (ours.length === 0) {
|
|
68
68
|
return { phase: "nothing", message: "No mintree worktrees in this repo. Nothing to clean." };
|
|
69
69
|
}
|
|
70
|
-
const prs = await Promise.all(ours.map(w => fetchPr(w.branch)));
|
|
70
|
+
const prs = await Promise.all(ours.map((w) => fetchPr(w.branch)));
|
|
71
71
|
const candidates = [];
|
|
72
72
|
for (let i = 0; i < ours.length; i++) {
|
|
73
73
|
const w = ours[i];
|
|
@@ -96,7 +96,7 @@ async function loadCandidates(force) {
|
|
|
96
96
|
return { phase: "prompt", repoRoot: root, candidates };
|
|
97
97
|
}
|
|
98
98
|
function executeRemovals(repoRoot, candidates) {
|
|
99
|
-
const toRemove = candidates.filter(c => c.willClean);
|
|
99
|
+
const toRemove = candidates.filter((c) => c.willClean);
|
|
100
100
|
const results = [];
|
|
101
101
|
for (const c of toRemove) {
|
|
102
102
|
try {
|
|
@@ -120,7 +120,10 @@ function PrTag({ pr }) {
|
|
|
120
120
|
}
|
|
121
121
|
export default function Clean({ options }) {
|
|
122
122
|
const { exit } = useApp();
|
|
123
|
-
const [state, setState] = useState({
|
|
123
|
+
const [state, setState] = useState({
|
|
124
|
+
phase: "loading",
|
|
125
|
+
message: "Inspecting worktrees...",
|
|
126
|
+
});
|
|
124
127
|
useEffect(() => {
|
|
125
128
|
(async () => {
|
|
126
129
|
try {
|
|
@@ -193,14 +196,14 @@ export default function Clean({ options }) {
|
|
|
193
196
|
return (_jsx(Box, { padding: 1, children: _jsx(Text, { dimColor: true, children: state.message }) }));
|
|
194
197
|
}
|
|
195
198
|
if (state.phase === "prompt" || state.phase === "executing") {
|
|
196
|
-
const willCleanCount = state.candidates.filter(c => c.willClean).length;
|
|
197
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree clean" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", state.candidates.length, " candidate(s)"] })] }), state.candidates.map((c, i) => (_jsxs(Box, { children: [_jsx(Text, { color: c.willClean ? "green" : "yellow", children: c.willClean ? "✓" : "○" }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: c.branch }), _jsx(Text, { children: " " }), _jsx(PrTag, { pr: c.pr }), c.dirty && _jsx(Text, { color: "yellow", children: " [dirty]" }), c.reasonSkipped && _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.reasonSkipped] })] }, i))), _jsx(Box, { marginTop: 1, children: state.phase === "prompt" ? (_jsxs(Text, { children: ["Remove ", willCleanCount, " worktree(s)?
|
|
199
|
+
const willCleanCount = state.candidates.filter((c) => c.willClean).length;
|
|
200
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree clean" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", state.candidates.length, " candidate(s)"] })] }), state.candidates.map((c, i) => (_jsxs(Box, { children: [_jsx(Text, { color: c.willClean ? "green" : "yellow", children: c.willClean ? "✓" : "○" }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: c.branch }), _jsx(Text, { children: " " }), _jsx(PrTag, { pr: c.pr }), c.dirty && _jsx(Text, { color: "yellow", children: " [dirty]" }), c.reasonSkipped && _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.reasonSkipped] })] }, i))), _jsx(Box, { marginTop: 1, children: state.phase === "prompt" ? (_jsxs(Text, { children: ["Remove ", willCleanCount, " worktree(s)? ", _jsx(Text, { bold: true, children: "[y/N]" })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Removing..." })] })) })] }));
|
|
198
201
|
}
|
|
199
202
|
// state.phase === "done"
|
|
200
203
|
if (state.cancelled) {
|
|
201
204
|
return (_jsx(Box, { padding: 1, children: _jsx(Text, { dimColor: true, children: "Cancelled. No worktrees were removed." }) }));
|
|
202
205
|
}
|
|
203
|
-
const okCount = state.results.filter(r => r.ok).length;
|
|
206
|
+
const okCount = state.results.filter((r) => r.ok).length;
|
|
204
207
|
const failCount = state.results.length - okCount;
|
|
205
208
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "mintree worktree clean \u00B7 done" }) }), state.results.map((r, i) => (_jsxs(Box, { children: [_jsx(Text, { color: r.ok ? "green" : "red", children: r.ok ? "✓" : "✗" }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: r.branch }), !r.ok && _jsxs(Text, { color: "red", children: [" \u2014 ", r.error] })] }, i))), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Removed ", okCount, failCount > 0 && (_jsxs(_Fragment, { children: [", ", _jsxs(Text, { color: "red", children: [failCount, " failed"] })] })), ". Branches and metadata preserved."] }) })] }));
|
|
206
209
|
}
|
|
@@ -7,11 +7,11 @@ import { z } from "zod";
|
|
|
7
7
|
import { PERMISSION_MODES } from "../../lib/claude.js";
|
|
8
8
|
import { runCreate } from "../../lib/worktreeCreate.js";
|
|
9
9
|
import { buildCreateMarkers, emitMarkers } from "../../lib/markers.js";
|
|
10
|
+
import { findMainRepoRoot } from "../../lib/git.js";
|
|
11
|
+
import { transitionIssueToInProgress, describeTransition, } from "../../lib/githubProject.js";
|
|
10
12
|
export const description = "Create a worktree for an issue branch";
|
|
11
13
|
export const args = z.tuple([
|
|
12
|
-
z
|
|
13
|
-
.string()
|
|
14
|
-
.describe(argument({
|
|
14
|
+
z.string().describe(argument({
|
|
15
15
|
name: "branch",
|
|
16
16
|
description: "Branch in `<type>/<issue>-<kebab-desc>` format (e.g. feat/100-claude-md-inicial)",
|
|
17
17
|
})),
|
|
@@ -53,6 +53,7 @@ function StepIcon({ kind }) {
|
|
|
53
53
|
export default function Create({ args, options }) {
|
|
54
54
|
const [branch] = args;
|
|
55
55
|
const [result, setResult] = useState(null);
|
|
56
|
+
const [transition, setTransition] = useState("idle");
|
|
56
57
|
useEffect(() => {
|
|
57
58
|
setTimeout(() => {
|
|
58
59
|
try {
|
|
@@ -68,26 +69,70 @@ export default function Create({ args, options }) {
|
|
|
68
69
|
}
|
|
69
70
|
}, 0);
|
|
70
71
|
}, [branch, options.base, options.work, options.prompt, options.permissionMode]);
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
72
|
+
// Kick the Project v2 transition once the worktree is in place. Only when
|
|
73
|
+
// --work was on — non-work creates leave status untouched. Errors from the
|
|
74
|
+
// GraphQL call surface as a step but never block the worktree hand-off.
|
|
74
75
|
useEffect(() => {
|
|
75
76
|
if (!result || !result.ok)
|
|
76
77
|
return;
|
|
78
|
+
if (!result.work) {
|
|
79
|
+
setTransition("skipped");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setTransition("running");
|
|
83
|
+
let cancelled = false;
|
|
84
|
+
(async () => {
|
|
85
|
+
const root = findMainRepoRoot();
|
|
86
|
+
if (!root) {
|
|
87
|
+
if (!cancelled)
|
|
88
|
+
setTransition("skipped");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const r = await transitionIssueToInProgress(root, result.issueId);
|
|
93
|
+
if (!cancelled)
|
|
94
|
+
setTransition(r);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (cancelled)
|
|
98
|
+
return;
|
|
99
|
+
setTransition({
|
|
100
|
+
kind: "error",
|
|
101
|
+
message: err instanceof Error ? err.message : String(err),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
return () => {
|
|
106
|
+
cancelled = true;
|
|
107
|
+
};
|
|
108
|
+
}, [result]);
|
|
109
|
+
// Emit shell-wrapper markers when create succeeded AND the transition has
|
|
110
|
+
// settled (run or skipped). Goes through the emitMarkers helper so it
|
|
111
|
+
// lands in MINTREE_MARKER_FILE if set, otherwise stdout. Bypasses Ink so
|
|
112
|
+
// word-wrap can't split a long path mid-marker.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!result || !result.ok)
|
|
115
|
+
return;
|
|
116
|
+
if (transition === "idle" || transition === "running")
|
|
117
|
+
return;
|
|
77
118
|
emitMarkers(buildCreateMarkers({
|
|
78
119
|
worktreePath: result.worktreePath,
|
|
79
120
|
work: result.work,
|
|
80
121
|
promptFile: result.promptFile,
|
|
81
122
|
permissionMode: result.permissionMode,
|
|
82
123
|
}));
|
|
83
|
-
}, [result]);
|
|
124
|
+
}, [result, transition]);
|
|
84
125
|
if (!result) {
|
|
85
126
|
return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" Creating worktree for ", branch, "..."] })] }));
|
|
86
127
|
}
|
|
87
128
|
if (!result.ok) {
|
|
88
129
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", result.message] }), result.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", result.hint] }) }))] }));
|
|
89
130
|
}
|
|
90
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree create" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.steps.map((step, i) => (_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }, i))), _jsxs(Box, {
|
|
131
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree create" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.steps.map((step, i) => (_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }, i))), transition === "running" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Updating issue status..." })] })), typeof transition === "object" &&
|
|
132
|
+
(() => {
|
|
133
|
+
const step = describeTransition(transition);
|
|
134
|
+
return (_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }));
|
|
135
|
+
})(), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "green", children: ["Worktree ready at ", _jsx(Text, { bold: true, children: result.worktreePath })] }), _jsx(Text, { dimColor: true, children: result.work
|
|
91
136
|
? "Launching Claude in the new worktree..."
|
|
92
137
|
: "Next: `mt worktree work` to start a Claude session, or `cd` and run `claude` directly." })] })] }));
|
|
93
138
|
}
|
|
@@ -58,7 +58,7 @@ async function load(checkPr) {
|
|
|
58
58
|
}
|
|
59
59
|
const worktreesDir = getWorktreesDir(root);
|
|
60
60
|
const all = listWorktrees(root);
|
|
61
|
-
const ours = all.filter(w => {
|
|
61
|
+
const ours = all.filter((w) => {
|
|
62
62
|
// Filter to worktrees that live under .mintree/worktrees/. macOS reports
|
|
63
63
|
// /private/tmp paths so use a relative-prefix check after resolving both
|
|
64
64
|
// to absolute.
|
|
@@ -70,7 +70,7 @@ async function load(checkPr) {
|
|
|
70
70
|
return { phase: "empty", repoRoot: root };
|
|
71
71
|
}
|
|
72
72
|
const metadata = readMetadata(root);
|
|
73
|
-
const rows = ours.map(w => {
|
|
73
|
+
const rows = ours.map((w) => {
|
|
74
74
|
const issueId = extractIssueId(w.branch);
|
|
75
75
|
const baseFromMeta = issueId ? metadata.issues[issueId]?.base_branch : undefined;
|
|
76
76
|
return {
|
|
@@ -82,7 +82,7 @@ async function load(checkPr) {
|
|
|
82
82
|
};
|
|
83
83
|
});
|
|
84
84
|
if (checkPr) {
|
|
85
|
-
const prResults = await Promise.all(rows.map(r =>
|
|
85
|
+
const prResults = await Promise.all(rows.map((r) => r.branch === "(detached)" ? Promise.resolve(undefined) : fetchPrStatus(r.branch)));
|
|
86
86
|
rows.forEach((r, i) => {
|
|
87
87
|
r.pr = prResults[i];
|
|
88
88
|
});
|
|
@@ -137,7 +137,7 @@ export default function List({ options }) {
|
|
|
137
137
|
if (state.phase === "empty") {
|
|
138
138
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { dimColor: true, children: ["No mintree worktrees in ", state.repoRoot, "."] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Create one with ", _jsx(Text, { bold: true, children: "mintree worktree create <branch>" }), "."] }) })] }));
|
|
139
139
|
}
|
|
140
|
-
const issueWidth = Math.max(5, ...state.rows.map(r => (r.issueId ?? "—").length));
|
|
141
|
-
const branchWidth = Math.max(6, ...state.rows.map(r => r.branch.length));
|
|
142
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, children: pad("ISSUE", issueWidth) }), _jsx(Text, { children: "
|
|
140
|
+
const issueWidth = Math.max(5, ...state.rows.map((r) => (r.issueId ?? "—").length));
|
|
141
|
+
const branchWidth = Math.max(6, ...state.rows.map((r) => r.branch.length));
|
|
142
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, children: pad("ISSUE", issueWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: pad("BRANCH", branchWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "STATUS" }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "\u0394" }), _jsx(Text, { children: " " }), state.checkedPr && _jsx(Text, { bold: true, children: "PR" })] }), state.rows.map((r, i) => (_jsxs(Box, { children: [_jsx(Text, { children: pad(r.issueId ?? "—", issueWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: pad(r.branch, branchWidth) }), _jsx(Text, { children: " " }), _jsx(Box, { width: 9, children: _jsx(StatusCell, { dirty: r.dirty }) }), _jsx(Box, { width: 12, children: _jsx(AheadBehindCell, { ab: r.ab }) }), _jsx(PrCell, { pr: r.pr, checked: state.checkedPr })] }, i)))] }));
|
|
143
143
|
}
|
|
@@ -42,5 +42,5 @@ export default function Remove({ args, options }) {
|
|
|
42
42
|
if (!result.ok) {
|
|
43
43
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", result.message] }), result.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", result.hint] }) }))] }));
|
|
44
44
|
}
|
|
45
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree remove" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.variant === "pruned-orphan" ? (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "!" }), " worktree directory was already deleted; pruned the dangling reference"] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u2713" }), " removed
|
|
45
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree remove" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.variant === "pruned-orphan" ? (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "!" }), " worktree directory was already deleted; pruned the dangling reference"] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u2713" }), " removed ", _jsxs(Text, { dimColor: true, children: ["(", result.worktreePath, ")"] })] }), result.wasDirty && _jsx(Text, { color: "yellow", children: "\u21B3 forced past uncommitted changes" })] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Branch ", _jsx(Text, { color: "cyan", children: result.branch }), " was preserved (use `git branch -D", " ", result.branch, "` to delete it)."] }), _jsx(Text, { dimColor: true, children: "Issue metadata (incl. session_id) was preserved for re-attach." })] })] }));
|
|
46
46
|
}
|
|
@@ -50,9 +50,7 @@ function resolve(cwd) {
|
|
|
50
50
|
}
|
|
51
51
|
const worktreesDir = path.resolve(getWorktreesDir(repoRoot));
|
|
52
52
|
const cwdAbs = path.resolve(cwd);
|
|
53
|
-
const insideMintreeWorktree = cwdAbs === worktreesDir
|
|
54
|
-
? false
|
|
55
|
-
: cwdAbs.startsWith(worktreesDir + path.sep);
|
|
53
|
+
const insideMintreeWorktree = cwdAbs === worktreesDir ? false : cwdAbs.startsWith(worktreesDir + path.sep);
|
|
56
54
|
if (!insideMintreeWorktree) {
|
|
57
55
|
return {
|
|
58
56
|
ok: false,
|
package/dist/lib/dashboard.js
CHANGED
|
@@ -126,7 +126,7 @@ export async function loadDashboard(repoRoot) {
|
|
|
126
126
|
prByBranch.set(w.branch, pr);
|
|
127
127
|
});
|
|
128
128
|
await Promise.all(prFetches);
|
|
129
|
-
return issues.map(issue => {
|
|
129
|
+
return issues.map((issue) => {
|
|
130
130
|
const issueId = String(issue.number);
|
|
131
131
|
const worktreeRaw = worktreesByIssue.get(issueId) ?? null;
|
|
132
132
|
const sessionId = metadata.issues[issueId]?.session_id;
|
package/dist/lib/git.js
CHANGED
|
@@ -16,9 +16,7 @@ export function findMainRepoRoot(cwd = process.cwd()) {
|
|
|
16
16
|
})
|
|
17
17
|
.toString()
|
|
18
18
|
.trim();
|
|
19
|
-
const absoluteCommonDir = path.isAbsolute(commonDir)
|
|
20
|
-
? commonDir
|
|
21
|
-
: path.resolve(cwd, commonDir);
|
|
19
|
+
const absoluteCommonDir = path.isAbsolute(commonDir) ? commonDir : path.resolve(cwd, commonDir);
|
|
22
20
|
// `--git-common-dir` points at `<root>/.git` in a normal repo. Its parent
|
|
23
21
|
// is the working tree root we want.
|
|
24
22
|
if (path.basename(absoluteCommonDir) === ".git") {
|
|
@@ -119,7 +117,7 @@ export function isExecutable(p) {
|
|
|
119
117
|
*/
|
|
120
118
|
export function ensureGitignoreEntries(repoRoot, entries) {
|
|
121
119
|
const gitignorePath = path.join(repoRoot, ".gitignore");
|
|
122
|
-
const toAdd = entries.filter(entry => !isGitIgnored(entry, repoRoot));
|
|
120
|
+
const toAdd = entries.filter((entry) => !isGitIgnored(entry, repoRoot));
|
|
123
121
|
if (toAdd.length === 0)
|
|
124
122
|
return [];
|
|
125
123
|
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf-8") : "";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type TransitionResult = {
|
|
2
|
+
kind: "transitioned";
|
|
3
|
+
projectTitle: string;
|
|
4
|
+
from: string | null;
|
|
5
|
+
to: string;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "noop-already";
|
|
8
|
+
projectTitle: string;
|
|
9
|
+
} | {
|
|
10
|
+
kind: "noop-protected";
|
|
11
|
+
projectTitle: string;
|
|
12
|
+
current: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: "skip-no-repo";
|
|
15
|
+
} | {
|
|
16
|
+
kind: "skip-no-issue";
|
|
17
|
+
} | {
|
|
18
|
+
kind: "skip-no-project";
|
|
19
|
+
} | {
|
|
20
|
+
kind: "skip-ambiguous";
|
|
21
|
+
projects: string[];
|
|
22
|
+
} | {
|
|
23
|
+
kind: "skip-no-status-field";
|
|
24
|
+
projects: string[];
|
|
25
|
+
} | {
|
|
26
|
+
kind: "skip-no-in-progress-option";
|
|
27
|
+
projects: string[];
|
|
28
|
+
} | {
|
|
29
|
+
kind: "error";
|
|
30
|
+
message: string;
|
|
31
|
+
hint?: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Moves the GitHub issue to "In Progress" on its project. Auto-discovers the
|
|
35
|
+
* project from the issue's projectItems; if there are several candidates and
|
|
36
|
+
* `.mintree/metadata.json` doesn't pin one (via `project.url`), bails out
|
|
37
|
+
* rather than guess.
|
|
38
|
+
*
|
|
39
|
+
* Skips silently when the status is already In Progress or one of the
|
|
40
|
+
* protected statuses (In Review, Done by default) — to avoid clobbering
|
|
41
|
+
* a PR-driven transition done by something else.
|
|
42
|
+
*/
|
|
43
|
+
export declare function transitionIssueToInProgress(repoRoot: string, issueNumber: number | string): Promise<TransitionResult>;
|
|
44
|
+
/**
|
|
45
|
+
* Returns the gh CLI token scopes for github.com, or null when `gh` can't be
|
|
46
|
+
* called / the user isn't authenticated. `gh auth status` writes the scopes
|
|
47
|
+
* line to stderr; we capture both streams and grep for it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function getGhTokenScopes(): Promise<string[] | null>;
|
|
50
|
+
export declare function hasProjectScope(scopes: string[]): boolean;
|
|
51
|
+
export declare function describeTransition(result: TransitionResult): {
|
|
52
|
+
kind: "ok" | "skip" | "warn";
|
|
53
|
+
label: string;
|
|
54
|
+
detail?: string;
|
|
55
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { getRepoFullName } from "./github.js";
|
|
4
|
+
import { readMetadata } from "./metadata.js";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const DEFAULT_STATUS_FIELD = "Status";
|
|
7
|
+
const DEFAULT_IN_PROGRESS_OPTION = "In Progress";
|
|
8
|
+
const DEFAULT_PROTECTED_STATUSES = ["In Review", "Done"];
|
|
9
|
+
async function runGhGraphql(query, fields) {
|
|
10
|
+
const args = ["api", "graphql", "-f", `query=${query}`];
|
|
11
|
+
for (const [key, value] of fields) {
|
|
12
|
+
if (typeof value === "number") {
|
|
13
|
+
args.push("-F", `${key}=${value}`);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
args.push("-f", `${key}=${value}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const { stdout } = await execFileAsync("gh", args);
|
|
20
|
+
return JSON.parse(stdout);
|
|
21
|
+
}
|
|
22
|
+
function readProjectConfig(repoRoot) {
|
|
23
|
+
return readMetadata(repoRoot).project ?? {};
|
|
24
|
+
}
|
|
25
|
+
function parseProjectNumberFromUrl(url) {
|
|
26
|
+
const m = url.match(/\/projects\/(\d+)/);
|
|
27
|
+
return m && m[1] ? Number(m[1]) : null;
|
|
28
|
+
}
|
|
29
|
+
function interpretGhError(err) {
|
|
30
|
+
const stderr = err && typeof err === "object" && "stderr" in err
|
|
31
|
+
? String(err.stderr)
|
|
32
|
+
: err instanceof Error
|
|
33
|
+
? err.message
|
|
34
|
+
: String(err);
|
|
35
|
+
if (/INSUFFICIENT_SCOPES/i.test(stderr) || (/scope/i.test(stderr) && /project/i.test(stderr))) {
|
|
36
|
+
return {
|
|
37
|
+
kind: "error",
|
|
38
|
+
message: "gh token is missing the `project` scope.",
|
|
39
|
+
hint: "Run: gh auth refresh -s project",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (/Could not resolve to a Repository/i.test(stderr)) {
|
|
43
|
+
return { kind: "skip-no-repo" };
|
|
44
|
+
}
|
|
45
|
+
if (/Could not resolve to an Issue/i.test(stderr)) {
|
|
46
|
+
return { kind: "skip-no-issue" };
|
|
47
|
+
}
|
|
48
|
+
const firstLine = stderr.split("\n").find((line) => line.trim().length > 0) ?? "";
|
|
49
|
+
return {
|
|
50
|
+
kind: "error",
|
|
51
|
+
message: firstLine.slice(0, 200) || "gh api graphql failed",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Moves the GitHub issue to "In Progress" on its project. Auto-discovers the
|
|
56
|
+
* project from the issue's projectItems; if there are several candidates and
|
|
57
|
+
* `.mintree/metadata.json` doesn't pin one (via `project.url`), bails out
|
|
58
|
+
* rather than guess.
|
|
59
|
+
*
|
|
60
|
+
* Skips silently when the status is already In Progress or one of the
|
|
61
|
+
* protected statuses (In Review, Done by default) — to avoid clobbering
|
|
62
|
+
* a PR-driven transition done by something else.
|
|
63
|
+
*/
|
|
64
|
+
export async function transitionIssueToInProgress(repoRoot, issueNumber) {
|
|
65
|
+
const repo = await getRepoFullName();
|
|
66
|
+
if (!repo)
|
|
67
|
+
return { kind: "skip-no-repo" };
|
|
68
|
+
const [owner, name] = repo.split("/");
|
|
69
|
+
if (!owner || !name)
|
|
70
|
+
return { kind: "skip-no-repo" };
|
|
71
|
+
const cfg = readProjectConfig(repoRoot);
|
|
72
|
+
const statusFieldName = cfg.statusField ?? DEFAULT_STATUS_FIELD;
|
|
73
|
+
const inProgressOptionName = cfg.inProgressOption ?? DEFAULT_IN_PROGRESS_OPTION;
|
|
74
|
+
const protectedStatuses = cfg.protectedStatuses ?? DEFAULT_PROTECTED_STATUSES;
|
|
75
|
+
// The Status field name is interpolated into the query (not a variable)
|
|
76
|
+
// because GraphQL field-argument names are not parameterizable through
|
|
77
|
+
// the variables object. Escape any embedded quotes to keep the query valid.
|
|
78
|
+
const escapedFieldName = statusFieldName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
79
|
+
const query = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
80
|
+
repository(owner: $owner, name: $repo) {
|
|
81
|
+
issue(number: $number) {
|
|
82
|
+
id
|
|
83
|
+
projectItems(first: 20, includeArchived: false) {
|
|
84
|
+
nodes {
|
|
85
|
+
id
|
|
86
|
+
project {
|
|
87
|
+
id
|
|
88
|
+
title
|
|
89
|
+
number
|
|
90
|
+
url
|
|
91
|
+
field(name: "${escapedFieldName}") {
|
|
92
|
+
... on ProjectV2SingleSelectField {
|
|
93
|
+
id
|
|
94
|
+
name
|
|
95
|
+
options { id name }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
fieldValues(first: 30) {
|
|
100
|
+
nodes {
|
|
101
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
102
|
+
name
|
|
103
|
+
field {
|
|
104
|
+
... on ProjectV2SingleSelectField { name }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}`;
|
|
114
|
+
let raw;
|
|
115
|
+
try {
|
|
116
|
+
raw = (await runGhGraphql(query, [
|
|
117
|
+
["owner", owner],
|
|
118
|
+
["repo", name],
|
|
119
|
+
["number", Number(issueNumber)],
|
|
120
|
+
]));
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
return interpretGhError(err);
|
|
124
|
+
}
|
|
125
|
+
const issue = raw?.data?.repository?.issue;
|
|
126
|
+
if (!issue)
|
|
127
|
+
return { kind: "skip-no-issue" };
|
|
128
|
+
let nodes = issue.projectItems.nodes;
|
|
129
|
+
if (nodes.length === 0)
|
|
130
|
+
return { kind: "skip-no-project" };
|
|
131
|
+
// Honour an explicit project URL in the config before doing anything else.
|
|
132
|
+
if (cfg.url) {
|
|
133
|
+
const targetNumber = parseProjectNumberFromUrl(cfg.url);
|
|
134
|
+
nodes = nodes.filter((n) => n.project.url === cfg.url || (targetNumber !== null && n.project.number === targetNumber));
|
|
135
|
+
if (nodes.length === 0)
|
|
136
|
+
return { kind: "skip-no-project" };
|
|
137
|
+
}
|
|
138
|
+
const withField = nodes.filter((n) => n.project.field !== null);
|
|
139
|
+
if (withField.length === 0) {
|
|
140
|
+
return { kind: "skip-no-status-field", projects: nodes.map((n) => n.project.title) };
|
|
141
|
+
}
|
|
142
|
+
const withOption = withField.filter((n) => n.project.field.options.some((o) => o.name === inProgressOptionName));
|
|
143
|
+
if (withOption.length === 0) {
|
|
144
|
+
return {
|
|
145
|
+
kind: "skip-no-in-progress-option",
|
|
146
|
+
projects: withField.map((n) => n.project.title),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (withOption.length > 1) {
|
|
150
|
+
return { kind: "skip-ambiguous", projects: withOption.map((n) => n.project.title) };
|
|
151
|
+
}
|
|
152
|
+
const item = withOption[0];
|
|
153
|
+
const project = item.project;
|
|
154
|
+
const field = project.field;
|
|
155
|
+
const option = field.options.find((o) => o.name === inProgressOptionName);
|
|
156
|
+
if (!option) {
|
|
157
|
+
// Defensive — already filtered above, but TypeScript can't see it.
|
|
158
|
+
return { kind: "skip-no-in-progress-option", projects: [project.title] };
|
|
159
|
+
}
|
|
160
|
+
const currentStatus = item.fieldValues.nodes.find((v) => v.field?.name === statusFieldName)?.name ?? null;
|
|
161
|
+
if (currentStatus === inProgressOptionName) {
|
|
162
|
+
return { kind: "noop-already", projectTitle: project.title };
|
|
163
|
+
}
|
|
164
|
+
if (currentStatus !== null && protectedStatuses.includes(currentStatus)) {
|
|
165
|
+
return { kind: "noop-protected", projectTitle: project.title, current: currentStatus };
|
|
166
|
+
}
|
|
167
|
+
const mutation = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
168
|
+
updateProjectV2ItemFieldValue(input: {
|
|
169
|
+
projectId: $projectId
|
|
170
|
+
itemId: $itemId
|
|
171
|
+
fieldId: $fieldId
|
|
172
|
+
value: { singleSelectOptionId: $optionId }
|
|
173
|
+
}) {
|
|
174
|
+
projectV2Item { id }
|
|
175
|
+
}
|
|
176
|
+
}`;
|
|
177
|
+
try {
|
|
178
|
+
await runGhGraphql(mutation, [
|
|
179
|
+
["projectId", project.id],
|
|
180
|
+
["itemId", item.id],
|
|
181
|
+
["fieldId", field.id],
|
|
182
|
+
["optionId", option.id],
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
return interpretGhError(err);
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
kind: "transitioned",
|
|
190
|
+
projectTitle: project.title,
|
|
191
|
+
from: currentStatus,
|
|
192
|
+
to: inProgressOptionName,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Returns the gh CLI token scopes for github.com, or null when `gh` can't be
|
|
197
|
+
* called / the user isn't authenticated. `gh auth status` writes the scopes
|
|
198
|
+
* line to stderr; we capture both streams and grep for it.
|
|
199
|
+
*/
|
|
200
|
+
export async function getGhTokenScopes() {
|
|
201
|
+
try {
|
|
202
|
+
const { stdout, stderr } = await execFileAsync("gh", ["auth", "status"]);
|
|
203
|
+
const combined = `${stdout}\n${stderr}`;
|
|
204
|
+
return parseScopesFromAuthStatus(combined);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
const out = err && typeof err === "object" && "stdout" in err && "stderr" in err
|
|
208
|
+
? `${String(err.stdout)}\n${String(err.stderr)}`
|
|
209
|
+
: "";
|
|
210
|
+
const parsed = parseScopesFromAuthStatus(out);
|
|
211
|
+
return parsed ?? null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function parseScopesFromAuthStatus(text) {
|
|
215
|
+
const m = text.match(/Token scopes:\s*([^\n]+)/i);
|
|
216
|
+
if (!m || !m[1])
|
|
217
|
+
return null;
|
|
218
|
+
return m[1]
|
|
219
|
+
.split(",")
|
|
220
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
|
221
|
+
.filter(Boolean);
|
|
222
|
+
}
|
|
223
|
+
export function hasProjectScope(scopes) {
|
|
224
|
+
return scopes.some((s) => s === "project" || s === "write:project");
|
|
225
|
+
}
|
|
226
|
+
export function describeTransition(result) {
|
|
227
|
+
switch (result.kind) {
|
|
228
|
+
case "transitioned":
|
|
229
|
+
return {
|
|
230
|
+
kind: "ok",
|
|
231
|
+
label: `issue → ${result.to}`,
|
|
232
|
+
detail: result.from ? `${result.projectTitle} (was: ${result.from})` : result.projectTitle,
|
|
233
|
+
};
|
|
234
|
+
case "noop-already":
|
|
235
|
+
return {
|
|
236
|
+
kind: "skip",
|
|
237
|
+
label: "issue already In Progress",
|
|
238
|
+
detail: result.projectTitle,
|
|
239
|
+
};
|
|
240
|
+
case "noop-protected":
|
|
241
|
+
return {
|
|
242
|
+
kind: "skip",
|
|
243
|
+
label: `issue kept at ${result.current}`,
|
|
244
|
+
detail: `${result.projectTitle} (status is protected)`,
|
|
245
|
+
};
|
|
246
|
+
case "skip-no-repo":
|
|
247
|
+
return { kind: "skip", label: "no GitHub repo — skipping project update" };
|
|
248
|
+
case "skip-no-issue":
|
|
249
|
+
return { kind: "skip", label: "issue not found on GitHub — skipping project update" };
|
|
250
|
+
case "skip-no-project":
|
|
251
|
+
return { kind: "skip", label: "issue not on any project — skipping project update" };
|
|
252
|
+
case "skip-ambiguous":
|
|
253
|
+
return {
|
|
254
|
+
kind: "warn",
|
|
255
|
+
label: "multiple matching projects — skipping",
|
|
256
|
+
detail: `set .mintree/metadata.json project.url to one of: ${result.projects.join(", ")}`,
|
|
257
|
+
};
|
|
258
|
+
case "skip-no-status-field":
|
|
259
|
+
return {
|
|
260
|
+
kind: "skip",
|
|
261
|
+
label: "no Status field on project — skipping",
|
|
262
|
+
detail: result.projects.join(", "),
|
|
263
|
+
};
|
|
264
|
+
case "skip-no-in-progress-option":
|
|
265
|
+
return {
|
|
266
|
+
kind: "skip",
|
|
267
|
+
label: "no In Progress option on Status field — skipping",
|
|
268
|
+
detail: result.projects.join(", "),
|
|
269
|
+
};
|
|
270
|
+
case "error":
|
|
271
|
+
return {
|
|
272
|
+
kind: "warn",
|
|
273
|
+
label: "project update failed",
|
|
274
|
+
detail: result.hint ? `${result.message} — ${result.hint}` : result.message,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
package/dist/lib/metadata.d.ts
CHANGED
|
@@ -2,9 +2,16 @@ export type IssueMeta = {
|
|
|
2
2
|
base_branch?: string;
|
|
3
3
|
session_id?: string;
|
|
4
4
|
};
|
|
5
|
+
export type ProjectMeta = {
|
|
6
|
+
url?: string;
|
|
7
|
+
statusField?: string;
|
|
8
|
+
inProgressOption?: string;
|
|
9
|
+
protectedStatuses?: string[];
|
|
10
|
+
};
|
|
5
11
|
export type Metadata = {
|
|
6
12
|
version: 1;
|
|
7
13
|
issues: Record<string, IssueMeta>;
|
|
14
|
+
project?: ProjectMeta;
|
|
8
15
|
};
|
|
9
16
|
export declare function readMetadata(repoRoot: string): Metadata;
|
|
10
17
|
export declare function writeMetadata(repoRoot: string, data: Metadata): void;
|
package/dist/lib/metadata.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import { getMetadataPath } from "./git.js";
|
|
3
3
|
const EMPTY = { version: 1, issues: {} };
|
|
4
|
+
function sanitizeProject(raw) {
|
|
5
|
+
if (typeof raw !== "object" || raw === null)
|
|
6
|
+
return undefined;
|
|
7
|
+
const r = raw;
|
|
8
|
+
const out = {};
|
|
9
|
+
if (typeof r["url"] === "string" && r["url"].length > 0)
|
|
10
|
+
out.url = r["url"];
|
|
11
|
+
if (typeof r["statusField"] === "string" && r["statusField"].length > 0) {
|
|
12
|
+
out.statusField = r["statusField"];
|
|
13
|
+
}
|
|
14
|
+
if (typeof r["inProgressOption"] === "string" && r["inProgressOption"].length > 0) {
|
|
15
|
+
out.inProgressOption = r["inProgressOption"];
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(r["protectedStatuses"])) {
|
|
18
|
+
const arr = r["protectedStatuses"].filter((v) => typeof v === "string" && v.length > 0);
|
|
19
|
+
if (arr.length > 0)
|
|
20
|
+
out.protectedStatuses = arr;
|
|
21
|
+
}
|
|
22
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
23
|
+
}
|
|
4
24
|
export function readMetadata(repoRoot) {
|
|
5
25
|
const filePath = getMetadataPath(repoRoot);
|
|
6
26
|
if (!fs.existsSync(filePath))
|
|
@@ -9,11 +29,13 @@ export function readMetadata(repoRoot) {
|
|
|
9
29
|
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
10
30
|
if (typeof parsed !== "object" || parsed === null)
|
|
11
31
|
return { ...EMPTY, issues: {} };
|
|
32
|
+
const project = sanitizeProject(parsed.project);
|
|
12
33
|
return {
|
|
13
34
|
version: 1,
|
|
14
35
|
issues: typeof parsed.issues === "object" && parsed.issues !== null
|
|
15
36
|
? parsed.issues
|
|
16
37
|
: {},
|
|
38
|
+
...(project ? { project } : {}),
|
|
17
39
|
};
|
|
18
40
|
}
|
|
19
41
|
catch {
|
|
@@ -140,15 +140,15 @@ export function installHooks() {
|
|
|
140
140
|
existingHooks[event] = hookEntries;
|
|
141
141
|
continue;
|
|
142
142
|
}
|
|
143
|
-
const filtered = existing.filter(entry => {
|
|
143
|
+
const filtered = existing.filter((entry) => {
|
|
144
144
|
if (!entry || typeof entry !== "object")
|
|
145
145
|
return true;
|
|
146
146
|
const inner = entry.hooks ?? [];
|
|
147
|
-
return !inner.some(h => {
|
|
147
|
+
return !inner.some((h) => {
|
|
148
148
|
return (h !== null &&
|
|
149
149
|
typeof h === "object" &&
|
|
150
150
|
typeof h.command === "string" &&
|
|
151
|
-
|
|
151
|
+
h.command.includes("mintree helpers session-signal"));
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
154
|
existingHooks[event] = [...filtered, ...hookEntries];
|
package/package.json
CHANGED