santree 0.7.0 → 0.7.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.
@@ -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,7 @@ query GetIssue($id: String!) {
30
37
  title
31
38
  description
32
39
  url
40
+ dueDate
33
41
  state { name type }
34
42
  priority
35
43
  labels { nodes { name } }
@@ -65,21 +73,31 @@ query AssignedIssues {
65
73
  title
66
74
  description
67
75
  url
76
+ dueDate
68
77
  priority
69
78
  state { name type }
70
79
  labels { nodes { name } }
71
80
  project { id name }
81
+ relations(first: 10) { nodes { type relatedIssue { identifier state { type } } } }
82
+ inverseRelations(first: 10) { nodes { type issue { identifier state { type } } } }
72
83
  }
73
84
  }
74
85
  }
75
86
  }
76
87
  `;
88
+ const TERMINAL_STATE_TYPES = new Set(["completed", "canceled"]);
89
+ function refOf(issue) {
90
+ if (!issue?.identifier)
91
+ return null;
92
+ return { identifier: issue.identifier, done: TERMINAL_STATE_TYPES.has(issue.state?.type ?? "") };
93
+ }
77
94
  function mapAssigned(issue) {
78
95
  return {
79
96
  identifier: issue.identifier,
80
97
  title: issue.title,
81
98
  description: issue.description ?? null,
82
99
  url: issue.url,
100
+ dueDate: issue.dueDate ?? null,
83
101
  priority: issue.priority,
84
102
  priorityLabel: PRIORITY_MAP[issue.priority] ?? "No priority",
85
103
  state: {
@@ -89,6 +107,23 @@ function mapAssigned(issue) {
89
107
  labels: (issue.labels?.nodes ?? []).map((l) => l.name),
90
108
  projectId: issue.project?.id ?? null,
91
109
  projectName: issue.project?.name ?? null,
110
+ // Dependency relations. `inverseRelations` of type "blocks" point at this
111
+ // issue → those are its blockers; `relations` of type "blocks" point away
112
+ // → those are what it blocks. Left undefined when the query didn't fetch
113
+ // relations (e.g. the single-issue ISSUE_QUERY) so callers can tell
114
+ // "no blockers" from "unknown".
115
+ blockedBy: issue.inverseRelations
116
+ ? (issue.inverseRelations.nodes ?? [])
117
+ .filter((r) => r.type === "blocks")
118
+ .map((r) => refOf(r.issue))
119
+ .filter((r) => r !== null)
120
+ : undefined,
121
+ blocking: issue.relations
122
+ ? (issue.relations.nodes ?? [])
123
+ .filter((r) => r.type === "blocks")
124
+ .map((r) => refOf(r.relatedIssue))
125
+ .filter((r) => r !== null)
126
+ : undefined,
92
127
  };
93
128
  }
94
129
  function mapComments(nodes) {
@@ -119,10 +154,111 @@ export async function fetchIssue(ticketId, accessToken) {
119
154
  comments: mapComments(data.issue.comments?.nodes ?? []),
120
155
  };
121
156
  }
157
+ // ── Triage on-call schedules ──────────────────────────────────────────
158
+ // Linear's "Triage responsibility" can be backed by a time schedule (a weekly
159
+ // on-call rotation). We surface the schedules for the teams the viewer belongs
160
+ // to. Schedule entries reference users by id only, so a follow-up `users`
161
+ // lookup resolves display names.
162
+ const TRIAGE_SCHEDULES_QUERY = `
163
+ query TriageSchedules {
164
+ viewer {
165
+ id
166
+ teamMemberships(first: 100) {
167
+ nodes {
168
+ team {
169
+ key
170
+ name
171
+ triageResponsibility {
172
+ currentUser { id }
173
+ timeSchedule {
174
+ name
175
+ entries { startsAt endsAt userId userEmail }
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ `;
184
+ /** Resolve user ids → display names via a single `users` query. */
185
+ async function resolveUserNames(ids, accessToken) {
186
+ if (ids.length === 0)
187
+ return {};
188
+ const query = `query ResolveUsers($ids: [ID!]!) {
189
+ users(filter: { id: { in: $ids } }, first: 250) { nodes { id displayName } }
190
+ }`;
191
+ const data = (await graphqlQuery(query, { ids }, accessToken));
192
+ const map = {};
193
+ for (const u of data?.users?.nodes ?? [])
194
+ map[u.id] = u.displayName;
195
+ return map;
196
+ }
197
+ export async function fetchTriageSchedules(accessToken) {
198
+ const data = (await graphqlQuery(TRIAGE_SCHEDULES_QUERY, {}, accessToken));
199
+ const viewerId = data?.viewer?.id ?? null;
200
+ const memberships = data?.viewer?.teamMemberships?.nodes ?? [];
201
+ // Keep only teams whose triage responsibility is backed by a time schedule
202
+ // with at least one entry.
203
+ const teams = memberships
204
+ .map((m) => m.team)
205
+ .filter((t) => !!t && (t.triageResponsibility?.timeSchedule?.entries?.length ?? 0) > 0);
206
+ if (teams.length === 0)
207
+ return [];
208
+ // Resolve every referenced user id in one batch.
209
+ const ids = new Set();
210
+ for (const t of teams) {
211
+ const tr = t.triageResponsibility;
212
+ if (tr.currentUser?.id)
213
+ ids.add(tr.currentUser.id);
214
+ for (const e of tr.timeSchedule?.entries ?? [])
215
+ if (e.userId)
216
+ ids.add(e.userId);
217
+ }
218
+ const names = await resolveUserNames([...ids], accessToken);
219
+ const now = Date.now();
220
+ const schedules = teams.map((t) => {
221
+ const tr = t.triageResponsibility;
222
+ const shifts = (tr.timeSchedule?.entries ?? [])
223
+ .map((e) => {
224
+ const start = Date.parse(e.startsAt);
225
+ const end = Date.parse(e.endsAt);
226
+ return {
227
+ startsAt: e.startsAt,
228
+ endsAt: e.endsAt,
229
+ name: (e.userId && names[e.userId]) || e.userEmail || "Unknown",
230
+ isCurrent: now >= start && now < end,
231
+ isMe: !!viewerId && e.userId === viewerId,
232
+ };
233
+ })
234
+ .sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt));
235
+ const currentShift = shifts.find((s) => s.isCurrent) ?? null;
236
+ const currentUserId = tr.currentUser?.id ?? null;
237
+ const currentName = currentShift?.name ?? (currentUserId ? (names[currentUserId] ?? null) : null);
238
+ const currentIsMe = currentShift?.isMe ?? (!!viewerId && currentUserId === viewerId);
239
+ return {
240
+ teamKey: t.key,
241
+ teamName: t.name,
242
+ scheduleName: tr.timeSchedule?.name ?? `${t.key} triage`,
243
+ currentName,
244
+ currentIsMe,
245
+ shifts,
246
+ };
247
+ });
248
+ // Surface schedules the viewer actually participates in first.
249
+ schedules.sort((a, b) => {
250
+ const am = a.shifts.some((s) => s.isMe) ? 0 : 1;
251
+ const bm = b.shifts.some((s) => s.isMe) ? 0 : 1;
252
+ return am - bm;
253
+ });
254
+ return schedules;
255
+ }
122
256
  export async function fetchAssignedIssues(accessToken) {
123
257
  const data = (await graphqlQuery(ASSIGNED_ISSUES_QUERY, {}, accessToken));
124
258
  const nodes = data?.viewer?.assignedIssues?.nodes;
125
259
  if (!nodes)
126
260
  return null;
127
- return nodes.map(mapAssigned);
261
+ return nodes
262
+ .map(mapAssigned)
263
+ .filter((i) => !HIDDEN_STATE_NAMES.has(i.state.name.trim().toLowerCase()));
128
264
  }
@@ -1,6 +1,6 @@
1
1
  import { readLinearAuthStore } from "../auth-store.js";
2
2
  import { getRepoLinearOrg, getValidTokens, removeRepoLinearOrg, revokeTokens } from "./auth.js";
3
- import { fetchAssignedIssues, fetchIssue } from "./api.js";
3
+ import { fetchAssignedIssues, fetchIssue, fetchTriageSchedules } from "./api.js";
4
4
  import { cleanupLinearImages, rewriteLinearImages } from "./images.js";
5
5
  export { getRepoLinearOrg, setRepoLinearOrg, removeRepoLinearOrg, getValidTokens, revokeTokens, startOAuthFlow, } from "./auth.js";
6
6
  async function getAuthStatus(repoRoot) {
@@ -59,6 +59,20 @@ async function listAssigned(repoRoot) {
59
59
  }
60
60
  return { ok: true, value: issues };
61
61
  }
62
+ async function getTriageSchedules(repoRoot) {
63
+ const orgSlug = getRepoLinearOrg(repoRoot);
64
+ if (!orgSlug)
65
+ return [];
66
+ const tokens = await getValidTokens(orgSlug);
67
+ if (!tokens)
68
+ return [];
69
+ try {
70
+ return await fetchTriageSchedules(tokens.access_token);
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ }
62
76
  async function getIssue(identifier, repoRoot) {
63
77
  const orgSlug = getRepoLinearOrg(repoRoot);
64
78
  if (!orgSlug) {
@@ -91,10 +105,12 @@ export const linearTracker = {
91
105
  kind: "linear",
92
106
  displayName: "Linear",
93
107
  issueNoun: "ticket",
108
+ supportsTriage: true,
94
109
  getAuthStatus,
95
110
  signOut,
96
111
  extractIdFromBranch,
97
112
  cleanupCache: cleanupLinearImages,
98
113
  listAssigned,
99
114
  getIssue,
115
+ getTriageSchedules,
100
116
  };