santree 0.6.3 → 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 +463 -78
- 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 +150 -3
- package/dist/lib/dashboard/IssueList.d.ts +11 -1
- package/dist/lib/dashboard/IssueList.js +44 -9
- package/dist/lib/dashboard/Overlays.d.ts +2 -1
- package/dist/lib/dashboard/Overlays.js +23 -8
- 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 +93 -5
- package/dist/lib/dashboard/types.js +133 -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
|
@@ -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
|
};
|
|
@@ -9,6 +9,18 @@ export interface State {
|
|
|
9
9
|
name: string;
|
|
10
10
|
type: string;
|
|
11
11
|
}
|
|
12
|
+
/** Readiness of an issue given its blocking dependencies:
|
|
13
|
+
* "ready" — no unresolved blockers (or none at all)
|
|
14
|
+
* "blocked" — at least one blocker isn't done yet
|
|
15
|
+
* "unknown" — the tracker doesn't expose dependency data */
|
|
16
|
+
export type Readiness = "ready" | "blocked" | "unknown";
|
|
17
|
+
export declare function issueReadiness(blockedBy: IssueRef[] | undefined): Readiness;
|
|
18
|
+
/** A lightweight reference to a related issue, with whether it's resolved
|
|
19
|
+
* (state.type is completed/canceled). Used for dependency (blocks) relations. */
|
|
20
|
+
export interface IssueRef {
|
|
21
|
+
identifier: string;
|
|
22
|
+
done: boolean;
|
|
23
|
+
}
|
|
12
24
|
export interface AssignedIssue {
|
|
13
25
|
identifier: string;
|
|
14
26
|
title: string;
|
|
@@ -20,10 +32,45 @@ export interface AssignedIssue {
|
|
|
20
32
|
labels: string[];
|
|
21
33
|
projectId: string | null;
|
|
22
34
|
projectName: string | null;
|
|
35
|
+
/** Issues that block this one ("blocked by"). An issue is ready to start when
|
|
36
|
+
* every blocker is `done`. `undefined` when the tracker doesn't expose
|
|
37
|
+
* dependency relations (only Linear does); `[]` means no blockers. */
|
|
38
|
+
blockedBy?: IssueRef[];
|
|
39
|
+
/** Issues this one blocks (downstream dependents). */
|
|
40
|
+
blocking?: IssueRef[];
|
|
41
|
+
/** Due date as an ISO `YYYY-MM-DD` string, or null when none is set.
|
|
42
|
+
* Only trackers with a native due-date concept populate it (Linear today);
|
|
43
|
+
* others leave it undefined. Surfaced on the Triage tab as a colored,
|
|
44
|
+
* urgency-coded badge. */
|
|
45
|
+
dueDate?: string | null;
|
|
23
46
|
}
|
|
24
47
|
export interface Issue extends AssignedIssue {
|
|
25
48
|
comments: Comment[];
|
|
26
49
|
}
|
|
50
|
+
/** One slot in a triage on-call rotation. */
|
|
51
|
+
export interface TriageShift {
|
|
52
|
+
startsAt: string;
|
|
53
|
+
endsAt: string;
|
|
54
|
+
/** Resolved display name (falls back to email, then "Unknown"). */
|
|
55
|
+
name: string;
|
|
56
|
+
/** True when this shift covers the current moment. */
|
|
57
|
+
isCurrent: boolean;
|
|
58
|
+
/** True when this shift belongs to the authenticated viewer. */
|
|
59
|
+
isMe: boolean;
|
|
60
|
+
}
|
|
61
|
+
/** A team's triage responsibility rotation (Linear "Triage responsibility"
|
|
62
|
+
* backed by a time schedule). */
|
|
63
|
+
export interface TriageSchedule {
|
|
64
|
+
teamKey: string;
|
|
65
|
+
teamName: string;
|
|
66
|
+
scheduleName: string;
|
|
67
|
+
/** Display name of whoever is on call right now, if known. */
|
|
68
|
+
currentName: string | null;
|
|
69
|
+
/** Whether the viewer is the one currently on call. */
|
|
70
|
+
currentIsMe: boolean;
|
|
71
|
+
/** Chronological list of shifts. */
|
|
72
|
+
shifts: TriageShift[];
|
|
73
|
+
}
|
|
27
74
|
export interface AuthStatus {
|
|
28
75
|
authenticated: boolean;
|
|
29
76
|
accountLabel?: string;
|
|
@@ -65,6 +112,17 @@ export interface IssueTracker {
|
|
|
65
112
|
cleanupCache(identifier: string): void;
|
|
66
113
|
listAssigned(repoRoot: string): Promise<IssueTrackerResult<AssignedIssue[]>>;
|
|
67
114
|
getIssue(identifier: string, repoRoot: string): Promise<IssueTrackerResult<Issue>>;
|
|
115
|
+
/** When true, this tracker has a native triage concept (incoming issues in
|
|
116
|
+
* a `state.type === "triage"` inbox). The dashboard surfaces a dedicated
|
|
117
|
+
* Triage tab only when the active tracker sets this — feature detection,
|
|
118
|
+
* never a `kind === "linear"` string check, per the
|
|
119
|
+
* no-tracker-conditionals-outside-the-factory policy. Linear sets it;
|
|
120
|
+
* GitHub/Local leave it undefined. */
|
|
121
|
+
readonly supportsTriage?: boolean;
|
|
122
|
+
/** Triage on-call rotations for the viewer's teams. Optional — implemented
|
|
123
|
+
* only by trackers with a triage scheduling concept (Linear). Returns an
|
|
124
|
+
* empty array on failure or when no schedules exist; never throws. */
|
|
125
|
+
getTriageSchedules?(repoRoot: string): Promise<TriageSchedule[]>;
|
|
68
126
|
/** When true, the tracker implements createIssue/updateIssue/deleteIssue.
|
|
69
127
|
* Read-only trackers (Linear, GitHub) leave this undefined; UI surfaces
|
|
70
128
|
* gate every mutation path on `tracker.canMutate === true` (feature
|
package/package.json
CHANGED
package/prompts/ask.njk
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
You are helping triage an incoming issue. Below is the full issue with all of its comments. Answer the question that follows concisely and directly, grounded in what the issue and its discussion actually say.
|
|
2
|
+
|
|
3
|
+
When the question is about whether or how the issue can be fixed, you may inspect the codebase read-only (Read/Grep/Glob) to ground your answer in the real code — name the relevant files. Do not make any changes, and do not ask the user to run commands.
|
|
4
|
+
|
|
5
|
+
Keep the answer short and skimmable: a few sentences or a short bullet list. If the issue lacks the information needed to answer, say what's missing.
|
|
6
|
+
|
|
7
|
+
{{ ticket_content }}
|
|
8
|
+
|
|
9
|
+
## Question
|
|
10
|
+
{{ user_question }}
|