mintree 0.4.0 → 0.4.2

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.
@@ -16,9 +16,24 @@ import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
16
16
  import { readMetadata } from "../lib/metadata.js";
17
17
  import { createProvider } from "../lib/providers/index.js";
18
18
  import { loadDashboard } from "../lib/dashboard.js";
19
+ import { priorityDisplay } from "../lib/priority.js";
19
20
  const require = createRequire(import.meta.url);
20
21
  const { version: mintreeVersion } = require("../../package.json");
21
22
  export const description = "Interactive dashboard listing open issues assigned to you with worktree + session state";
23
+ function isOrphan(d) {
24
+ return d.orphan === true;
25
+ }
26
+ function tabIssues(issues, tab) {
27
+ return issues.filter((d) => (tab === "issues" ? !isOrphan(d) : isOrphan(d)));
28
+ }
29
+ function currentSelected(s) {
30
+ const displayed = tabIssues(s.issues, s.activeTab);
31
+ const selectedIndex = s.activeTab === "issues" ? s.issuesIndex : s.worktreesIndex;
32
+ return { displayed, selectedIndex };
33
+ }
34
+ function withSelectedIndex(s, next) {
35
+ return s.activeTab === "issues" ? { ...s, issuesIndex: next } : { ...s, worktreesIndex: next };
36
+ }
22
37
  // xterm/iTerm/etc switch to the alternate screen buffer with these escape
23
38
  // codes. Using the buffer means the dashboard owns the whole window for its
24
39
  // lifetime, and the previous shell content reappears unchanged the moment
@@ -152,8 +167,10 @@ function useTerminalSize() {
152
167
  }, [stdout]);
153
168
  return size;
154
169
  }
155
- function HeaderRow({ repoName, claudeVersion, issueCount, updateAvailable, }) {
156
- 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}) ` }) })] }));
170
+ function HeaderRow({ repoName, claudeVersion, issueCount, worktreeCount, activeTab, updateAvailable, }) {
171
+ const issuesLabel = ` Issues (${issueCount}) `;
172
+ const worktreesLabel = ` Worktrees (${worktreeCount}) `;
173
+ 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}` })] }), _jsxs(Box, { children: [activeTab === "issues" ? (_jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: issuesLabel })) : (_jsx(Text, { dimColor: true, children: issuesLabel })), _jsx(Text, { children: " " }), activeTab === "worktrees" ? (_jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: worktreesLabel })) : (_jsx(Text, { dimColor: true, children: worktreesLabel })), _jsx(Text, { dimColor: true, children: " ← / → switch tab" })] })] }));
157
174
  }
158
175
  function FooterRow({ phase, overlayKind, latestVersion, listWidth, }) {
159
176
  if (phase === "error") {
@@ -204,13 +221,17 @@ function IssueListRow({ d, selected, identifierWidth, rowWidth, }) {
204
221
  // Status-coloured leading dot — same convention as santree. Falls back to
205
222
  // gray when the issue has no project board membership.
206
223
  const dotColor = d.project?.statusColor ?? "gray";
224
+ // Compact priority glyph (Linear only; GitHub rows render a blank). The
225
+ // fixed single-width icon keeps the ids aligned whether or not a row has a
226
+ // priority. See lib/priority.ts.
227
+ const prio = priorityDisplay(d.issue.priority);
207
228
  const title = d.issue.title;
208
229
  // The leading-dot Text and the rest are nested under a single Text so the
209
230
  // selection background paints the whole row in one contiguous block.
210
231
  // `wrap="truncate"` clamps the row to a single line and Ink renders an
211
232
  // ellipsis at the cut. The outer Box has a fixed width so the wrap
212
233
  // behaviour knows where to truncate.
213
- return (_jsx(Box, { width: rowWidth, children: _jsxs(Text, { wrap: "truncate", backgroundColor: selected ? "blue" : undefined, color: selected ? "white" : undefined, children: [" ", _jsx(Text, { color: selected ? "white" : dotColor, children: "\u25CF" }), ` ${idText} ${title}`] }) }));
234
+ return (_jsx(Box, { width: rowWidth, children: _jsxs(Text, { wrap: "truncate", backgroundColor: selected ? "blue" : undefined, color: selected ? "white" : undefined, children: [" ", _jsx(Text, { color: selected ? "white" : dotColor, children: "\u25CF" }), " ", _jsx(Text, { color: selected ? "white" : prio.color, children: prio.icon }), ` ${idText} ${title}`] }) }));
214
235
  }
215
236
  // A project board header — the top level of the grouped issue list. Mirrors
216
237
  // the bold project name + dim count seen in the santree dashboard.
@@ -646,18 +667,32 @@ export default function Dashboard() {
646
667
  return;
647
668
  }
648
669
  setState((prev) => {
649
- const previousIndex = prev.phase === "ready" ? prev.selectedIndex : 0;
650
- const previousOverlay = prev.phase === "ready" ? prev.overlay : null;
651
- const previousToast = prev.phase === "ready" ? prev.toast : null;
652
- const previousScroll = prev.phase === "ready" ? prev.detailScrollOffset : 0;
653
- const clamped = Math.min(previousIndex, Math.max(0, issues.length - 1));
654
- // Preserve scroll only when the selected issue stayed put — clamping
655
- // to a different row means the user is now reading something else.
656
- const detailScrollOffset = clamped === previousIndex ? previousScroll : 0;
670
+ const prevReady = prev.phase === "ready" ? prev : null;
671
+ const activeTab = prevReady?.activeTab ?? "issues";
672
+ const previousIssuesIndex = prevReady?.issuesIndex ?? 0;
673
+ const previousWorktreesIndex = prevReady?.worktreesIndex ?? 0;
674
+ const previousOverlay = prevReady?.overlay ?? null;
675
+ const previousToast = prevReady?.toast ?? null;
676
+ const previousScroll = prevReady?.detailScrollOffset ?? 0;
677
+ const issuesList = tabIssues(issues, "issues");
678
+ const worktreesList = tabIssues(issues, "worktrees");
679
+ const issuesIndex = Math.min(previousIssuesIndex, Math.max(0, issuesList.length - 1));
680
+ const worktreesIndex = Math.min(previousWorktreesIndex, Math.max(0, worktreesList.length - 1));
681
+ // Preserve scroll only when the active tab's selected issue still
682
+ // resolves to the same row — clamping or list churn means the user
683
+ // is now reading something else.
684
+ const prevDisplayed = prevReady ? tabIssues(prevReady.issues, activeTab) : [];
685
+ const nextDisplayed = activeTab === "issues" ? issuesList : worktreesList;
686
+ const prevSelectedId = prevDisplayed[activeTab === "issues" ? previousIssuesIndex : previousWorktreesIndex]?.issue
687
+ .id ?? null;
688
+ const nextSelectedId = nextDisplayed[activeTab === "issues" ? issuesIndex : worktreesIndex]?.issue.id ?? null;
689
+ const detailScrollOffset = prevSelectedId !== null && prevSelectedId === nextSelectedId ? previousScroll : 0;
657
690
  return {
658
691
  phase: "ready",
659
692
  issues,
660
- selectedIndex: clamped,
693
+ activeTab,
694
+ issuesIndex,
695
+ worktreesIndex,
661
696
  detailScrollOffset,
662
697
  refreshing: false,
663
698
  overlay: previousOverlay,
@@ -714,10 +749,11 @@ export default function Dashboard() {
714
749
  if (prev.overlay)
715
750
  return prev; // overlay pauses scroll routing
716
751
  if (inLeftPane) {
717
- const next = Math.max(0, Math.min(prev.issues.length - 1, prev.selectedIndex + delta));
718
- if (next === prev.selectedIndex)
752
+ const { displayed, selectedIndex } = currentSelected(prev);
753
+ const next = Math.max(0, Math.min(displayed.length - 1, selectedIndex + delta));
754
+ if (next === selectedIndex)
719
755
  return prev;
720
- return { ...prev, selectedIndex: next, detailScrollOffset: 0 };
756
+ return { ...withSelectedIndex(prev, next), detailScrollOffset: 0 };
721
757
  }
722
758
  const next = Math.max(0, prev.detailScrollOffset + delta);
723
759
  if (next === prev.detailScrollOffset)
@@ -788,20 +824,23 @@ export default function Dashboard() {
788
824
  }
789
825
  if (state.phase !== "ready")
790
826
  return;
827
+ if (key.leftArrow || key.rightArrow) {
828
+ // Two tabs only — either arrow toggles. Per-tab indices are
829
+ // preserved, so the user returns to the row they left.
830
+ const next = state.activeTab === "issues" ? "worktrees" : "issues";
831
+ setState({ ...state, activeTab: next, detailScrollOffset: 0 });
832
+ return;
833
+ }
791
834
  if (key.upArrow || input === "k") {
792
- setState({
793
- ...state,
794
- selectedIndex: Math.max(0, state.selectedIndex - 1),
795
- detailScrollOffset: 0,
796
- });
835
+ const { selectedIndex } = currentSelected(state);
836
+ const nextIndex = Math.max(0, selectedIndex - 1);
837
+ setState({ ...withSelectedIndex(state, nextIndex), detailScrollOffset: 0 });
797
838
  return;
798
839
  }
799
840
  if (key.downArrow || input === "j") {
800
- setState({
801
- ...state,
802
- selectedIndex: Math.min(state.issues.length - 1, state.selectedIndex + 1),
803
- detailScrollOffset: 0,
804
- });
841
+ const { displayed, selectedIndex } = currentSelected(state);
842
+ const nextIndex = Math.min(Math.max(0, displayed.length - 1), selectedIndex + 1);
843
+ setState({ ...withSelectedIndex(state, nextIndex), detailScrollOffset: 0 });
805
844
  return;
806
845
  }
807
846
  if (key.pageUp) {
@@ -824,7 +863,8 @@ export default function Dashboard() {
824
863
  return;
825
864
  }
826
865
  if (input === "o") {
827
- const issue = state.issues[state.selectedIndex];
866
+ const { displayed, selectedIndex } = currentSelected(state);
867
+ const issue = displayed[selectedIndex];
828
868
  // Orphan rows carry an empty URL — nothing to open. Skip silently
829
869
  // rather than asking the OS to open an empty string.
830
870
  if (issue && issue.issue.url)
@@ -832,7 +872,8 @@ export default function Dashboard() {
832
872
  return;
833
873
  }
834
874
  if (input === "w") {
835
- const issue = state.issues[state.selectedIndex];
875
+ const { displayed, selectedIndex } = currentSelected(state);
876
+ const issue = displayed[selectedIndex];
836
877
  if (!issue)
837
878
  return;
838
879
  if (issue.worktree) {
@@ -843,7 +884,8 @@ export default function Dashboard() {
843
884
  return;
844
885
  }
845
886
  if (key.return) {
846
- const issue = state.issues[state.selectedIndex];
887
+ const { displayed, selectedIndex } = currentSelected(state);
888
+ const issue = displayed[selectedIndex];
847
889
  if (!issue)
848
890
  return;
849
891
  if (issue.worktree) {
@@ -859,7 +901,8 @@ export default function Dashboard() {
859
901
  return;
860
902
  }
861
903
  if (input === "d") {
862
- const issue = state.issues[state.selectedIndex];
904
+ const { displayed, selectedIndex } = currentSelected(state);
905
+ const issue = displayed[selectedIndex];
863
906
  if (!issue || !issue.worktree)
864
907
  return;
865
908
  setState({
@@ -1129,8 +1172,11 @@ export default function Dashboard() {
1129
1172
  if (state.phase === "error") {
1130
1173
  return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", state.message] }), state.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", state.hint] }) })), _jsx(Box, { marginTop: 1, children: _jsx(FooterRow, { phase: "error" }) })] }));
1131
1174
  }
1132
- const { issues, selectedIndex, refreshing, overlay, toast } = state;
1133
- const selected = issues[selectedIndex] ?? null;
1175
+ const { issues, refreshing, overlay, toast, activeTab } = state;
1176
+ const { displayed, selectedIndex } = currentSelected(state);
1177
+ const selected = displayed[selectedIndex] ?? null;
1178
+ const issuesTabCount = issues.reduce((n, d) => (isOrphan(d) ? n : n + 1), 0);
1179
+ const worktreesTabCount = issues.length - issuesTabCount;
1134
1180
  const onOverlayDescChange = (next) => {
1135
1181
  if (state.phase !== "ready" || !state.overlay)
1136
1182
  return;
@@ -1157,7 +1203,7 @@ export default function Dashboard() {
1157
1203
  const listWidthPct = 0.5;
1158
1204
  const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
1159
1205
  const detailWidth = columns - listWidth - 2; // border slack
1160
- const identifierWidth = Math.max(3, ...issues.map((d) => d.issue.id.length));
1206
+ const identifierWidth = Math.max(3, ...displayed.map((d) => d.issue.id.length));
1161
1207
  // Reserve rows: header (2), top borders (1), footer (3).
1162
1208
  const listVisibleRows = Math.max(3, rows - 9);
1163
1209
  // Detail pane content height inside the bordered box. Header eats 2 rows,
@@ -1171,8 +1217,14 @@ export default function Dashboard() {
1171
1217
  // Grouped list: build the project/status header rows interleaved with
1172
1218
  // issue rows, then split into a sticky header region (the selected issue's
1173
1219
  // project + Status, pinned to the top) and a windowed scrollable body.
1174
- const listRows = buildListRows(issues);
1220
+ // The Worktrees tab renders flat — the tab title already labels the group,
1221
+ // so the per-project headers would just be visual noise.
1222
+ const listRows = activeTab === "issues"
1223
+ ? buildListRows(displayed)
1224
+ : displayed.map((d, index) => ({ kind: "issue", d, index }));
1175
1225
  const listView = windowListRows(listRows, selectedIndex, listVisibleRows);
1176
1226
  const listContentWidth = Math.max(8, listWidth - 4);
1177
- 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, 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, 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, listWidth: listWidth })] })] }));
1227
+ 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: issuesTabCount, worktreeCount: worktreesTabCount, activeTab: activeTab, 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: displayed.length === 0 ? (_jsx(Text, { dimColor: true, children: activeTab === "issues"
1228
+ ? "No open issues assigned to you in this repo."
1229
+ : "No orphaned worktrees — anything in `.mintree/worktrees/` matches an open issue." })) : (_jsxs(_Fragment, { children: [listView.sticky.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, 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, 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, listWidth: listWidth })] })] }));
1178
1230
  }
@@ -3,6 +3,7 @@ import * as path from "path";
3
3
  import { listWorktrees, getWorktreesDir, isDirty, getAheadBehind, } from "./git.js";
4
4
  import { readMetadata } from "./metadata.js";
5
5
  import { fetchPrForBranch } from "./pr.js";
6
+ import { prioritySortRank } from "./priority.js";
6
7
  import { createProvider } from "./providers/index.js";
7
8
  /**
8
9
  * Builds a map from issue id (the canonical string — "100" on GitHub,
@@ -95,6 +96,13 @@ function sortGroupedIssues(issues, configuredUrl) {
95
96
  return a.project.statusOrder - b.project.statusOrder;
96
97
  }
97
98
  }
99
+ // Within a status group, surface higher-priority issues first
100
+ // (Urgent → Low; "no priority" sinks to the bottom). Orphans and
101
+ // GitHub rows have null priority and so fall through to the date sort.
102
+ const pa = prioritySortRank(a.issue.priority);
103
+ const pb = prioritySortRank(b.issue.priority);
104
+ if (pa !== pb)
105
+ return pa - pb;
98
106
  // Newest-first for issues — id is a numeric-or-prefixed string. Numeric
99
107
  // compare falls back to localeCompare for non-numeric ids (Linear's
100
108
  // "FE-123" form).
@@ -137,6 +145,7 @@ function buildOrphanRows(worktreesByIssue, assignedIds, sessionLookup, prByBranc
137
145
  body: "",
138
146
  createdAt: "",
139
147
  updatedAt: "",
148
+ priority: null,
140
149
  },
141
150
  worktree,
142
151
  session: sessionLookup(issueId),
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Issue priority, normalised across providers.
3
+ *
4
+ * Linear exposes a native `priority` field on the 0-4 scale:
5
+ * 0 = No priority, 1 = Urgent, 2 = High, 3 = Medium, 4 = Low.
6
+ * GitHub Issues has no native priority concept, so its provider always yields
7
+ * `null` here — the dashboard simply renders no priority glyph for those rows.
8
+ *
9
+ * `ProviderIssue.priority` stores the raw Linear number (or null), and these
10
+ * helpers turn it into a compact dashboard glyph and a sort rank. Keeping the
11
+ * mapping in one module means the render path (dashboard.tsx) and the sort
12
+ * path (dashboard.ts) stay in lock-step.
13
+ */
14
+ export type PriorityValue = number | null | undefined;
15
+ export type PriorityDisplay = {
16
+ /** Human label, e.g. "Urgent". Empty string when there's no priority. */
17
+ label: string;
18
+ /** Single-width glyph for the list row. A space when there's no priority. */
19
+ icon: string;
20
+ /** Ink-renderable colour name for the glyph. */
21
+ color: string;
22
+ };
23
+ /**
24
+ * Maps a raw priority value to its dashboard glyph. Urgent reads as a bold
25
+ * red bang; High/Medium/Low use arrows that step down in weight and colour.
26
+ * "No priority" (0) and null both render as a blank, keeping rows aligned
27
+ * without drawing the eye.
28
+ */
29
+ export declare function priorityDisplay(priority: PriorityValue): PriorityDisplay;
30
+ /**
31
+ * Sort rank for "highest priority first". Urgent (1) sorts before Low (4);
32
+ * "No priority" (0) and null sort last. Used as a tie-break inside a status
33
+ * group before the newest-first fallback.
34
+ */
35
+ export declare function prioritySortRank(priority: PriorityValue): number;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Issue priority, normalised across providers.
3
+ *
4
+ * Linear exposes a native `priority` field on the 0-4 scale:
5
+ * 0 = No priority, 1 = Urgent, 2 = High, 3 = Medium, 4 = Low.
6
+ * GitHub Issues has no native priority concept, so its provider always yields
7
+ * `null` here — the dashboard simply renders no priority glyph for those rows.
8
+ *
9
+ * `ProviderIssue.priority` stores the raw Linear number (or null), and these
10
+ * helpers turn it into a compact dashboard glyph and a sort rank. Keeping the
11
+ * mapping in one module means the render path (dashboard.tsx) and the sort
12
+ * path (dashboard.ts) stay in lock-step.
13
+ */
14
+ const NONE = { label: "", icon: " ", color: "gray" };
15
+ /**
16
+ * Maps a raw priority value to its dashboard glyph. Urgent reads as a bold
17
+ * red bang; High/Medium/Low use arrows that step down in weight and colour.
18
+ * "No priority" (0) and null both render as a blank, keeping rows aligned
19
+ * without drawing the eye.
20
+ */
21
+ export function priorityDisplay(priority) {
22
+ switch (priority) {
23
+ case 1:
24
+ return { label: "Urgent", icon: "!", color: "red" };
25
+ case 2:
26
+ return { label: "High", icon: "↑", color: "red" };
27
+ case 3:
28
+ return { label: "Medium", icon: "=", color: "yellow" };
29
+ case 4:
30
+ return { label: "Low", icon: "↓", color: "blue" };
31
+ default:
32
+ return NONE;
33
+ }
34
+ }
35
+ /**
36
+ * Sort rank for "highest priority first". Urgent (1) sorts before Low (4);
37
+ * "No priority" (0) and null sort last. Used as a tie-break inside a status
38
+ * group before the newest-first fallback.
39
+ */
40
+ export function prioritySortRank(priority) {
41
+ if (priority == null || priority === 0)
42
+ return Number.POSITIVE_INFINITY;
43
+ return priority;
44
+ }
@@ -142,6 +142,8 @@ export class GithubProvider {
142
142
  body: raw.body,
143
143
  createdAt: raw.createdAt,
144
144
  updatedAt: raw.updatedAt,
145
+ // GitHub Issues has no native priority field.
146
+ priority: null,
145
147
  }));
146
148
  }
147
149
  catch {
@@ -271,6 +271,7 @@ const BOOTSTRAP_QUERY = /* GraphQL */ `
271
271
  title
272
272
  description
273
273
  url
274
+ priority
274
275
  createdAt
275
276
  updatedAt
276
277
  team {
@@ -329,6 +330,10 @@ function mapIssueToProviderIssue(wi) {
329
330
  body: wi.description ?? "",
330
331
  createdAt: wi.createdAt ?? "",
331
332
  updatedAt: wi.updatedAt ?? "",
333
+ // Linear sends 0 for "No priority"; normalise it (and any missing
334
+ // value) to null so the dashboard treats it the same as GitHub's
335
+ // no-priority rows.
336
+ priority: wi.priority && wi.priority > 0 ? wi.priority : null,
332
337
  };
333
338
  }
334
339
  export class LinearProvider {
@@ -26,6 +26,7 @@ export type ProviderIssue = {
26
26
  body: string;
27
27
  createdAt: string;
28
28
  updatedAt: string;
29
+ priority: number | null;
29
30
  };
30
31
  /**
31
32
  * The issue's membership on a project board (GitHub Projects v2 / Linear
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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>",