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.
@@ -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
- const line = ` ${idText} ${icon} ${title}`;
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: " #N ICON " (id + 4 cols of pad/icon).
897
- const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 8);
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
- const startIdx = Math.max(0, Math.min(Math.max(0, issues.length - listVisibleRows), selectedIndex - Math.floor(listVisibleRows / 2)));
909
- const endIdx = Math.min(issues.length, startIdx + listVisibleRows);
910
- const slice = issues.slice(startIdx, endIdx);
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) => {
912
- const absoluteIdx = startIdx + i;
913
- return (_jsx(IssueListRow, { d: d, selected: absoluteIdx === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }, d.issue.number));
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 })] })] }));
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
  }
@@ -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
@@ -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
- await Promise.all(prFetches);
129
- return issues.map((issue) => {
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,2 @@
1
+ export declare function getLatestVersion(pkg: string): Promise<string | null>;
2
+ export declare function isNewerVersion(current: string, latest: string): boolean;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Issue-driven git worktrees + Claude Code sessions for repos with an opinionated SDD+TDD flow.",
5
5
  "license": "MIT",
6
6
  "author": "Martin Mineo <mmineo@canarytechnologies.com>",