mintree 0.1.7 → 0.1.9

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.
@@ -208,10 +208,147 @@ function IssueListRow({ d, selected, identifierWidth, maxTitleWidth, }) {
208
208
  const title = truncate(d.issue.title, maxTitleWidth);
209
209
  // One single Text with a single string so the background highlight is
210
210
  // continuous across the whole row. Coloured per-state icons live in the
211
- // detail pane instead — keeps the list selection visually solid.
212
- 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}`;
213
214
  return (_jsx(Box, { children: _jsx(Text, { backgroundColor: selected ? "blue" : undefined, color: selected ? "white" : undefined, children: line }) }));
214
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
+ }
275
+ /**
276
+ * Splits the grouped list into a pinned-header region and a scrollable body
277
+ * windowed around the selected issue. The selected issue's own project and
278
+ * Status headers are lifted out of the scroll flow so they stay visible
279
+ * ("sticky") while paging through a long group — without them the user loses
280
+ * track of which project/status the rows below belong to.
281
+ *
282
+ * Navigation is unaffected: it still moves `selectedIndex` over the flat
283
+ * issues array; this only decides what's drawn where.
284
+ */
285
+ function windowListRows(listRows, selectedIndex, viewportRows) {
286
+ const selRow = listRows.findIndex((r) => r.kind === "issue" && r.index === selectedIndex);
287
+ const anchor = selRow >= 0 ? selRow : 0;
288
+ // Walk back from the selected row to find its enclosing project and Status
289
+ // headers. A "Sin proyecto" issue has a project header but no status one.
290
+ let projIdx = -1;
291
+ let statusIdx = -1;
292
+ for (let i = anchor; i >= 0; i--) {
293
+ const r = listRows[i];
294
+ if (!r)
295
+ continue;
296
+ if (statusIdx === -1 && r.kind === "status")
297
+ statusIdx = i;
298
+ if (r.kind === "project") {
299
+ projIdx = i;
300
+ break;
301
+ }
302
+ }
303
+ // Pinned rows are pulled out of the body so they never render twice. The
304
+ // blank spacer that precedes a project header is dropped too, so the pane
305
+ // doesn't open with an empty line.
306
+ const pinned = new Set();
307
+ const sticky = [];
308
+ if (projIdx >= 0) {
309
+ pinned.add(projIdx);
310
+ sticky.push(listRows[projIdx]);
311
+ const before = listRows[projIdx - 1];
312
+ if (before && before.kind === "spacer")
313
+ pinned.add(projIdx - 1);
314
+ }
315
+ if (statusIdx >= 0) {
316
+ pinned.add(statusIdx);
317
+ sticky.push(listRows[statusIdx]);
318
+ }
319
+ const body = [];
320
+ let anchorInBody = 0;
321
+ listRows.forEach((r, i) => {
322
+ if (pinned.has(i))
323
+ return;
324
+ if (i === anchor)
325
+ anchorInBody = body.length;
326
+ body.push(r);
327
+ });
328
+ const bodyViewport = Math.max(1, viewportRows - sticky.length);
329
+ const maxStart = Math.max(0, body.length - bodyViewport);
330
+ const start = Math.max(0, Math.min(maxStart, anchorInBody - Math.floor(bodyViewport / 2)));
331
+ const end = Math.min(body.length, start + bodyViewport);
332
+ return {
333
+ sticky,
334
+ body: body.slice(start, end),
335
+ issuesAbove: body.slice(0, start).filter((r) => r.kind === "issue").length,
336
+ issuesBelow: body.slice(end).filter((r) => r.kind === "issue").length,
337
+ };
338
+ }
339
+ // Renders a single grouped-list row — used for both the sticky header region
340
+ // and the scrollable body so the two stay visually identical.
341
+ function ListRowView({ row, selectedIndex, identifierWidth, maxTitleWidth, width, }) {
342
+ if (row.kind === "spacer")
343
+ return _jsx(Text, { children: " " });
344
+ if (row.kind === "project") {
345
+ return _jsx(ProjectHeaderRow, { title: row.title, count: row.count, width: width });
346
+ }
347
+ if (row.kind === "status") {
348
+ return _jsx(StatusHeaderRow, { name: row.name, color: row.color, count: row.count, width: width });
349
+ }
350
+ return (_jsx(IssueListRow, { d: row.d, selected: row.index === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }));
351
+ }
215
352
  // Word-wraps a single line at `width` columns, breaking on the last space
216
353
  // before the limit when that yields a reasonable cut. Falls back to a hard
217
354
  // cut for unbroken runs (long URLs, code-fence content) so the detail pane
@@ -298,6 +435,13 @@ function buildDetailLines(d, width) {
298
435
  const labelText = labels.length > 0 ? labels.map((l) => `[${l}]`).join(" ") : "(no labels)";
299
436
  for (const w2 of wrapLine(labelText, w))
300
437
  lines.push([{ text: w2, dim: true }]);
438
+ if (d.project) {
439
+ lines.push([
440
+ { text: "● ", color: d.project.statusColor },
441
+ { text: d.project.status ?? "Sin estado", color: d.project.statusColor },
442
+ { text: ` · ${truncate(d.project.projectTitle, Math.max(8, w - 12))}`, dim: true },
443
+ ]);
444
+ }
301
445
  lines.push([
302
446
  {
303
447
  text: `updated ${relativeTime(d.issue.updatedAt)} · created ${relativeTime(d.issue.createdAt)}`,
@@ -900,8 +1044,8 @@ export default function Dashboard() {
900
1044
  const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
901
1045
  const detailWidth = columns - listWidth - 2; // border slack
902
1046
  const identifierWidth = Math.max(3, ...issues.map((d) => `#${d.issue.number}`.length));
903
- // Lista ocupa todo menos: " #N ICON " (id + 4 cols of pad/icon).
904
- const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 8);
1047
+ // Lista ocupa todo menos: " #N ICON " (2-space nest indent + id + icon).
1048
+ const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 9);
905
1049
  // Reserve rows: header (2), top borders (1), footer (3).
906
1050
  const listVisibleRows = Math.max(3, rows - 9);
907
1051
  // Detail pane content height inside the bordered box. Header eats 2 rows,
@@ -912,11 +1056,11 @@ export default function Dashboard() {
912
1056
  // correct pane. Ref lets the stdin listener (mounted once) read the live
913
1057
  // value without re-binding on every resize.
914
1058
  listWidthRef.current = listWidth;
915
- const startIdx = Math.max(0, Math.min(Math.max(0, issues.length - listVisibleRows), selectedIndex - Math.floor(listVisibleRows / 2)));
916
- const endIdx = Math.min(issues.length, startIdx + listVisibleRows);
917
- const slice = issues.slice(startIdx, endIdx);
918
- 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: [slice.map((d, i) => {
919
- const absoluteIdx = startIdx + i;
920
- return (_jsx(IssueListRow, { d: d, selected: absoluteIdx === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }, d.issue.number));
921
- }), 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, latestVersion: latestVersion })] })] }));
1059
+ // Grouped list: build the project/status header rows interleaved with
1060
+ // issue rows, then split into a sticky header region (the selected issue's
1061
+ // project + Status, pinned to the top) and a windowed scrollable body.
1062
+ const listRows = buildListRows(issues);
1063
+ const listView = windowListRows(listRows, selectedIndex, listVisibleRows);
1064
+ const listContentWidth = Math.max(8, listWidth - 4);
1065
+ 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: [listView.sticky.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth, width: listContentWidth }, `sticky-${i}`))), listView.issuesAbove > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", listView.issuesAbove, " more above"] })), listView.body.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth, width: listContentWidth }, `body-${i}`))), listView.issuesBelow > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", listView.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 })] })] }));
922
1066
  }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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>",