mintree 0.2.4 → 0.3.1

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,55 @@
1
+ /**
2
+ * LinearProvider — implements IssueProvider against Linear's GraphQL API
3
+ * (https://api.linear.app/graphql).
4
+ *
5
+ * One POST per dashboard refresh: a single GraphQL query pulls viewer +
6
+ * teams (with states) + assigned issues in one shot. Transitions add a
7
+ * second call for the `issueUpdate` mutation.
8
+ *
9
+ * Auth resolution order: `LINEAR_API_KEY` env var → `~/.mintree/
10
+ * credentials.json` (`{ linear: { apiKey: "..." } }`). Never reads or
11
+ * writes credentials to the repo's `.mintree/` directory — personal API
12
+ * keys are user-scoped, not repo-scoped.
13
+ *
14
+ * Linear personal API keys (`lin_api_...`) go directly into the
15
+ * Authorization header with no `Bearer` prefix.
16
+ */
17
+ import type { IssueId, IssueProjectInfo, IssueProvider, ProviderIssue, TransitionResult } from "./types.js";
18
+ export declare class LinearProvider implements IssueProvider {
19
+ private readonly repoRoot;
20
+ readonly kind: "linear";
21
+ private snapshotPromise;
22
+ constructor(repoRoot: string);
23
+ private getConfig;
24
+ /**
25
+ * Single source of truth for the dashboard's data. Both listAssignedIssues
26
+ * and fetchProjectAssignments call this so we never double-fetch within a
27
+ * load. Per-instance promise memoisation handles the back-to-back call;
28
+ * the module-level cache handles refreshes within the TTL.
29
+ */
30
+ private loadSnapshot;
31
+ listAssignedIssues(): Promise<ProviderIssue[] | null>;
32
+ fetchProjectAssignments(): Promise<Map<IssueId, IssueProjectInfo> | null>;
33
+ transitionIssueToInProgress(issueId: IssueId): Promise<TransitionResult>;
34
+ }
35
+ /**
36
+ * Doctor-side snapshot of the Linear integration's health. Bundles API-key
37
+ * resolution, `viewer` ping, and per-configured-team existence check into
38
+ * one async call so the doctor row can render everything in one pass.
39
+ */
40
+ export type LinearSetupCheck = {
41
+ configured: boolean;
42
+ hasApiKey: boolean;
43
+ authOk: boolean;
44
+ user?: string;
45
+ workspaceSlug?: string;
46
+ apiUrl?: string;
47
+ teams: Array<{
48
+ key: string;
49
+ name?: string;
50
+ ok: boolean;
51
+ error?: string;
52
+ }>;
53
+ hint?: string;
54
+ };
55
+ export declare function checkLinearSetup(repoRoot: string): Promise<LinearSetupCheck>;
@@ -0,0 +1,629 @@
1
+ /**
2
+ * LinearProvider — implements IssueProvider against Linear's GraphQL API
3
+ * (https://api.linear.app/graphql).
4
+ *
5
+ * One POST per dashboard refresh: a single GraphQL query pulls viewer +
6
+ * teams (with states) + assigned issues in one shot. Transitions add a
7
+ * second call for the `issueUpdate` mutation.
8
+ *
9
+ * Auth resolution order: `LINEAR_API_KEY` env var → `~/.mintree/
10
+ * credentials.json` (`{ linear: { apiKey: "..." } }`). Never reads or
11
+ * writes credentials to the repo's `.mintree/` directory — personal API
12
+ * keys are user-scoped, not repo-scoped.
13
+ *
14
+ * Linear personal API keys (`lin_api_...`) go directly into the
15
+ * Authorization header with no `Bearer` prefix.
16
+ */
17
+ import * as fs from "fs";
18
+ import * as os from "os";
19
+ import * as path from "path";
20
+ import { readMetadata } from "../metadata.js";
21
+ const DEFAULT_API_URL = "https://api.linear.app/graphql";
22
+ // Linear state types we treat as "done" — work in these states is excluded
23
+ // from the assigned list and protected from transitions back to In Progress.
24
+ const DEFAULT_PROTECTED_STATE_TYPES = ["completed", "canceled"];
25
+ const STATUS_ORDER_UNSET = 999;
26
+ // One query covers viewer + teams + issues; a single 20s budget comfortably
27
+ // fits even the slowest cold-start response without making real failures
28
+ // (DNS, network down) drag too long.
29
+ const REQUEST_TIMEOUT_MS = 20_000;
30
+ const MAX_RETRIES = 2;
31
+ const RETRY_BASE_DELAY_MS = 400;
32
+ const RETRY_AFTER_CAP_MS = 5_000;
33
+ const MIN_REQUEST_INTERVAL_MS = 200;
34
+ const SNAPSHOT_CACHE_TTL_MS = 60 * 1000;
35
+ const snapshotCache = new Map();
36
+ function snapshotCacheKey(workspaceSlug, teamKeys) {
37
+ return `${workspaceSlug}\x00${[...teamKeys].sort().join(",")}`;
38
+ }
39
+ function readSnapshotCache(workspaceSlug, teamKeys) {
40
+ const entry = snapshotCache.get(snapshotCacheKey(workspaceSlug, teamKeys));
41
+ if (!entry)
42
+ return null;
43
+ if (Date.now() - entry.fetchedAt > SNAPSHOT_CACHE_TTL_MS) {
44
+ snapshotCache.delete(snapshotCacheKey(workspaceSlug, teamKeys));
45
+ return null;
46
+ }
47
+ return entry.snapshot;
48
+ }
49
+ function writeSnapshotCache(workspaceSlug, teamKeys, snapshot) {
50
+ snapshotCache.set(snapshotCacheKey(workspaceSlug, teamKeys), {
51
+ snapshot,
52
+ fetchedAt: Date.now(),
53
+ });
54
+ }
55
+ function invalidateSnapshotCache(workspaceSlug, teamKeys) {
56
+ snapshotCache.delete(snapshotCacheKey(workspaceSlug, teamKeys));
57
+ }
58
+ // Process-global throttle: serialises Linear requests with a minimum gap
59
+ // between them. Linear's published per-IP rate limit is generous, but
60
+ // repeated dashboard refreshes can still queue up bursts — this keeps the
61
+ // sequence orderly without making the dashboard feel slow.
62
+ let throttleQueue = Promise.resolve();
63
+ let lastRequestAt = 0;
64
+ function throttle() {
65
+ const wait = throttleQueue.then(async () => {
66
+ const elapsed = Date.now() - lastRequestAt;
67
+ if (elapsed < MIN_REQUEST_INTERVAL_MS) {
68
+ await sleep(MIN_REQUEST_INTERVAL_MS - elapsed);
69
+ }
70
+ lastRequestAt = Date.now();
71
+ });
72
+ throttleQueue = wait;
73
+ return wait;
74
+ }
75
+ const DEBUG_LOG_PATH = path.join(os.homedir(), ".mintree", "linear-debug.log");
76
+ /**
77
+ * Set `MINTREE_DEBUG=1` to enable Linear HTTP debug logging to
78
+ * `~/.mintree/linear-debug.log`. Always-on stderr/stdout would corrupt the
79
+ * Ink-rendered dashboard, so the log is file-only and opt-in.
80
+ */
81
+ function debugEnabled() {
82
+ const v = process.env["MINTREE_DEBUG"];
83
+ return v === "1" || v === "true";
84
+ }
85
+ function logDebug(message) {
86
+ if (!debugEnabled())
87
+ return;
88
+ try {
89
+ const dir = path.dirname(DEBUG_LOG_PATH);
90
+ if (!fs.existsSync(dir))
91
+ fs.mkdirSync(dir, { recursive: true });
92
+ fs.appendFileSync(DEBUG_LOG_PATH, `[${new Date().toISOString()}] ${message}\n`);
93
+ }
94
+ catch {
95
+ // Logging never crashes the dashboard.
96
+ }
97
+ }
98
+ function isRetryableStatus(status) {
99
+ // 0 → AbortError / network timeout / DNS / TLS
100
+ // 429 → rate-limited
101
+ // 5xx → server error
102
+ return status === 0 || status === 429 || (status >= 500 && status < 600);
103
+ }
104
+ function sleep(ms) {
105
+ return new Promise((resolve) => setTimeout(resolve, ms));
106
+ }
107
+ function resolveApiKey() {
108
+ const env = process.env["LINEAR_API_KEY"];
109
+ if (env && env.length > 0)
110
+ return env;
111
+ const credsPath = path.join(os.homedir(), ".mintree", "credentials.json");
112
+ try {
113
+ if (!fs.existsSync(credsPath))
114
+ return null;
115
+ const parsed = JSON.parse(fs.readFileSync(credsPath, "utf-8"));
116
+ const k = parsed?.linear?.apiKey;
117
+ return typeof k === "string" && k.length > 0 ? k : null;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ async function doLinearRequest(apiUrl, apiKey, query, variables) {
124
+ await throttle();
125
+ const controller = new AbortController();
126
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
127
+ try {
128
+ const res = await fetch(apiUrl, {
129
+ method: "POST",
130
+ headers: {
131
+ Authorization: apiKey,
132
+ "Content-Type": "application/json",
133
+ Accept: "application/json",
134
+ },
135
+ body: JSON.stringify({ query, variables: variables ?? {} }),
136
+ signal: controller.signal,
137
+ });
138
+ clearTimeout(timer);
139
+ if (!res.ok) {
140
+ const text = await res.text().catch(() => "");
141
+ const failure = interpretHttpError(res.status, text);
142
+ if (res.status === 429) {
143
+ const ra = res.headers.get("retry-after");
144
+ if (ra) {
145
+ const seconds = Number(ra);
146
+ if (Number.isFinite(seconds) && seconds > 0) {
147
+ failure.retryAfterMs = Math.min(seconds * 1000, RETRY_AFTER_CAP_MS);
148
+ }
149
+ }
150
+ }
151
+ return failure;
152
+ }
153
+ const body = (await res.json());
154
+ // GraphQL errors land with HTTP 200; surface them like an HTTP failure
155
+ // so the retry loop and caller logic can treat them uniformly.
156
+ if (body.errors && body.errors.length > 0) {
157
+ const messages = body.errors
158
+ .map((e) => (typeof e.message === "string" ? e.message : "unknown error"))
159
+ .join("; ");
160
+ return { ok: false, status: 200, error: `GraphQL error: ${messages}` };
161
+ }
162
+ if (!body.data) {
163
+ return { ok: false, status: 200, error: "Linear API returned no data" };
164
+ }
165
+ return { ok: true, data: body.data };
166
+ }
167
+ catch (err) {
168
+ clearTimeout(timer);
169
+ if (err instanceof Error && err.name === "AbortError") {
170
+ return { ok: false, status: 0, error: "Linear API request timed out" };
171
+ }
172
+ return {
173
+ ok: false,
174
+ status: 0,
175
+ error: err instanceof Error ? err.message : String(err),
176
+ };
177
+ }
178
+ }
179
+ /**
180
+ * Calls the Linear API with retry on transient errors. Retries up to
181
+ * MAX_RETRIES on 429 / 5xx / network. Permanent errors (auth, GraphQL
182
+ * validation) return immediately. Failures log when MINTREE_DEBUG=1.
183
+ */
184
+ async function linearRequest(apiUrl, apiKey, query, variables) {
185
+ let attempt = 0;
186
+ let lastResult = null;
187
+ while (attempt <= MAX_RETRIES) {
188
+ const result = await doLinearRequest(apiUrl, apiKey, query, variables);
189
+ if (result.ok) {
190
+ if (attempt > 0) {
191
+ logDebug(`recovered Linear query after ${attempt} retry/retries`);
192
+ }
193
+ return result;
194
+ }
195
+ lastResult = result;
196
+ if (!isRetryableStatus(result.status) || attempt === MAX_RETRIES) {
197
+ logDebug(`failed Linear query status=${result.status} error=${result.error}${result.hint ? ` hint=${result.hint}` : ""}`);
198
+ return result;
199
+ }
200
+ const delay = result.retryAfterMs !== undefined
201
+ ? result.retryAfterMs
202
+ : RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
203
+ logDebug(`retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (last status=${result.status}${result.retryAfterMs !== undefined ? ", server-Retry-After" : ""})`);
204
+ await sleep(delay);
205
+ attempt += 1;
206
+ }
207
+ return (lastResult ?? {
208
+ ok: false,
209
+ status: 0,
210
+ error: "linearRequest exhausted retries with no recorded error",
211
+ });
212
+ }
213
+ function interpretHttpError(status, body) {
214
+ if (status === 401 || status === 403) {
215
+ return {
216
+ ok: false,
217
+ status,
218
+ error: "Linear rejected the API key (401/403).",
219
+ hint: "Verify LINEAR_API_KEY or ~/.mintree/credentials.json#linear.apiKey",
220
+ };
221
+ }
222
+ if (status === 404) {
223
+ return {
224
+ ok: false,
225
+ status,
226
+ error: "Linear API endpoint not found (404).",
227
+ hint: "Check linear.apiUrl in .mintree/metadata.json",
228
+ };
229
+ }
230
+ const snippet = body.slice(0, 200).replace(/\s+/g, " ").trim();
231
+ return {
232
+ ok: false,
233
+ status,
234
+ error: snippet || `Linear API responded with HTTP ${status}`,
235
+ };
236
+ }
237
+ const BOOTSTRAP_QUERY = /* GraphQL */ `
238
+ query MintreeBootstrap($teamKeys: [String!]!) {
239
+ viewer {
240
+ id
241
+ name
242
+ email
243
+ }
244
+ teams(filter: { key: { in: $teamKeys } }) {
245
+ nodes {
246
+ id
247
+ key
248
+ name
249
+ states {
250
+ nodes {
251
+ id
252
+ name
253
+ color
254
+ type
255
+ position
256
+ }
257
+ }
258
+ }
259
+ }
260
+ issues(
261
+ first: 100
262
+ filter: {
263
+ assignee: { isMe: { eq: true } }
264
+ state: { type: { nin: ["completed", "canceled"] } }
265
+ team: { key: { in: $teamKeys } }
266
+ }
267
+ ) {
268
+ nodes {
269
+ id
270
+ identifier
271
+ title
272
+ description
273
+ url
274
+ createdAt
275
+ updatedAt
276
+ team {
277
+ id
278
+ key
279
+ name
280
+ }
281
+ project {
282
+ id
283
+ name
284
+ }
285
+ state {
286
+ id
287
+ name
288
+ color
289
+ type
290
+ position
291
+ }
292
+ labels {
293
+ nodes {
294
+ name
295
+ }
296
+ }
297
+ }
298
+ }
299
+ }
300
+ `;
301
+ const TRANSITION_QUERY = /* GraphQL */ `
302
+ mutation MintreeMoveIssue($id: String!, $stateId: String!) {
303
+ issueUpdate(id: $id, input: { stateId: $stateId }) {
304
+ success
305
+ issue {
306
+ id
307
+ state {
308
+ id
309
+ name
310
+ }
311
+ }
312
+ }
313
+ }
314
+ `;
315
+ function mapIssueToProviderIssue(wi) {
316
+ const labels = [];
317
+ if (wi.labels?.nodes) {
318
+ for (const l of wi.labels.nodes) {
319
+ if (l && typeof l.name === "string")
320
+ labels.push({ name: l.name });
321
+ }
322
+ }
323
+ return {
324
+ id: wi.identifier,
325
+ title: wi.title,
326
+ state: wi.state?.name ?? "",
327
+ url: wi.url,
328
+ labels,
329
+ body: wi.description ?? "",
330
+ createdAt: wi.createdAt ?? "",
331
+ updatedAt: wi.updatedAt ?? "",
332
+ };
333
+ }
334
+ export class LinearProvider {
335
+ repoRoot;
336
+ kind = "linear";
337
+ snapshotPromise = null;
338
+ constructor(repoRoot) {
339
+ this.repoRoot = repoRoot;
340
+ }
341
+ getConfig() {
342
+ return readMetadata(this.repoRoot).linear ?? null;
343
+ }
344
+ /**
345
+ * Single source of truth for the dashboard's data. Both listAssignedIssues
346
+ * and fetchProjectAssignments call this so we never double-fetch within a
347
+ * load. Per-instance promise memoisation handles the back-to-back call;
348
+ * the module-level cache handles refreshes within the TTL.
349
+ */
350
+ async loadSnapshot() {
351
+ if (this.snapshotPromise)
352
+ return this.snapshotPromise;
353
+ const cfg = this.getConfig();
354
+ if (!cfg) {
355
+ return { ok: false, status: 0, error: "Linear config missing in .mintree/metadata.json" };
356
+ }
357
+ if (cfg.teams.length === 0) {
358
+ return { ok: false, status: 0, error: "No Linear teams configured" };
359
+ }
360
+ const apiKey = resolveApiKey();
361
+ if (!apiKey) {
362
+ return {
363
+ ok: false,
364
+ status: 0,
365
+ error: "LINEAR_API_KEY not set",
366
+ hint: "export LINEAR_API_KEY=<key> or write ~/.mintree/credentials.json#linear.apiKey",
367
+ };
368
+ }
369
+ const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
370
+ const teamKeys = cfg.teams.map((t) => t.key);
371
+ const cached = readSnapshotCache(cfg.workspaceSlug, teamKeys);
372
+ if (cached)
373
+ return cached;
374
+ this.snapshotPromise = (async () => {
375
+ const r = await linearRequest(apiUrl, apiKey, BOOTSTRAP_QUERY, { teamKeys });
376
+ if (!r.ok)
377
+ return r;
378
+ const snapshot = {
379
+ viewer: r.data.viewer,
380
+ teams: r.data.teams?.nodes ?? [],
381
+ issues: r.data.issues?.nodes ?? [],
382
+ };
383
+ writeSnapshotCache(cfg.workspaceSlug, teamKeys, snapshot);
384
+ return snapshot;
385
+ })();
386
+ return this.snapshotPromise;
387
+ }
388
+ async listAssignedIssues() {
389
+ const cfg = this.getConfig();
390
+ if (!cfg || cfg.teams.length === 0)
391
+ return [];
392
+ const snapshot = await this.loadSnapshot();
393
+ if ("ok" in snapshot && snapshot.ok === false)
394
+ return null;
395
+ const data = snapshot;
396
+ const protectedTypes = new Set(cfg.protectedStateTypes ?? DEFAULT_PROTECTED_STATE_TYPES);
397
+ const out = [];
398
+ for (const wi of data.issues) {
399
+ // Defensive — the bootstrap query already excludes completed/canceled
400
+ // via state.type.nin, but a workspace could have custom state types
401
+ // the user added to the protected list locally.
402
+ const type = wi.state?.type;
403
+ if (type && protectedTypes.has(type))
404
+ continue;
405
+ out.push(mapIssueToProviderIssue(wi));
406
+ }
407
+ return out;
408
+ }
409
+ async fetchProjectAssignments() {
410
+ const cfg = this.getConfig();
411
+ const result = new Map();
412
+ if (!cfg || cfg.teams.length === 0)
413
+ return result;
414
+ const snapshot = await this.loadSnapshot();
415
+ if ("ok" in snapshot && snapshot.ok === false)
416
+ return null;
417
+ const data = snapshot;
418
+ // Build a per-team workflow-state index so we can attach position
419
+ // (statusOrder) and colour to each issue's status row.
420
+ const teamByKey = new Map();
421
+ for (const t of data.teams) {
422
+ if (!t.key)
423
+ continue;
424
+ const states = (t.states?.nodes ?? [])
425
+ .slice()
426
+ .sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
427
+ teamByKey.set(t.key, {
428
+ team: { key: t.key, name: t.name },
429
+ states,
430
+ });
431
+ }
432
+ for (const wi of data.issues) {
433
+ const teamKey = wi.team?.key;
434
+ if (!teamKey)
435
+ continue;
436
+ const teamEntry = teamByKey.get(teamKey);
437
+ const orderedStates = teamEntry?.states ?? [];
438
+ const statusOrder = wi.state?.id ? orderedStates.findIndex((s) => s.id === wi.state?.id) : -1;
439
+ const teamName = teamEntry?.team.name ?? wi.team?.name ?? teamKey;
440
+ // Issues may or may not be assigned to a Linear project. When they
441
+ // are, suffix the group header so issues from the same team but
442
+ // different projects render as separate sections — keeps things
443
+ // scannable when one team contributes to many projects.
444
+ const projectName = wi.project?.name;
445
+ const projectTitle = projectName ? `${teamName} — ${projectName}` : teamName;
446
+ // Keep the URL pointed at the team page rather than the project
447
+ // page — the team view is the consistent landing spot regardless
448
+ // of whether an issue happens to be on a project.
449
+ const projectUrl = `https://linear.app/${cfg.workspaceSlug}/team/${teamKey}`;
450
+ result.set(wi.identifier, {
451
+ projectTitle,
452
+ projectUrl,
453
+ projectNumber: 0,
454
+ status: wi.state?.name ?? null,
455
+ statusColor: wi.state?.color ?? "yellow",
456
+ statusOrder: statusOrder >= 0 ? statusOrder : STATUS_ORDER_UNSET,
457
+ });
458
+ }
459
+ return result;
460
+ }
461
+ async transitionIssueToInProgress(issueId) {
462
+ const cfg = this.getConfig();
463
+ if (!cfg) {
464
+ return {
465
+ kind: "error",
466
+ message: "Linear config missing in .mintree/metadata.json",
467
+ hint: "Run `mintree init --provider linear --workspace <slug> --team <key>` first",
468
+ };
469
+ }
470
+ const apiKey = resolveApiKey();
471
+ if (!apiKey) {
472
+ return {
473
+ kind: "error",
474
+ message: "LINEAR_API_KEY not set",
475
+ hint: "export LINEAR_API_KEY=<key> (or write ~/.mintree/credentials.json)",
476
+ };
477
+ }
478
+ const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
479
+ const dash = issueId.lastIndexOf("-");
480
+ if (dash <= 0)
481
+ return { kind: "skip-no-issue" };
482
+ const teamKey = issueId.slice(0, dash);
483
+ const team = cfg.teams.find((t) => t.key === teamKey);
484
+ if (!team)
485
+ return { kind: "skip-no-project" };
486
+ const snapshot = await this.loadSnapshot();
487
+ if ("ok" in snapshot && snapshot.ok === false) {
488
+ return {
489
+ kind: "error",
490
+ message: snapshot.error,
491
+ ...(snapshot.hint ? { hint: snapshot.hint } : {}),
492
+ };
493
+ }
494
+ const data = snapshot;
495
+ const teamNode = data.teams.find((t) => t.key === teamKey);
496
+ const states = teamNode?.states?.nodes ?? [];
497
+ if (states.length === 0) {
498
+ return { kind: "error", message: `Could not fetch states for team ${teamKey}` };
499
+ }
500
+ const targetStateName = cfg.inProgressStateName;
501
+ let targetState = targetStateName ? states.find((s) => s.name === targetStateName) : undefined;
502
+ if (!targetState)
503
+ targetState = states.find((s) => s.type === "started");
504
+ if (!targetState) {
505
+ return {
506
+ kind: "skip-no-in-progress-option",
507
+ projects: [team.name ?? team.key],
508
+ };
509
+ }
510
+ const workItem = data.issues.find((i) => i.identifier === issueId);
511
+ if (!workItem)
512
+ return { kind: "skip-no-issue" };
513
+ const protectedTypes = new Set(cfg.protectedStateTypes ?? DEFAULT_PROTECTED_STATE_TYPES);
514
+ const currentState = workItem.state;
515
+ if (currentState?.id === targetState.id) {
516
+ return { kind: "noop-already", projectTitle: team.name ?? team.key };
517
+ }
518
+ if (currentState?.type && protectedTypes.has(currentState.type)) {
519
+ return {
520
+ kind: "noop-protected",
521
+ projectTitle: team.name ?? team.key,
522
+ current: currentState.name ?? currentState.type,
523
+ };
524
+ }
525
+ const patch = await linearRequest(apiUrl, apiKey, TRANSITION_QUERY, { id: workItem.id, stateId: targetState.id });
526
+ if (!patch.ok) {
527
+ return {
528
+ kind: "error",
529
+ message: patch.error,
530
+ ...(patch.hint ? { hint: patch.hint } : {}),
531
+ };
532
+ }
533
+ if (!patch.data.issueUpdate.success) {
534
+ return { kind: "error", message: "Linear rejected the issueUpdate mutation" };
535
+ }
536
+ // Snapshot is now stale — wipe both the per-instance promise and the
537
+ // module-level cache so the next loadSnapshot refetches.
538
+ this.snapshotPromise = null;
539
+ invalidateSnapshotCache(cfg.workspaceSlug, cfg.teams.map((t) => t.key));
540
+ return {
541
+ kind: "transitioned",
542
+ projectTitle: team.name ?? team.key,
543
+ from: currentState?.name ?? null,
544
+ to: targetState.name,
545
+ };
546
+ }
547
+ }
548
+ export async function checkLinearSetup(repoRoot) {
549
+ const cfg = readMetadata(repoRoot).linear;
550
+ if (!cfg) {
551
+ return {
552
+ configured: false,
553
+ hasApiKey: false,
554
+ authOk: false,
555
+ teams: [],
556
+ hint: "Linear not configured. Run: mintree init --provider linear --workspace <slug> --team <key>",
557
+ };
558
+ }
559
+ const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
560
+ const apiKey = resolveApiKey();
561
+ if (!apiKey) {
562
+ return {
563
+ configured: true,
564
+ hasApiKey: false,
565
+ authOk: false,
566
+ workspaceSlug: cfg.workspaceSlug,
567
+ apiUrl,
568
+ teams: cfg.teams.map((t) => ({ key: t.key, ...(t.name ? { name: t.name } : {}), ok: false })),
569
+ hint: "export LINEAR_API_KEY=<key> or populate ~/.mintree/credentials.json#linear.apiKey",
570
+ };
571
+ }
572
+ // One round-trip covers viewer + every configured team. If any team key
573
+ // is wrong we'll see it as a missing node in the response.
574
+ const teamKeys = cfg.teams.map((t) => t.key);
575
+ const r = await linearRequest(apiUrl, apiKey,
576
+ /* GraphQL */ `
577
+ query MintreeDoctor($teamKeys: [String!]!) {
578
+ viewer {
579
+ id
580
+ name
581
+ email
582
+ }
583
+ teams(filter: { key: { in: $teamKeys } }) {
584
+ nodes {
585
+ id
586
+ key
587
+ name
588
+ }
589
+ }
590
+ }
591
+ `, { teamKeys });
592
+ if (!r.ok) {
593
+ return {
594
+ configured: true,
595
+ hasApiKey: true,
596
+ authOk: false,
597
+ workspaceSlug: cfg.workspaceSlug,
598
+ apiUrl,
599
+ teams: cfg.teams.map((t) => ({ key: t.key, ...(t.name ? { name: t.name } : {}), ok: false })),
600
+ hint: r.hint ?? r.error,
601
+ };
602
+ }
603
+ const foundKeys = new Set((r.data.teams.nodes ?? []).map((t) => t.key));
604
+ const teamResults = cfg.teams.map((t) => {
605
+ const ok = foundKeys.has(t.key);
606
+ const entry = { key: t.key, ok };
607
+ if (t.name)
608
+ entry.name = t.name;
609
+ if (!ok)
610
+ entry.error = `Team key "${t.key}" not found in workspace`;
611
+ return entry;
612
+ });
613
+ const allTeamsOk = teamResults.every((t) => t.ok);
614
+ const noTeams = cfg.teams.length === 0;
615
+ return {
616
+ configured: true,
617
+ hasApiKey: true,
618
+ authOk: true,
619
+ user: r.data.viewer.name ?? r.data.viewer.email ?? r.data.viewer.id,
620
+ workspaceSlug: cfg.workspaceSlug,
621
+ apiUrl,
622
+ teams: teamResults,
623
+ hint: noTeams
624
+ ? "No teams configured. Add at least one to .mintree/metadata.json#linear.teams[]"
625
+ : !allTeamsOk
626
+ ? "One or more configured teams could not be found — check teams[].key"
627
+ : undefined,
628
+ };
629
+ }