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.
- package/dist/commands/dashboard.js +155 -11
- package/dist/lib/dashboard.d.ts +18 -0
- package/dist/lib/dashboard.js +172 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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: "
|
|
904
|
-
const maxTitleWidth = Math.max(8, listWidth - identifierWidth -
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
}
|
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
|
}
|
package/package.json
CHANGED