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.
- package/dist/commands/dashboard.js +15 -15
- package/dist/commands/doctor.js +30 -24
- package/dist/commands/init.d.ts +2 -1
- package/dist/commands/init.js +34 -21
- package/dist/commands/worktree/list.js +1 -1
- package/dist/commands/worktree/work.js +1 -1
- package/dist/lib/branch.d.ts +2 -2
- package/dist/lib/branch.js +4 -4
- package/dist/lib/dashboard.js +5 -7
- package/dist/lib/gh.d.ts +2 -2
- package/dist/lib/gh.js +2 -2
- package/dist/lib/metadata.d.ts +7 -8
- package/dist/lib/metadata.js +16 -18
- package/dist/lib/pr.d.ts +2 -2
- package/dist/lib/pr.js +2 -2
- package/dist/lib/providers/index.js +3 -3
- package/dist/lib/providers/linear.d.ts +55 -0
- package/dist/lib/providers/linear.js +629 -0
- package/dist/lib/providers/types.d.ts +9 -9
- package/dist/lib/providers/types.js +3 -3
- package/dist/lib/session-signal.d.ts +1 -1
- package/dist/lib/session-signal.js +1 -1
- package/dist/lib/worktreeCreate.js +2 -2
- package/package.json +1 -1
- package/dist/lib/providers/plane.d.ts +0 -61
- package/dist/lib/providers/plane.js +0 -749
|
@@ -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
|
+
}
|