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.
@@ -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
  };
@@ -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
@@ -1 +1,5 @@
1
- export {};
1
+ export function issueReadiness(blockedBy) {
2
+ if (blockedBy === undefined)
3
+ return "unknown";
4
+ return blockedBy.some((b) => !b.done) ? "blocked" : "ready";
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.6.3",
3
+ "version": "0.7.2",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
@@ -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 }}