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.
- package/dist/commands/dashboard.js +418 -70
- package/dist/lib/ai.d.ts +13 -0
- package/dist/lib/ai.js +16 -0
- package/dist/lib/dashboard/DetailPanel.d.ts +18 -2
- package/dist/lib/dashboard/DetailPanel.js +144 -1
- package/dist/lib/dashboard/IssueList.d.ts +9 -1
- package/dist/lib/dashboard/IssueList.js +35 -8
- package/dist/lib/dashboard/Overlays.js +20 -5
- package/dist/lib/dashboard/TriageScheduleOverlay.d.ts +16 -0
- package/dist/lib/dashboard/TriageScheduleOverlay.js +78 -0
- package/dist/lib/dashboard/data.d.ts +2 -0
- package/dist/lib/dashboard/data.js +26 -4
- package/dist/lib/dashboard/due.d.ts +24 -0
- package/dist/lib/dashboard/due.js +32 -0
- package/dist/lib/dashboard/types.d.ts +90 -5
- package/dist/lib/dashboard/types.js +129 -4
- package/dist/lib/git.d.ts +1 -1
- package/dist/lib/git.js +7 -1
- package/dist/lib/trackers/linear/api.d.ts +2 -1
- package/dist/lib/trackers/linear/api.js +137 -1
- package/dist/lib/trackers/linear/index.js +17 -1
- package/dist/lib/trackers/types.d.ts +58 -0
- package/dist/lib/trackers/types.js +5 -1
- package/package.json +1 -1
- package/prompts/ask.njk +10 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
};
|