mintree 0.1.5 → 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 +49 -34
- 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";
|
|
@@ -131,11 +132,7 @@ function sanitizeDesc(value) {
|
|
|
131
132
|
}
|
|
132
133
|
function openInBrowser(url) {
|
|
133
134
|
try {
|
|
134
|
-
const cmd = process.platform === "darwin"
|
|
135
|
-
? "open"
|
|
136
|
-
: process.platform === "win32"
|
|
137
|
-
? "start"
|
|
138
|
-
: "xdg-open";
|
|
135
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
139
136
|
execSync(`${cmd} ${shQuote(url)}`, { stdio: "ignore" });
|
|
140
137
|
return true;
|
|
141
138
|
}
|
|
@@ -171,15 +168,15 @@ function FooterRow({ phase, overlayKind, }) {
|
|
|
171
168
|
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
|
|
172
169
|
}
|
|
173
170
|
if (overlayKind === "create") {
|
|
174
|
-
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" })] })] }));
|
|
175
172
|
}
|
|
176
173
|
if (overlayKind === "remove") {
|
|
177
|
-
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" })] }));
|
|
178
175
|
}
|
|
179
|
-
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" })] })] }));
|
|
180
177
|
}
|
|
181
178
|
function RemoveOverlayView({ overlay }) {
|
|
182
|
-
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] }) }))] }));
|
|
183
180
|
}
|
|
184
181
|
function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
|
|
185
182
|
const labelWidth = 14;
|
|
@@ -191,9 +188,7 @@ function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
|
|
|
191
188
|
const dirPreview = isNewBranch
|
|
192
189
|
? `${overlay.issue.issue.number}-${overlay.desc}`
|
|
193
190
|
: `${overlay.issue.issue.number}-${detachedDesc}`;
|
|
194
|
-
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
|
|
195
|
-
? "new"
|
|
196
|
-
: `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] }) }))] }));
|
|
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] })] }))] }));
|
|
197
192
|
}
|
|
198
193
|
function stateChar(state) {
|
|
199
194
|
if (!state)
|
|
@@ -242,7 +237,10 @@ function wrapLine(s, width) {
|
|
|
242
237
|
// (consecutive empty lines collapse to one) and trimming leading/trailing
|
|
243
238
|
// blank lines. Used to feed the description into the flat-line renderer.
|
|
244
239
|
function wrapBody(body, width) {
|
|
245
|
-
const raw = body
|
|
240
|
+
const raw = body
|
|
241
|
+
.replace(/\r\n/g, "\n")
|
|
242
|
+
.split("\n")
|
|
243
|
+
.map((l) => l.trimEnd());
|
|
246
244
|
while (raw.length > 0 && raw[0] === "")
|
|
247
245
|
raw.shift();
|
|
248
246
|
while (raw.length > 0 && raw[raw.length - 1] === "")
|
|
@@ -286,14 +284,17 @@ function buildDetailLines(d, width) {
|
|
|
286
284
|
const titleWrapped = wrapLine(d.issue.title, Math.max(8, w - titlePrefix.length));
|
|
287
285
|
titleWrapped.forEach((chunk, i) => {
|
|
288
286
|
if (i === 0) {
|
|
289
|
-
lines.push([
|
|
287
|
+
lines.push([
|
|
288
|
+
{ text: titlePrefix, bold: true },
|
|
289
|
+
{ text: chunk, bold: true },
|
|
290
|
+
]);
|
|
290
291
|
}
|
|
291
292
|
else {
|
|
292
293
|
lines.push([{ text: " ".repeat(titlePrefix.length) + chunk, bold: true }]);
|
|
293
294
|
}
|
|
294
295
|
});
|
|
295
|
-
const labels = d.issue.labels.map(l => l.name);
|
|
296
|
-
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)";
|
|
297
298
|
for (const w2 of wrapLine(labelText, w))
|
|
298
299
|
lines.push([{ text: w2, dim: true }]);
|
|
299
300
|
lines.push([
|
|
@@ -321,9 +322,7 @@ function buildDetailLines(d, width) {
|
|
|
321
322
|
for (const w2 of wrapLine(` path: ${d.worktree.path}`, w))
|
|
322
323
|
lines.push([{ text: w2, dim: true }]);
|
|
323
324
|
const statusLine = [{ text: ` status: `, dim: true }];
|
|
324
|
-
statusLine.push(d.worktree.dirty
|
|
325
|
-
? { text: "dirty", color: "yellow" }
|
|
326
|
-
: { text: "clean", color: "green" });
|
|
325
|
+
statusLine.push(d.worktree.dirty ? { text: "dirty", color: "yellow" } : { text: "clean", color: "green" });
|
|
327
326
|
if (d.worktree.ab) {
|
|
328
327
|
statusLine.push({
|
|
329
328
|
text: ` +${d.worktree.ab.ahead} / -${d.worktree.ab.behind}`,
|
|
@@ -446,7 +445,7 @@ export default function Dashboard() {
|
|
|
446
445
|
});
|
|
447
446
|
return;
|
|
448
447
|
}
|
|
449
|
-
setState(prev => {
|
|
448
|
+
setState((prev) => {
|
|
450
449
|
const previousIndex = prev.phase === "ready" ? prev.selectedIndex : 0;
|
|
451
450
|
const previousOverlay = prev.phase === "ready" ? prev.overlay : null;
|
|
452
451
|
const previousToast = prev.phase === "ready" ? prev.toast : null;
|
|
@@ -505,7 +504,7 @@ export default function Dashboard() {
|
|
|
505
504
|
const inLeftPane = col <= lw;
|
|
506
505
|
// Functional setState so a fast wheel doesn't read stale
|
|
507
506
|
// scroll/selection through the ref between dispatches.
|
|
508
|
-
setState(prev => {
|
|
507
|
+
setState((prev) => {
|
|
509
508
|
if (prev.phase !== "ready")
|
|
510
509
|
return prev;
|
|
511
510
|
if (prev.overlay)
|
|
@@ -638,10 +637,7 @@ export default function Dashboard() {
|
|
|
638
637
|
// as `worktree create --work`, minus the create. The wrapper
|
|
639
638
|
// will cd + run `mintree worktree work`, which itself sees the
|
|
640
639
|
// session_id in metadata and uses --resume.
|
|
641
|
-
emitMarkers([
|
|
642
|
-
`MINTREE_CD:${issue.worktree.path}`,
|
|
643
|
-
"MINTREE_WORK:1",
|
|
644
|
-
]);
|
|
640
|
+
emitMarkers([`MINTREE_CD:${issue.worktree.path}`, "MINTREE_WORK:1"]);
|
|
645
641
|
exit();
|
|
646
642
|
return;
|
|
647
643
|
}
|
|
@@ -684,6 +680,7 @@ export default function Dashboard() {
|
|
|
684
680
|
field: "branchMode",
|
|
685
681
|
error: null,
|
|
686
682
|
conventionDoc: root ? findBranchConventionDoc(root) : null,
|
|
683
|
+
pending: null,
|
|
687
684
|
},
|
|
688
685
|
toast: null,
|
|
689
686
|
});
|
|
@@ -692,6 +689,12 @@ export default function Dashboard() {
|
|
|
692
689
|
if (state.phase !== "ready" || !state.overlay)
|
|
693
690
|
return;
|
|
694
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
|
+
}
|
|
695
698
|
if (key.escape || (input === "c" && key.ctrl)) {
|
|
696
699
|
setState({ ...state, overlay: null });
|
|
697
700
|
return;
|
|
@@ -700,7 +703,6 @@ export default function Dashboard() {
|
|
|
700
703
|
handleRemoveOverlayInput(input, key, overlay);
|
|
701
704
|
return;
|
|
702
705
|
}
|
|
703
|
-
// Create overlay from here on.
|
|
704
706
|
// In "current" branch mode we skip type+desc fields entirely — they have
|
|
705
707
|
// no meaning when the worktree is going to be detached. Tab cycles
|
|
706
708
|
// branchMode ⇄ prompt only.
|
|
@@ -804,6 +806,23 @@ export default function Dashboard() {
|
|
|
804
806
|
});
|
|
805
807
|
return;
|
|
806
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
|
+
}
|
|
807
826
|
emitMarkers(buildCreateMarkers({
|
|
808
827
|
worktreePath: result.worktreePath,
|
|
809
828
|
work: result.work,
|
|
@@ -870,10 +889,10 @@ export default function Dashboard() {
|
|
|
870
889
|
// Left pane is the issue list — it only needs room for "#N ICON title".
|
|
871
890
|
// We give it ~40% of the width so the detail pane (URLs, descriptions,
|
|
872
891
|
// branch paths) has the room it actually needs.
|
|
873
|
-
const listWidthPct = 0.
|
|
892
|
+
const listWidthPct = 0.4;
|
|
874
893
|
const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
|
|
875
894
|
const detailWidth = columns - listWidth - 2; // border slack
|
|
876
|
-
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));
|
|
877
896
|
// Lista ocupa todo menos: " #N ICON " (id + 4 cols of pad/icon).
|
|
878
897
|
const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 8);
|
|
879
898
|
// Reserve rows: header (2), top borders (1), footer (3).
|
|
@@ -892,9 +911,5 @@ export default function Dashboard() {
|
|
|
892
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) => {
|
|
893
912
|
const absoluteIdx = startIdx + i;
|
|
894
913
|
return (_jsx(IssueListRow, { d: d, selected: absoluteIdx === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }, d.issue.number));
|
|
895
|
-
}), startIdx > 0 &&
|
|
896
|
-
? "green"
|
|
897
|
-
: toast.kind === "error"
|
|
898
|
-
? "red"
|
|
899
|
-
: "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 })] })] }));
|
|
900
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