mintree 0.1.6 → 0.1.8
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 +108 -15
- package/dist/lib/dashboard.d.ts +18 -0
- package/dist/lib/dashboard.js +172 -2
- package/dist/lib/version.d.ts +2 -0
- package/dist/lib/version.js +45 -0
- package/package.json +1 -1
|
@@ -8,6 +8,7 @@ import { createRequire } from "module";
|
|
|
8
8
|
import { findBranchConventionDoc, findMainRepoRoot, getCurrentBranch, getMintreeDir, pathExists, } from "../lib/git.js";
|
|
9
9
|
import { resolveClaudeBinary } from "../lib/claude.js";
|
|
10
10
|
import { tryExec } from "../lib/exec.js";
|
|
11
|
+
import { getLatestVersion, isNewerVersion } from "../lib/version.js";
|
|
11
12
|
import { ALLOWED_TYPES } from "../lib/branch.js";
|
|
12
13
|
import { runCreate, runCreateDetached } from "../lib/worktreeCreate.js";
|
|
13
14
|
import { runRemove, runRemoveByPath } from "../lib/worktreeRemove.js";
|
|
@@ -160,10 +161,10 @@ function useTerminalSize() {
|
|
|
160
161
|
}, [stdout]);
|
|
161
162
|
return size;
|
|
162
163
|
}
|
|
163
|
-
function HeaderRow({ repoName, claudeVersion, issueCount, }) {
|
|
164
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "mintree" }), _jsx(Text, { dimColor: true, children: ` v${mintreeVersion}` }), claudeVersion && _jsx(Text, { dimColor: true, children: ` · claude ${claudeVersion}` }), repoName && _jsx(Text, { dimColor: true, children: ` · ${repoName}` })] }), _jsx(Box, { children: _jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: ` Issues (${issueCount}) ` }) })] }));
|
|
164
|
+
function HeaderRow({ repoName, claudeVersion, issueCount, updateAvailable, }) {
|
|
165
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "mintree" }), _jsx(Text, { dimColor: true, children: ` v${mintreeVersion}` }), updateAvailable && _jsx(Text, { color: "yellow", children: " (*)" }), claudeVersion && _jsx(Text, { dimColor: true, children: ` · claude ${claudeVersion}` }), repoName && _jsx(Text, { dimColor: true, children: ` · ${repoName}` })] }), _jsx(Box, { children: _jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: ` Issues (${issueCount}) ` }) })] }));
|
|
165
166
|
}
|
|
166
|
-
function FooterRow({ phase, overlayKind, }) {
|
|
167
|
+
function FooterRow({ phase, overlayKind, latestVersion, }) {
|
|
167
168
|
if (phase === "error") {
|
|
168
169
|
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
|
|
169
170
|
}
|
|
@@ -173,7 +174,7 @@ function FooterRow({ phase, overlayKind, }) {
|
|
|
173
174
|
if (overlayKind === "remove") {
|
|
174
175
|
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" })] }));
|
|
175
176
|
}
|
|
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" })] })] }));
|
|
177
|
+
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" })] }), latestVersion && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "(*)" }), _jsx(Text, { dimColor: true, children: ` new version available — v${latestVersion} · npm i -g mintree` })] }))] }));
|
|
177
178
|
}
|
|
178
179
|
function RemoveOverlayView({ overlay }) {
|
|
179
180
|
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] }) }))] }));
|
|
@@ -207,10 +208,70 @@ function IssueListRow({ d, selected, identifierWidth, maxTitleWidth, }) {
|
|
|
207
208
|
const title = truncate(d.issue.title, maxTitleWidth);
|
|
208
209
|
// One single Text with a single string so the background highlight is
|
|
209
210
|
// continuous across the whole row. Coloured per-state icons live in the
|
|
210
|
-
// detail pane instead — keeps the list selection visually solid.
|
|
211
|
-
|
|
211
|
+
// detail pane instead — keeps the list selection visually solid. The two
|
|
212
|
+
// leading spaces nest the row under its Status sub-header.
|
|
213
|
+
const line = ` ${idText} ${icon} ${title}`;
|
|
212
214
|
return (_jsx(Box, { children: _jsx(Text, { backgroundColor: selected ? "blue" : undefined, color: selected ? "white" : undefined, children: line }) }));
|
|
213
215
|
}
|
|
216
|
+
// A project board header — the top level of the grouped issue list. Mirrors
|
|
217
|
+
// the bold project name + dim count seen in the santree dashboard.
|
|
218
|
+
function ProjectHeaderRow({ title, count, width, }) {
|
|
219
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: truncate(title, Math.max(4, width - 6)) }), _jsx(Text, { dimColor: true, children: ` ${count}` })] }));
|
|
220
|
+
}
|
|
221
|
+
// A Status sub-header within a project group. The bullet and name take the
|
|
222
|
+
// colour the board itself assigned to that Status option.
|
|
223
|
+
function StatusHeaderRow({ name, color, count, width, }) {
|
|
224
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: color, children: " ●" }), _jsx(Text, { bold: true, color: color, children: ` ${truncate(name, Math.max(4, width - 8))}` }), _jsx(Text, { dimColor: true, children: ` ${count}` })] }));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Walks the already-grouped flat issue array (loadDashboard sorts it by
|
|
228
|
+
* project → status → number) and interleaves project/status header rows
|
|
229
|
+
* whenever the group changes. When no issue belongs to a project board the
|
|
230
|
+
* list stays flat — same look the dashboard had before grouping existed.
|
|
231
|
+
*/
|
|
232
|
+
function buildListRows(issues) {
|
|
233
|
+
if (!issues.some((d) => d.project !== null)) {
|
|
234
|
+
return issues.map((d, index) => ({ kind: "issue", d, index }));
|
|
235
|
+
}
|
|
236
|
+
const projectTitle = (d) => d.project?.projectTitle ?? "Sin proyecto";
|
|
237
|
+
const projectCount = new Map();
|
|
238
|
+
const statusCount = new Map();
|
|
239
|
+
for (const d of issues) {
|
|
240
|
+
const p = projectTitle(d);
|
|
241
|
+
projectCount.set(p, (projectCount.get(p) ?? 0) + 1);
|
|
242
|
+
if (d.project) {
|
|
243
|
+
const key = `${p}${d.project.status ?? "Sin estado"}`;
|
|
244
|
+
statusCount.set(key, (statusCount.get(key) ?? 0) + 1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const rows = [];
|
|
248
|
+
let curProject = null;
|
|
249
|
+
let curStatus = null;
|
|
250
|
+
issues.forEach((d, index) => {
|
|
251
|
+
const p = projectTitle(d);
|
|
252
|
+
if (p !== curProject) {
|
|
253
|
+
if (curProject !== null)
|
|
254
|
+
rows.push({ kind: "spacer" });
|
|
255
|
+
rows.push({ kind: "project", title: p, count: projectCount.get(p) ?? 0 });
|
|
256
|
+
curProject = p;
|
|
257
|
+
curStatus = null;
|
|
258
|
+
}
|
|
259
|
+
if (d.project) {
|
|
260
|
+
const s = d.project.status ?? "Sin estado";
|
|
261
|
+
if (s !== curStatus) {
|
|
262
|
+
rows.push({
|
|
263
|
+
kind: "status",
|
|
264
|
+
name: s,
|
|
265
|
+
color: d.project.statusColor,
|
|
266
|
+
count: statusCount.get(`${p}${s}`) ?? 0,
|
|
267
|
+
});
|
|
268
|
+
curStatus = s;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
rows.push({ kind: "issue", d, index });
|
|
272
|
+
});
|
|
273
|
+
return rows;
|
|
274
|
+
}
|
|
214
275
|
// Word-wraps a single line at `width` columns, breaking on the last space
|
|
215
276
|
// before the limit when that yields a reasonable cut. Falls back to a hard
|
|
216
277
|
// cut for unbroken runs (long URLs, code-fence content) so the detail pane
|
|
@@ -297,6 +358,13 @@ function buildDetailLines(d, width) {
|
|
|
297
358
|
const labelText = labels.length > 0 ? labels.map((l) => `[${l}]`).join(" ") : "(no labels)";
|
|
298
359
|
for (const w2 of wrapLine(labelText, w))
|
|
299
360
|
lines.push([{ text: w2, dim: true }]);
|
|
361
|
+
if (d.project) {
|
|
362
|
+
lines.push([
|
|
363
|
+
{ text: "● ", color: d.project.statusColor },
|
|
364
|
+
{ text: d.project.status ?? "Sin estado", color: d.project.statusColor },
|
|
365
|
+
{ text: ` · ${truncate(d.project.projectTitle, Math.max(8, w - 12))}`, dim: true },
|
|
366
|
+
]);
|
|
367
|
+
}
|
|
300
368
|
lines.push([
|
|
301
369
|
{
|
|
302
370
|
text: `updated ${relativeTime(d.issue.updatedAt)} · created ${relativeTime(d.issue.createdAt)}`,
|
|
@@ -399,6 +467,8 @@ export default function Dashboard() {
|
|
|
399
467
|
const [state, setState] = useState({ phase: "loading" });
|
|
400
468
|
const [repoName, setRepoName] = useState(null);
|
|
401
469
|
const [claudeVersion, setClaudeVersion] = useState(null);
|
|
470
|
+
// Set only when the npm registry reports a strictly newer version.
|
|
471
|
+
const [latestVersion, setLatestVersion] = useState(null);
|
|
402
472
|
const { columns, rows } = useTerminalSize();
|
|
403
473
|
// Switch to the alt-screen buffer once, synchronously, on the first render
|
|
404
474
|
// pass. Doing this here (instead of inside a useEffect) is what makes the
|
|
@@ -479,6 +549,10 @@ export default function Dashboard() {
|
|
|
479
549
|
setClaudeVersion(m && m[1] ? m[1] : v);
|
|
480
550
|
}
|
|
481
551
|
}
|
|
552
|
+
const latest = await getLatestVersion("mintree");
|
|
553
|
+
if (latest && isNewerVersion(mintreeVersion, latest)) {
|
|
554
|
+
setLatestVersion(latest);
|
|
555
|
+
}
|
|
482
556
|
})();
|
|
483
557
|
}, []);
|
|
484
558
|
// SGR mouse tracking: enable on mount, disable on unmount, and route
|
|
@@ -893,8 +967,8 @@ export default function Dashboard() {
|
|
|
893
967
|
const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
|
|
894
968
|
const detailWidth = columns - listWidth - 2; // border slack
|
|
895
969
|
const identifierWidth = Math.max(3, ...issues.map((d) => `#${d.issue.number}`.length));
|
|
896
|
-
// Lista ocupa todo menos: "
|
|
897
|
-
const maxTitleWidth = Math.max(8, listWidth - identifierWidth -
|
|
970
|
+
// Lista ocupa todo menos: " #N ICON " (2-space nest indent + id + icon).
|
|
971
|
+
const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 9);
|
|
898
972
|
// Reserve rows: header (2), top borders (1), footer (3).
|
|
899
973
|
const listVisibleRows = Math.max(3, rows - 9);
|
|
900
974
|
// Detail pane content height inside the bordered box. Header eats 2 rows,
|
|
@@ -905,11 +979,30 @@ export default function Dashboard() {
|
|
|
905
979
|
// correct pane. Ref lets the stdin listener (mounted once) read the live
|
|
906
980
|
// value without re-binding on every resize.
|
|
907
981
|
listWidthRef.current = listWidth;
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
982
|
+
// Grouped list: build the project/status header rows interleaved with
|
|
983
|
+
// issue rows, then window the rows around the selected issue's row.
|
|
984
|
+
// Navigation still moves `selectedIndex` over the flat issues array —
|
|
985
|
+
// headers and spacers are non-selectable, purely visual rows.
|
|
986
|
+
const listRows = buildListRows(issues);
|
|
987
|
+
const selectedRowIdx = listRows.findIndex((r) => r.kind === "issue" && r.index === selectedIndex);
|
|
988
|
+
const rowAnchor = selectedRowIdx >= 0 ? selectedRowIdx : 0;
|
|
989
|
+
const maxRowStart = Math.max(0, listRows.length - listVisibleRows);
|
|
990
|
+
const rowStart = Math.max(0, Math.min(maxRowStart, rowAnchor - Math.floor(listVisibleRows / 2)));
|
|
991
|
+
const rowEnd = Math.min(listRows.length, rowStart + listVisibleRows);
|
|
992
|
+
const visibleRows = listRows.slice(rowStart, rowEnd);
|
|
993
|
+
const issuesAbove = listRows.slice(0, rowStart).filter((r) => r.kind === "issue").length;
|
|
994
|
+
const issuesBelow = listRows.slice(rowEnd).filter((r) => r.kind === "issue").length;
|
|
995
|
+
const listContentWidth = Math.max(8, listWidth - 4);
|
|
996
|
+
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, updateAvailable: latestVersion !== null }) }), 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: [visibleRows.map((row, i) => {
|
|
997
|
+
const key = rowStart + i;
|
|
998
|
+
if (row.kind === "spacer")
|
|
999
|
+
return _jsx(Text, { children: " " }, key);
|
|
1000
|
+
if (row.kind === "project") {
|
|
1001
|
+
return (_jsx(ProjectHeaderRow, { title: row.title, count: row.count, width: listContentWidth }, key));
|
|
1002
|
+
}
|
|
1003
|
+
if (row.kind === "status") {
|
|
1004
|
+
return (_jsx(StatusHeaderRow, { name: row.name, color: row.color, count: row.count, width: listContentWidth }, key));
|
|
1005
|
+
}
|
|
1006
|
+
return (_jsx(IssueListRow, { d: row.d, selected: row.index === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }, key));
|
|
1007
|
+
}), issuesAbove > 0 && _jsxs(Text, { dimColor: true, children: ["\u2191 ", issuesAbove, " more above"] }), issuesBelow > 0 && _jsxs(Text, { dimColor: true, children: ["\u2193 ", issuesBelow, " 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, latestVersion: latestVersion })] })] }));
|
|
915
1008
|
}
|
package/dist/lib/dashboard.d.ts
CHANGED
|
@@ -29,11 +29,29 @@ export type PrInfo = {
|
|
|
29
29
|
state: "OPEN" | "CLOSED" | "MERGED";
|
|
30
30
|
url: string;
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* The issue's membership in a GitHub Projects v2 board, used to group the
|
|
34
|
+
* dashboard list. `status` is the value of the board's single-select Status
|
|
35
|
+
* field (null when the issue is on the board but has no status set).
|
|
36
|
+
* `statusOrder` is the index of that option in the field definition, so the
|
|
37
|
+
* dashboard can order status sub-groups the same way the board's columns are
|
|
38
|
+
* ordered. `statusColor` is an Ink colour derived from the option's own
|
|
39
|
+
* configured colour.
|
|
40
|
+
*/
|
|
41
|
+
export type IssueProjectInfo = {
|
|
42
|
+
projectTitle: string;
|
|
43
|
+
projectUrl: string;
|
|
44
|
+
projectNumber: number;
|
|
45
|
+
status: string | null;
|
|
46
|
+
statusColor: string;
|
|
47
|
+
statusOrder: number;
|
|
48
|
+
};
|
|
32
49
|
export type DashboardIssue = {
|
|
33
50
|
issue: GhIssue;
|
|
34
51
|
worktree: WorktreeInfo | null;
|
|
35
52
|
session: SessionStateInfo | null;
|
|
36
53
|
pr: PrInfo | null;
|
|
54
|
+
project: IssueProjectInfo | null;
|
|
37
55
|
};
|
|
38
56
|
/**
|
|
39
57
|
* Fetches open issues assigned to the authenticated GitHub user for the
|
package/dist/lib/dashboard.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
+
import { execFile } from "child_process";
|
|
4
|
+
import { promisify } from "util";
|
|
3
5
|
import { tryExec } from "./exec.js";
|
|
4
6
|
import { listWorktrees, getWorktreesDir, isDirty, getAheadBehind, } from "./git.js";
|
|
5
7
|
import { readMetadata } from "./metadata.js";
|
|
8
|
+
import { getRepoFullName } from "./github.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
6
10
|
const ISSUE_LIST_LIMIT = 50;
|
|
7
11
|
/**
|
|
8
12
|
* Fetches open issues assigned to the authenticated GitHub user for the
|
|
@@ -102,6 +106,163 @@ async function fetchPrForBranch(branch) {
|
|
|
102
106
|
}
|
|
103
107
|
return null;
|
|
104
108
|
}
|
|
109
|
+
// GitHub Projects v2 single-select options carry their own colour enum.
|
|
110
|
+
// Map each to the closest Ink/chalk colour; ORANGE and PINK have no 16-colour
|
|
111
|
+
// keyword so they use hex (truecolor terminals render them, others approximate).
|
|
112
|
+
const PROJECT_STATUS_COLORS = {
|
|
113
|
+
GRAY: "gray",
|
|
114
|
+
BLUE: "blue",
|
|
115
|
+
GREEN: "green",
|
|
116
|
+
YELLOW: "yellow",
|
|
117
|
+
ORANGE: "#d18616",
|
|
118
|
+
RED: "red",
|
|
119
|
+
PINK: "#d2a8ff",
|
|
120
|
+
PURPLE: "magenta",
|
|
121
|
+
};
|
|
122
|
+
const STATUS_ORDER_UNSET = 999;
|
|
123
|
+
function parseProjectNumberFromUrl(url) {
|
|
124
|
+
const m = url.match(/\/projects\/(\d+)/);
|
|
125
|
+
return m && m[1] ? Number(m[1]) : null;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Runs a GraphQL query via `gh api graphql`. Returns null on any failure
|
|
129
|
+
* (gh not authenticated, missing scope, network) — the caller degrades
|
|
130
|
+
* gracefully to an ungrouped list.
|
|
131
|
+
*/
|
|
132
|
+
async function ghGraphql(query) {
|
|
133
|
+
try {
|
|
134
|
+
const { stdout } = await execFileAsync("gh", ["api", "graphql", "-f", `query=${query}`]);
|
|
135
|
+
return JSON.parse(stdout);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Picks which project board an issue belongs to for grouping purposes. When
|
|
143
|
+
* `.mintree/metadata.json` pins a project URL, only that board counts;
|
|
144
|
+
* otherwise the first board the issue appears on wins.
|
|
145
|
+
*/
|
|
146
|
+
function pickProjectNode(nodes, configuredUrl) {
|
|
147
|
+
if (nodes.length === 0)
|
|
148
|
+
return null;
|
|
149
|
+
if (configuredUrl) {
|
|
150
|
+
const targetNumber = parseProjectNumberFromUrl(configuredUrl);
|
|
151
|
+
return (nodes.find((n) => n.project?.url === configuredUrl ||
|
|
152
|
+
(targetNumber !== null && n.project?.number === targetNumber)) ?? null);
|
|
153
|
+
}
|
|
154
|
+
return nodes[0] ?? null;
|
|
155
|
+
}
|
|
156
|
+
function toProjectInfo(node) {
|
|
157
|
+
const proj = node.project;
|
|
158
|
+
if (!proj)
|
|
159
|
+
return null;
|
|
160
|
+
const options = proj.field?.options ?? [];
|
|
161
|
+
const status = node.fieldValueByName?.name ?? null;
|
|
162
|
+
const optionIndex = status ? options.findIndex((o) => o.name === status) : -1;
|
|
163
|
+
const option = optionIndex >= 0 ? options[optionIndex] : undefined;
|
|
164
|
+
return {
|
|
165
|
+
projectTitle: proj.title ?? "(untitled project)",
|
|
166
|
+
projectUrl: proj.url ?? "",
|
|
167
|
+
projectNumber: proj.number ?? 0,
|
|
168
|
+
status,
|
|
169
|
+
statusColor: option?.color
|
|
170
|
+
? (PROJECT_STATUS_COLORS[option.color] ?? "yellow")
|
|
171
|
+
: status
|
|
172
|
+
? "yellow"
|
|
173
|
+
: "gray",
|
|
174
|
+
statusOrder: optionIndex >= 0 ? optionIndex : STATUS_ORDER_UNSET,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Fetches, in a single GraphQL round-trip, which Projects v2 board (and
|
|
179
|
+
* Status value) each open assigned issue belongs to. Returns an empty map
|
|
180
|
+
* when the lookup fails — the dashboard then renders an ungrouped list.
|
|
181
|
+
*/
|
|
182
|
+
async function fetchProjectAssignments(statusFieldName, configuredUrl) {
|
|
183
|
+
const result = new Map();
|
|
184
|
+
const repo = await getRepoFullName();
|
|
185
|
+
if (!repo)
|
|
186
|
+
return result;
|
|
187
|
+
// The Status field name is interpolated into the query (not a variable)
|
|
188
|
+
// because it appears as a field argument; escape embedded quotes.
|
|
189
|
+
const escapedField = statusFieldName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
190
|
+
const searchQuery = `repo:${repo} is:issue is:open assignee:@me`.replace(/"/g, '\\"');
|
|
191
|
+
const query = `query {
|
|
192
|
+
search(query: "${searchQuery}", type: ISSUE, first: ${ISSUE_LIST_LIMIT}) {
|
|
193
|
+
nodes {
|
|
194
|
+
... on Issue {
|
|
195
|
+
number
|
|
196
|
+
projectItems(first: 10, includeArchived: false) {
|
|
197
|
+
nodes {
|
|
198
|
+
project {
|
|
199
|
+
title
|
|
200
|
+
number
|
|
201
|
+
url
|
|
202
|
+
field(name: "${escapedField}") {
|
|
203
|
+
... on ProjectV2SingleSelectField {
|
|
204
|
+
options { name color }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
fieldValueByName(name: "${escapedField}") {
|
|
209
|
+
... on ProjectV2ItemFieldSingleSelectValue { name }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}`;
|
|
217
|
+
const raw = (await ghGraphql(query));
|
|
218
|
+
const nodes = raw?.data?.search?.nodes;
|
|
219
|
+
if (!Array.isArray(nodes))
|
|
220
|
+
return result;
|
|
221
|
+
for (const node of nodes) {
|
|
222
|
+
if (typeof node?.number !== "number")
|
|
223
|
+
continue;
|
|
224
|
+
const items = node.projectItems?.nodes ?? [];
|
|
225
|
+
const picked = pickProjectNode(items, configuredUrl);
|
|
226
|
+
if (!picked)
|
|
227
|
+
continue;
|
|
228
|
+
const info = toProjectInfo(picked);
|
|
229
|
+
if (info)
|
|
230
|
+
result.set(node.number, info);
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Orders the flat issue list so issues are contiguous by project, then by
|
|
236
|
+
* Status (board column order), then newest issue first. The dashboard derives
|
|
237
|
+
* its group headers by walking this already-ordered array.
|
|
238
|
+
*
|
|
239
|
+
* Project group order: a config-pinned project first, then other projects
|
|
240
|
+
* alphabetically, then issues with no project ("Sin proyecto") last.
|
|
241
|
+
*/
|
|
242
|
+
function sortGroupedIssues(issues, configuredUrl) {
|
|
243
|
+
const projectTier = (p) => {
|
|
244
|
+
if (!p)
|
|
245
|
+
return 2;
|
|
246
|
+
if (configuredUrl && p.projectUrl === configuredUrl)
|
|
247
|
+
return 0;
|
|
248
|
+
return 1;
|
|
249
|
+
};
|
|
250
|
+
return [...issues].sort((a, b) => {
|
|
251
|
+
const ta = projectTier(a.project);
|
|
252
|
+
const tb = projectTier(b.project);
|
|
253
|
+
if (ta !== tb)
|
|
254
|
+
return ta - tb;
|
|
255
|
+
if (a.project && b.project) {
|
|
256
|
+
const byTitle = a.project.projectTitle.localeCompare(b.project.projectTitle);
|
|
257
|
+
if (byTitle !== 0)
|
|
258
|
+
return byTitle;
|
|
259
|
+
if (a.project.statusOrder !== b.project.statusOrder) {
|
|
260
|
+
return a.project.statusOrder - b.project.statusOrder;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return b.issue.number - a.issue.number;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
105
266
|
/**
|
|
106
267
|
* Top-level loader: enriches each assigned issue with its worktree and
|
|
107
268
|
* session snapshot. Designed to be called on dashboard mount and on every
|
|
@@ -113,6 +274,8 @@ export async function loadDashboard(repoRoot) {
|
|
|
113
274
|
return null;
|
|
114
275
|
const worktreesByIssue = buildWorktreeIndex(repoRoot);
|
|
115
276
|
const metadata = readMetadata(repoRoot);
|
|
277
|
+
const projectCfg = metadata.project ?? {};
|
|
278
|
+
const configuredUrl = projectCfg.url ?? null;
|
|
116
279
|
// Fetch PRs in parallel for branches that actually have a worktree —
|
|
117
280
|
// issues without one wouldn't have a branch on this user's repo, so we
|
|
118
281
|
// skip the per-issue gh call for them. Detached worktrees (branch=null)
|
|
@@ -125,8 +288,13 @@ export async function loadDashboard(repoRoot) {
|
|
|
125
288
|
if (pr)
|
|
126
289
|
prByBranch.set(w.branch, pr);
|
|
127
290
|
});
|
|
128
|
-
|
|
129
|
-
|
|
291
|
+
// Project membership comes from a single GraphQL query; fetch it alongside
|
|
292
|
+
// the per-branch PR probes so neither blocks the other.
|
|
293
|
+
const [, projectByIssue] = await Promise.all([
|
|
294
|
+
Promise.all(prFetches),
|
|
295
|
+
fetchProjectAssignments(projectCfg.statusField ?? "Status", configuredUrl),
|
|
296
|
+
]);
|
|
297
|
+
const enriched = issues.map((issue) => {
|
|
130
298
|
const issueId = String(issue.number);
|
|
131
299
|
const worktreeRaw = worktreesByIssue.get(issueId) ?? null;
|
|
132
300
|
const sessionId = metadata.issues[issueId]?.session_id;
|
|
@@ -137,6 +305,8 @@ export async function loadDashboard(repoRoot) {
|
|
|
137
305
|
worktree,
|
|
138
306
|
session: readSessionState(repoRoot, issueId),
|
|
139
307
|
pr,
|
|
308
|
+
project: projectByIssue.get(issue.number) ?? null,
|
|
140
309
|
};
|
|
141
310
|
});
|
|
311
|
+
return sortGroupedIssues(enriched, configuredUrl);
|
|
142
312
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Update check: ask the npm registry for the latest published version and
|
|
2
|
+
// compare it against what's running. Best-effort — any failure (offline,
|
|
3
|
+
// timeout, private registry) resolves to null and the dashboard simply
|
|
4
|
+
// doesn't show an update hint.
|
|
5
|
+
const REGISTRY_TIMEOUT_MS = 3000;
|
|
6
|
+
export async function getLatestVersion(pkg) {
|
|
7
|
+
try {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const timer = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS);
|
|
10
|
+
const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`, {
|
|
11
|
+
signal: controller.signal,
|
|
12
|
+
headers: { accept: "application/json" },
|
|
13
|
+
});
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
return null;
|
|
17
|
+
const data = (await res.json());
|
|
18
|
+
return typeof data.version === "string" ? data.version : null;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Returns true when `latest` is strictly newer than `current`. Both are
|
|
25
|
+
// expected as plain `major.minor.patch` strings; anything unparseable is
|
|
26
|
+
// treated as "not newer" so we never nag on a bad comparison.
|
|
27
|
+
export function isNewerVersion(current, latest) {
|
|
28
|
+
const parse = (v) => v
|
|
29
|
+
.trim()
|
|
30
|
+
.split(".")
|
|
31
|
+
.map((n) => parseInt(n, 10));
|
|
32
|
+
const a = parse(current);
|
|
33
|
+
const b = parse(latest);
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
const ca = a[i] ?? 0;
|
|
36
|
+
const cb = b[i] ?? 0;
|
|
37
|
+
if (Number.isNaN(ca) || Number.isNaN(cb))
|
|
38
|
+
return false;
|
|
39
|
+
if (cb > ca)
|
|
40
|
+
return true;
|
|
41
|
+
if (cb < ca)
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
package/package.json
CHANGED