santree 0.7.0 → 0.7.3

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.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Triage SLA + snooze formatting for the Triage tab. Linear issues in triage
3
+ * carry an `slaBreachesAt` ISO timestamp (when the response SLA is breached);
4
+ * we render a short, urgency-coded countdown badge — the same "2d 23h" / "13h"
5
+ * shape Linear itself shows — used by both the issue list (right column) and
6
+ * the detail panel.
7
+ *
8
+ * Urgency buckets (by time until breach):
9
+ * - breached (≤ 0) → red, "breached"
10
+ * - imminent (< 24h) → red, "5h" / "45m"
11
+ * - soon (< 48h) → yellow, "1d 6h"
12
+ * - later (≥ 48h) → gray, "3d 2h"
13
+ *
14
+ * Returns null when there is no SLA so callers can render nothing.
15
+ */
16
+ export interface SlaInfo {
17
+ /** Short badge text, e.g. "2d 23h", "13h", or "breached". */
18
+ label: string;
19
+ /** Ink color name keyed to urgency. */
20
+ color: "red" | "yellow" | "gray";
21
+ /** True for breached / < 24h — the cases worth a bold warning. */
22
+ urgent: boolean;
23
+ }
24
+ export declare function formatSla(slaBreachesAt: string | null | undefined, now?: Date): SlaInfo | null;
25
+ /** True when the issue is snoozed past the current moment. A snoozed triage
26
+ * issue is parked — the UI greys it and sinks it below active work. */
27
+ export declare function isSnoozed(snoozedUntilAt: string | null | undefined, now?: Date): boolean;
@@ -0,0 +1,35 @@
1
+ export function formatSla(slaBreachesAt, now = new Date()) {
2
+ if (!slaBreachesAt)
3
+ return null;
4
+ const breach = Date.parse(slaBreachesAt);
5
+ if (Number.isNaN(breach))
6
+ return null;
7
+ const ms = breach - now.getTime();
8
+ if (ms <= 0)
9
+ return { label: "breached", color: "red", urgent: true };
10
+ const totalMin = Math.floor(ms / 60_000);
11
+ const days = Math.floor(totalMin / 1440);
12
+ const hours = Math.floor((totalMin % 1440) / 60);
13
+ const mins = totalMin % 60;
14
+ let label;
15
+ if (days >= 1)
16
+ label = `${days}d ${hours}h`;
17
+ else if (hours >= 1)
18
+ label = `${hours}h`;
19
+ else
20
+ label = `${mins}m`;
21
+ const hoursLeft = ms / 3_600_000;
22
+ if (hoursLeft < 24)
23
+ return { label, color: "red", urgent: true };
24
+ if (hoursLeft < 48)
25
+ return { label, color: "yellow", urgent: false };
26
+ return { label, color: "gray", urgent: false };
27
+ }
28
+ /** True when the issue is snoozed past the current moment. A snoozed triage
29
+ * issue is parked — the UI greys it and sinks it below active work. */
30
+ export function isSnoozed(snoozedUntilAt, now = new Date()) {
31
+ if (!snoozedUntilAt)
32
+ return false;
33
+ const until = Date.parse(snoozedUntilAt);
34
+ return !Number.isNaN(until) && until > now.getTime();
35
+ }
@@ -1,7 +1,7 @@
1
1
  import type { PRInfo, PRCheck, PRReview, PRConversationComment, SearchPR } from "../github.js";
2
2
  import type { ClaudeTodo } from "../claude-todos.js";
3
- import type { AssignedIssue, Issue } from "../trackers/types.js";
4
- export type { AssignedIssue, Issue } from "../trackers/types.js";
3
+ import type { AssignedIssue, Comment, Issue, TriageSchedule } from "../trackers/types.js";
4
+ export type { AssignedIssue, Comment, Issue, TriageSchedule } from "../trackers/types.js";
5
5
  export interface WorktreeInfo {
6
6
  path: string;
7
7
  branch: string;
@@ -72,8 +72,11 @@ export interface EnrichedReviewPR {
72
72
  * Null when the user hasn't set one or the lookup failed. */
73
73
  authorName: string | null;
74
74
  }
75
- export type DashboardTab = "issues" | "trees" | "reviews";
76
- export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | "diff" | "help" | "tracker-select" | "issue-form" | "confirm-delete-issue" | null;
75
+ export type DashboardTab = "triage" | "issues" | "trees" | "reviews";
76
+ export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | "diff" | "help" | "tracker-select" | "issue-form" | "confirm-delete-issue" | "triage-ask" | "triage-schedule" | null;
77
+ /** Triage "ask Claude" flow sub-phase. "input" is owned by MultilineTextArea
78
+ * (Ctrl+D submit / Ctrl+G cancel); the rest are driven by the outer handler. */
79
+ export type TriageAskPhase = "input" | "running" | "answer" | "error";
77
80
  /** Tracker-selection overlay sub-phase: pick a tracker, then (for Linear with
78
81
  * multiple authenticated workspaces) pick the org. */
79
82
  export type TrackerSelectPhase = "root" | "linear-org";
@@ -96,6 +99,15 @@ export interface DiffFile {
96
99
  }
97
100
  export type CommitPhase = "idle" | "confirm-stage" | "choose-mode" | "filling" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
98
101
  export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "confirm" | "creating" | "done" | "error";
102
+ /** Per-worktree deletion progress. Deletions run concurrently (fire `d`, move
103
+ * on, fire `d` again) so each tracks its own staged log + phase; the detail
104
+ * pane renders the entry for the selected worktree. Entries are pruned on the
105
+ * next data refresh once the worktree is gone (see SET_DATA). */
106
+ export interface DeleteStatus {
107
+ logs: string;
108
+ phase: "removing" | "done" | "error";
109
+ error: string | null;
110
+ }
99
111
  export interface DashboardState {
100
112
  activeTab: DashboardTab;
101
113
  groups: ProjectGroup[];
@@ -112,6 +124,25 @@ export interface DashboardState {
112
124
  treeSelectedIndex: number;
113
125
  treeListScrollOffset: number;
114
126
  treeDetailScrollOffset: number;
127
+ triageGroups: ProjectGroup[];
128
+ flatTriage: DashboardIssue[];
129
+ triageSelectedIndex: number;
130
+ triageListScrollOffset: number;
131
+ triageDetailScrollOffset: number;
132
+ /** Lazily-fetched comments per triage issue identifier. Absent = not yet
133
+ * loaded (detail panel shows "loading…"); present (possibly empty) = loaded. */
134
+ triageCommentsById: Record<string, Comment[]>;
135
+ /** Triage on-call rotations for the viewer's teams (Linear). Loaded on
136
+ * refresh; shown via the `triage-schedule` overlay and a compact line in the
137
+ * triage detail pane. */
138
+ triageSchedules: TriageSchedule[];
139
+ triageScheduleScrollOffset: number;
140
+ triageAskTicketId: string | null;
141
+ triageAskPhase: TriageAskPhase;
142
+ triageAskQuestion: string;
143
+ triageAskAnswer: string | null;
144
+ triageAskError: string | null;
145
+ triageAskScrollOffset: number;
115
146
  trackerSelectPhase: TrackerSelectPhase;
116
147
  trackerSelectIndex: number;
117
148
  trackerSelectOrgs: TrackerOrgOption[];
@@ -130,7 +161,8 @@ export interface DashboardState {
130
161
  creatingForTicket: string | null;
131
162
  creationLogs: string;
132
163
  creationError: string | null;
133
- deletingForTicket: string | null;
164
+ /** In-flight (and just-finished) worktree deletions, keyed by ticket id. */
165
+ deletingTickets: Record<string, DeleteStatus>;
134
166
  commitPhase: CommitPhase;
135
167
  commitMessage: string;
136
168
  commitError: string | null;
@@ -183,6 +215,8 @@ export type DashboardAction = {
183
215
  flatIssues: DashboardIssue[];
184
216
  treeGroups: ProjectGroup[];
185
217
  flatTrees: DashboardIssue[];
218
+ triageGroups: ProjectGroup[];
219
+ flatTriage: DashboardIssue[];
186
220
  } | {
187
221
  type: "SELECT";
188
222
  index: number;
@@ -195,6 +229,48 @@ export type DashboardAction = {
195
229
  } | {
196
230
  type: "TREE_SCROLL_DETAIL";
197
231
  offset: number;
232
+ } | {
233
+ type: "TRIAGE_SELECT";
234
+ index: number;
235
+ } | {
236
+ type: "TRIAGE_SCROLL_LIST";
237
+ offset: number;
238
+ } | {
239
+ type: "TRIAGE_SCROLL_DETAIL";
240
+ offset: number;
241
+ } | {
242
+ type: "TRIAGE_COMMENTS_LOADED";
243
+ id: string;
244
+ comments: Comment[];
245
+ } | {
246
+ type: "SET_TRIAGE_SCHEDULES";
247
+ schedules: TriageSchedule[];
248
+ } | {
249
+ type: "TRIAGE_SCHEDULE_OPEN";
250
+ } | {
251
+ type: "TRIAGE_SCHEDULE_SCROLL";
252
+ offset: number;
253
+ } | {
254
+ type: "TRIAGE_SCHEDULE_CLOSE";
255
+ } | {
256
+ type: "TRIAGE_ASK_OPEN";
257
+ ticketId: string;
258
+ } | {
259
+ type: "TRIAGE_ASK_CHANGE";
260
+ value: string;
261
+ } | {
262
+ type: "TRIAGE_ASK_RUN";
263
+ } | {
264
+ type: "TRIAGE_ASK_ANSWER";
265
+ answer: string;
266
+ } | {
267
+ type: "TRIAGE_ASK_ERROR";
268
+ error: string;
269
+ } | {
270
+ type: "TRIAGE_ASK_SCROLL";
271
+ offset: number;
272
+ } | {
273
+ type: "TRIAGE_ASK_CLOSE";
198
274
  } | {
199
275
  type: "TRACKER_SELECT_OPEN";
200
276
  } | {
@@ -268,8 +344,17 @@ export type DashboardAction = {
268
344
  } | {
269
345
  type: "DELETE_START";
270
346
  ticketId: string;
347
+ } | {
348
+ type: "DELETE_LOG";
349
+ ticketId: string;
350
+ logs: string;
271
351
  } | {
272
352
  type: "DELETE_DONE";
353
+ ticketId: string;
354
+ } | {
355
+ type: "DELETE_ERROR";
356
+ ticketId: string;
357
+ error: string;
273
358
  } | {
274
359
  type: "COMMIT_START";
275
360
  /** Null when committing on a non-ticket branch (e.g. the main
@@ -15,6 +15,20 @@ export const initialState = {
15
15
  treeSelectedIndex: 0,
16
16
  treeListScrollOffset: 0,
17
17
  treeDetailScrollOffset: 0,
18
+ triageGroups: [],
19
+ flatTriage: [],
20
+ triageSelectedIndex: 0,
21
+ triageListScrollOffset: 0,
22
+ triageDetailScrollOffset: 0,
23
+ triageCommentsById: {},
24
+ triageSchedules: [],
25
+ triageScheduleScrollOffset: 0,
26
+ triageAskTicketId: null,
27
+ triageAskPhase: "input",
28
+ triageAskQuestion: "",
29
+ triageAskAnswer: null,
30
+ triageAskError: null,
31
+ triageAskScrollOffset: 0,
18
32
  trackerSelectPhase: "root",
19
33
  trackerSelectIndex: 0,
20
34
  trackerSelectOrgs: [],
@@ -33,7 +47,7 @@ export const initialState = {
33
47
  creatingForTicket: null,
34
48
  creationLogs: "",
35
49
  creationError: null,
36
- deletingForTicket: null,
50
+ deletingTickets: {},
37
51
  commitPhase: "idle",
38
52
  commitMessage: "",
39
53
  commitError: null,
@@ -91,19 +105,36 @@ export function reducer(state, action) {
91
105
  if (found >= 0)
92
106
  newTreeIndex = found;
93
107
  }
108
+ const prevTriageId = state.flatTriage[state.triageSelectedIndex]?.issue.identifier;
109
+ let newTriageIndex = 0;
110
+ if (prevTriageId) {
111
+ const found = action.flatTriage.findIndex((d) => d.issue.identifier === prevTriageId);
112
+ if (found >= 0)
113
+ newTriageIndex = found;
114
+ }
115
+ // Prune delete-progress entries whose worktree is gone (a successful
116
+ // removal). In-progress ("removing") and failed ("error") deletions
117
+ // keep their row, so their entries survive and stay visible.
118
+ const presentTreeIds = new Set(action.flatTrees.map((d) => d.issue.identifier));
119
+ const deletingTickets = Object.fromEntries(Object.entries(state.deletingTickets).filter(([id]) => presentTreeIds.has(id)));
94
120
  return {
95
121
  ...state,
96
122
  groups: action.groups,
97
123
  flatIssues: action.flatIssues,
98
124
  treeGroups: action.treeGroups,
99
125
  flatTrees: action.flatTrees,
126
+ triageGroups: action.triageGroups,
127
+ flatTriage: action.flatTriage,
128
+ deletingTickets,
100
129
  selectedIndex: newIndex,
101
130
  treeSelectedIndex: newTreeIndex,
131
+ triageSelectedIndex: newTriageIndex,
102
132
  loading: false,
103
133
  refreshing: false,
104
134
  error: null,
105
135
  detailScrollOffset: 0,
106
136
  treeDetailScrollOffset: 0,
137
+ triageDetailScrollOffset: 0,
107
138
  };
108
139
  }
109
140
  case "TREE_SELECT":
@@ -112,6 +143,62 @@ export function reducer(state, action) {
112
143
  return { ...state, treeListScrollOffset: action.offset };
113
144
  case "TREE_SCROLL_DETAIL":
114
145
  return { ...state, treeDetailScrollOffset: action.offset };
146
+ case "TRIAGE_SELECT":
147
+ return { ...state, triageSelectedIndex: action.index, triageDetailScrollOffset: 0 };
148
+ case "TRIAGE_SCROLL_LIST":
149
+ return { ...state, triageListScrollOffset: action.offset };
150
+ case "TRIAGE_SCROLL_DETAIL":
151
+ return { ...state, triageDetailScrollOffset: action.offset };
152
+ case "TRIAGE_COMMENTS_LOADED":
153
+ return {
154
+ ...state,
155
+ triageCommentsById: { ...state.triageCommentsById, [action.id]: action.comments },
156
+ };
157
+ case "SET_TRIAGE_SCHEDULES":
158
+ return { ...state, triageSchedules: action.schedules };
159
+ case "TRIAGE_SCHEDULE_OPEN":
160
+ return { ...state, overlay: "triage-schedule", triageScheduleScrollOffset: 0 };
161
+ case "TRIAGE_SCHEDULE_SCROLL":
162
+ return { ...state, triageScheduleScrollOffset: action.offset };
163
+ case "TRIAGE_SCHEDULE_CLOSE":
164
+ return { ...state, overlay: null };
165
+ case "TRIAGE_ASK_OPEN":
166
+ return {
167
+ ...state,
168
+ overlay: "triage-ask",
169
+ triageAskTicketId: action.ticketId,
170
+ triageAskPhase: "input",
171
+ triageAskQuestion: "",
172
+ triageAskAnswer: null,
173
+ triageAskError: null,
174
+ triageAskScrollOffset: 0,
175
+ };
176
+ case "TRIAGE_ASK_CHANGE":
177
+ return { ...state, triageAskQuestion: action.value };
178
+ case "TRIAGE_ASK_RUN":
179
+ return { ...state, triageAskPhase: "running", triageAskError: null };
180
+ case "TRIAGE_ASK_ANSWER":
181
+ return {
182
+ ...state,
183
+ triageAskPhase: "answer",
184
+ triageAskAnswer: action.answer,
185
+ triageAskScrollOffset: 0,
186
+ };
187
+ case "TRIAGE_ASK_ERROR":
188
+ return { ...state, triageAskPhase: "error", triageAskError: action.error };
189
+ case "TRIAGE_ASK_SCROLL":
190
+ return { ...state, triageAskScrollOffset: action.offset };
191
+ case "TRIAGE_ASK_CLOSE":
192
+ return {
193
+ ...state,
194
+ overlay: null,
195
+ triageAskTicketId: null,
196
+ triageAskPhase: "input",
197
+ triageAskQuestion: "",
198
+ triageAskAnswer: null,
199
+ triageAskError: null,
200
+ triageAskScrollOffset: 0,
201
+ };
115
202
  case "TRACKER_SELECT_OPEN":
116
203
  return {
117
204
  ...state,
@@ -216,9 +303,47 @@ export function reducer(state, action) {
216
303
  baseSelectChosen: null,
217
304
  };
218
305
  case "DELETE_START":
219
- return { ...state, deletingForTicket: action.ticketId };
220
- case "DELETE_DONE":
221
- return { ...state, deletingForTicket: null };
306
+ return {
307
+ ...state,
308
+ deletingTickets: {
309
+ ...state.deletingTickets,
310
+ [action.ticketId]: { logs: "", phase: "removing", error: null },
311
+ },
312
+ };
313
+ case "DELETE_LOG": {
314
+ const prev = state.deletingTickets[action.ticketId];
315
+ if (!prev)
316
+ return state;
317
+ return {
318
+ ...state,
319
+ deletingTickets: {
320
+ ...state.deletingTickets,
321
+ [action.ticketId]: { ...prev, logs: prev.logs + action.logs },
322
+ },
323
+ };
324
+ }
325
+ case "DELETE_DONE": {
326
+ const prev = state.deletingTickets[action.ticketId];
327
+ if (!prev)
328
+ return state;
329
+ return {
330
+ ...state,
331
+ deletingTickets: {
332
+ ...state.deletingTickets,
333
+ [action.ticketId]: { ...prev, phase: "done" },
334
+ },
335
+ };
336
+ }
337
+ case "DELETE_ERROR": {
338
+ const prev = state.deletingTickets[action.ticketId] ?? { logs: "" };
339
+ return {
340
+ ...state,
341
+ deletingTickets: {
342
+ ...state.deletingTickets,
343
+ [action.ticketId]: { logs: prev.logs, phase: "error", error: action.error },
344
+ },
345
+ };
346
+ }
222
347
  case "COMMIT_START":
223
348
  return {
224
349
  ...state,
package/dist/lib/git.d.ts CHANGED
@@ -74,7 +74,7 @@ export declare function createWorktree(branchName: string, baseBranch: string, r
74
74
  * Runs: `git worktree remove [--force] <path>` then `git branch -d|-D <branchName>`
75
75
  * Returns { success: false, error } if worktree not found or git fails.
76
76
  */
77
- export declare function removeWorktree(branchName: string, repoRoot: string, force?: boolean): Promise<{
77
+ export declare function removeWorktree(branchName: string, repoRoot: string, force?: boolean, onProgress?: (message: string) => void): Promise<{
78
78
  success: boolean;
79
79
  error?: string;
80
80
  }>;
package/dist/lib/git.js CHANGED
@@ -191,13 +191,15 @@ export async function createWorktree(branchName, baseBranch, repoRoot) {
191
191
  * Runs: `git worktree remove [--force] <path>` then `git branch -d|-D <branchName>`
192
192
  * Returns { success: false, error } if worktree not found or git fails.
193
193
  */
194
- export async function removeWorktree(branchName, repoRoot, force = false) {
194
+ export async function removeWorktree(branchName, repoRoot, force = false, onProgress) {
195
+ const report = onProgress ?? (() => { });
195
196
  // Find the worktree by branch name using git's worktree tracking
196
197
  const worktreePath = getWorktreePath(branchName);
197
198
  if (!worktreePath) {
198
199
  return { success: false, error: `Worktree not found: ${branchName}` };
199
200
  }
200
201
  try {
202
+ report("Removing worktree…");
201
203
  const forceFlag = force ? "--force" : "";
202
204
  await execAsync(`git worktree remove ${forceFlag} "${worktreePath}"`, {
203
205
  cwd: repoRoot,
@@ -205,6 +207,7 @@ export async function removeWorktree(branchName, repoRoot, force = false) {
205
207
  // Clean up any remaining files (untracked files, node_modules, etc.)
206
208
  // git worktree remove doesn't delete untracked files
207
209
  if (fs.existsSync(worktreePath)) {
210
+ report("Cleaning up leftover files…");
208
211
  // Fix permissions first (node_modules often has restricted perms)
209
212
  try {
210
213
  execSync(`chmod -R u+w "${worktreePath}"`, { stdio: "ignore" });
@@ -225,6 +228,7 @@ export async function removeWorktree(branchName, repoRoot, force = false) {
225
228
  clearSessionState(repoRoot, ticketId);
226
229
  }
227
230
  // Also delete the branch
231
+ report("Deleting branch…");
228
232
  const deleteFlag = force ? "-D" : "-d";
229
233
  try {
230
234
  await execAsync(`git branch ${deleteFlag} "${branchName}"`, {
@@ -233,7 +237,9 @@ export async function removeWorktree(branchName, repoRoot, force = false) {
233
237
  }
234
238
  catch {
235
239
  // Branch deletion failed, but worktree was removed
240
+ report("Worktree removed (branch delete skipped)");
236
241
  }
242
+ report("Done");
237
243
  return { success: true };
238
244
  }
239
245
  catch (e) {
@@ -1,4 +1,5 @@
1
- import type { AssignedIssue, Issue } from "../types.js";
1
+ import type { AssignedIssue, Issue, TriageSchedule } from "../types.js";
2
2
  export declare const PRIORITY_MAP: Record<number, string>;
3
3
  export declare function fetchIssue(ticketId: string, accessToken: string): Promise<Issue | null>;
4
+ export declare function fetchTriageSchedules(accessToken: string): Promise<TriageSchedule[]>;
4
5
  export declare function fetchAssignedIssues(accessToken: string): Promise<AssignedIssue[] | null>;
@@ -6,6 +6,13 @@ export const PRIORITY_MAP = {
6
6
  3: "Medium",
7
7
  4: "Low",
8
8
  };
9
+ // Workflow states whose issues should never appear in the assigned-work list,
10
+ // matched by name (case-insensitive) regardless of their configured `type`.
11
+ // Linear ships a default "Duplicate" state, but workspaces sometimes type it
12
+ // as non-terminal (backlog/unstarted) rather than `canceled`, so it slips past
13
+ // the query's `type: { nin: ["completed", "canceled"] }` filter and clutters
14
+ // the backlog. These are resolution states — hide them everywhere.
15
+ const HIDDEN_STATE_NAMES = new Set(["duplicate"]);
9
16
  async function graphqlQuery(query, variables, accessToken) {
10
17
  const res = await fetch(LINEAR_GRAPHQL_URL, {
11
18
  method: "POST",
@@ -30,6 +37,8 @@ query GetIssue($id: String!) {
30
37
  title
31
38
  description
32
39
  url
40
+ slaBreachesAt
41
+ snoozedUntilAt
33
42
  state { name type }
34
43
  priority
35
44
  labels { nodes { name } }
@@ -65,21 +74,33 @@ query AssignedIssues {
65
74
  title
66
75
  description
67
76
  url
77
+ slaBreachesAt
78
+ snoozedUntilAt
68
79
  priority
69
80
  state { name type }
70
81
  labels { nodes { name } }
71
82
  project { id name }
83
+ relations(first: 10) { nodes { type relatedIssue { identifier state { type } } } }
84
+ inverseRelations(first: 10) { nodes { type issue { identifier state { type } } } }
72
85
  }
73
86
  }
74
87
  }
75
88
  }
76
89
  `;
90
+ const TERMINAL_STATE_TYPES = new Set(["completed", "canceled"]);
91
+ function refOf(issue) {
92
+ if (!issue?.identifier)
93
+ return null;
94
+ return { identifier: issue.identifier, done: TERMINAL_STATE_TYPES.has(issue.state?.type ?? "") };
95
+ }
77
96
  function mapAssigned(issue) {
78
97
  return {
79
98
  identifier: issue.identifier,
80
99
  title: issue.title,
81
100
  description: issue.description ?? null,
82
101
  url: issue.url,
102
+ slaBreachesAt: issue.slaBreachesAt ?? null,
103
+ snoozedUntilAt: issue.snoozedUntilAt ?? null,
83
104
  priority: issue.priority,
84
105
  priorityLabel: PRIORITY_MAP[issue.priority] ?? "No priority",
85
106
  state: {
@@ -89,6 +110,23 @@ function mapAssigned(issue) {
89
110
  labels: (issue.labels?.nodes ?? []).map((l) => l.name),
90
111
  projectId: issue.project?.id ?? null,
91
112
  projectName: issue.project?.name ?? null,
113
+ // Dependency relations. `inverseRelations` of type "blocks" point at this
114
+ // issue → those are its blockers; `relations` of type "blocks" point away
115
+ // → those are what it blocks. Left undefined when the query didn't fetch
116
+ // relations (e.g. the single-issue ISSUE_QUERY) so callers can tell
117
+ // "no blockers" from "unknown".
118
+ blockedBy: issue.inverseRelations
119
+ ? (issue.inverseRelations.nodes ?? [])
120
+ .filter((r) => r.type === "blocks")
121
+ .map((r) => refOf(r.issue))
122
+ .filter((r) => r !== null)
123
+ : undefined,
124
+ blocking: issue.relations
125
+ ? (issue.relations.nodes ?? [])
126
+ .filter((r) => r.type === "blocks")
127
+ .map((r) => refOf(r.relatedIssue))
128
+ .filter((r) => r !== null)
129
+ : undefined,
92
130
  };
93
131
  }
94
132
  function mapComments(nodes) {
@@ -119,10 +157,111 @@ export async function fetchIssue(ticketId, accessToken) {
119
157
  comments: mapComments(data.issue.comments?.nodes ?? []),
120
158
  };
121
159
  }
160
+ // ── Triage on-call schedules ──────────────────────────────────────────
161
+ // Linear's "Triage responsibility" can be backed by a time schedule (a weekly
162
+ // on-call rotation). We surface the schedules for the teams the viewer belongs
163
+ // to. Schedule entries reference users by id only, so a follow-up `users`
164
+ // lookup resolves display names.
165
+ const TRIAGE_SCHEDULES_QUERY = `
166
+ query TriageSchedules {
167
+ viewer {
168
+ id
169
+ teamMemberships(first: 100) {
170
+ nodes {
171
+ team {
172
+ key
173
+ name
174
+ triageResponsibility {
175
+ currentUser { id }
176
+ timeSchedule {
177
+ name
178
+ entries { startsAt endsAt userId userEmail }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+ }
186
+ `;
187
+ /** Resolve user ids → display names via a single `users` query. */
188
+ async function resolveUserNames(ids, accessToken) {
189
+ if (ids.length === 0)
190
+ return {};
191
+ const query = `query ResolveUsers($ids: [ID!]!) {
192
+ users(filter: { id: { in: $ids } }, first: 250) { nodes { id displayName } }
193
+ }`;
194
+ const data = (await graphqlQuery(query, { ids }, accessToken));
195
+ const map = {};
196
+ for (const u of data?.users?.nodes ?? [])
197
+ map[u.id] = u.displayName;
198
+ return map;
199
+ }
200
+ export async function fetchTriageSchedules(accessToken) {
201
+ const data = (await graphqlQuery(TRIAGE_SCHEDULES_QUERY, {}, accessToken));
202
+ const viewerId = data?.viewer?.id ?? null;
203
+ const memberships = data?.viewer?.teamMemberships?.nodes ?? [];
204
+ // Keep only teams whose triage responsibility is backed by a time schedule
205
+ // with at least one entry.
206
+ const teams = memberships
207
+ .map((m) => m.team)
208
+ .filter((t) => !!t && (t.triageResponsibility?.timeSchedule?.entries?.length ?? 0) > 0);
209
+ if (teams.length === 0)
210
+ return [];
211
+ // Resolve every referenced user id in one batch.
212
+ const ids = new Set();
213
+ for (const t of teams) {
214
+ const tr = t.triageResponsibility;
215
+ if (tr.currentUser?.id)
216
+ ids.add(tr.currentUser.id);
217
+ for (const e of tr.timeSchedule?.entries ?? [])
218
+ if (e.userId)
219
+ ids.add(e.userId);
220
+ }
221
+ const names = await resolveUserNames([...ids], accessToken);
222
+ const now = Date.now();
223
+ const schedules = teams.map((t) => {
224
+ const tr = t.triageResponsibility;
225
+ const shifts = (tr.timeSchedule?.entries ?? [])
226
+ .map((e) => {
227
+ const start = Date.parse(e.startsAt);
228
+ const end = Date.parse(e.endsAt);
229
+ return {
230
+ startsAt: e.startsAt,
231
+ endsAt: e.endsAt,
232
+ name: (e.userId && names[e.userId]) || e.userEmail || "Unknown",
233
+ isCurrent: now >= start && now < end,
234
+ isMe: !!viewerId && e.userId === viewerId,
235
+ };
236
+ })
237
+ .sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt));
238
+ const currentShift = shifts.find((s) => s.isCurrent) ?? null;
239
+ const currentUserId = tr.currentUser?.id ?? null;
240
+ const currentName = currentShift?.name ?? (currentUserId ? (names[currentUserId] ?? null) : null);
241
+ const currentIsMe = currentShift?.isMe ?? (!!viewerId && currentUserId === viewerId);
242
+ return {
243
+ teamKey: t.key,
244
+ teamName: t.name,
245
+ scheduleName: tr.timeSchedule?.name ?? `${t.key} triage`,
246
+ currentName,
247
+ currentIsMe,
248
+ shifts,
249
+ };
250
+ });
251
+ // Surface schedules the viewer actually participates in first.
252
+ schedules.sort((a, b) => {
253
+ const am = a.shifts.some((s) => s.isMe) ? 0 : 1;
254
+ const bm = b.shifts.some((s) => s.isMe) ? 0 : 1;
255
+ return am - bm;
256
+ });
257
+ return schedules;
258
+ }
122
259
  export async function fetchAssignedIssues(accessToken) {
123
260
  const data = (await graphqlQuery(ASSIGNED_ISSUES_QUERY, {}, accessToken));
124
261
  const nodes = data?.viewer?.assignedIssues?.nodes;
125
262
  if (!nodes)
126
263
  return null;
127
- return nodes.map(mapAssigned);
264
+ return nodes
265
+ .map(mapAssigned)
266
+ .filter((i) => !HIDDEN_STATE_NAMES.has(i.state.name.trim().toLowerCase()));
128
267
  }