mintree 0.2.3 → 0.3.0

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.
@@ -1,749 +0,0 @@
1
- /**
2
- * PlaneProvider — implements IssueProvider against Plane's REST API
3
- * (https://api.plane.so by default, overridable for self-hosted).
4
- *
5
- * Talks raw HTTP rather than going through Plane's MCP server because
6
- * mintree runs as a Node CLI, not inside an MCP host. The shape of the
7
- * calls mirrors the MCP tools (list_work_items, list_states, get_me,
8
- * update_work_item) so the eventual error surface looks familiar.
9
- *
10
- * Auth resolution order: `PLANE_API_KEY` env var → `~/.mintree/
11
- * credentials.json` (`{ plane: { apiKey: "..." } }`). Never reads or
12
- * writes credentials to the repo's `.mintree/` directory — workspace API
13
- * keys are user-scoped, not repo-scoped.
14
- *
15
- * Filtering by assignee is done client-side: Plane's documented list
16
- * endpoint doesn't expose an assignee filter, so the provider fetches
17
- * the project's work items (paginated, per_page=100) and drops ones not
18
- * assigned to the current user. Acceptable for typical project sizes;
19
- * if a workspace ever grows past a few hundred items per project we can
20
- * revisit (probably by switching to the `/work-items/search/` endpoint
21
- * when it stabilises).
22
- */
23
- import * as fs from "fs";
24
- import * as os from "os";
25
- import * as path from "path";
26
- import { readMetadata } from "../metadata.js";
27
- const DEFAULT_API_URL = "https://api.plane.so";
28
- const DEFAULT_PROTECTED_STATE_GROUPS = ["completed", "cancelled"];
29
- const STATUS_ORDER_UNSET = 999;
30
- const WORK_ITEMS_PER_PAGE = 100;
31
- // Plane's list endpoints on large projects (with expand=state) routinely
32
- // take 10–15s to respond — we'd hit our previous 10s cap and start a chain
33
- // of retries before the original request even finished. 20s comfortably
34
- // fits the slowest projects in observed traffic without making real
35
- // failures (DNS issues, network down) drag too long.
36
- const REQUEST_TIMEOUT_MS = 20_000;
37
- const MAX_RETRIES = 2;
38
- const RETRY_BASE_DELAY_MS = 400;
39
- const STATES_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
40
- // Cap retry-after at this — we don't want to freeze the dashboard for a minute
41
- // because Plane is being overly defensive. Beyond this we just give up.
42
- const RETRY_AFTER_CAP_MS = 5_000;
43
- const MIN_REQUEST_INTERVAL_MS = 300;
44
- const USER_ID_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
45
- const EMPTY_PROJECT_TTL_MS = 30 * 60 * 1000; // 30 minutes
46
- const statesCache = new Map();
47
- function statesCacheKey(workspaceSlug, projectId) {
48
- return `${workspaceSlug}\x00${projectId}`;
49
- }
50
- function readStatesCache(workspaceSlug, projectId) {
51
- const entry = statesCache.get(statesCacheKey(workspaceSlug, projectId));
52
- if (!entry)
53
- return null;
54
- if (Date.now() - entry.fetchedAt > STATES_CACHE_TTL_MS) {
55
- statesCache.delete(statesCacheKey(workspaceSlug, projectId));
56
- return null;
57
- }
58
- return entry.states;
59
- }
60
- function writeStatesCache(workspaceSlug, projectId, states) {
61
- statesCache.set(statesCacheKey(workspaceSlug, projectId), {
62
- states,
63
- fetchedAt: Date.now(),
64
- });
65
- }
66
- // Module-level cache for the current user's id. /users/me/ is otherwise
67
- // fetched on every dashboard refresh (each one constructs a new
68
- // PlaneProvider); caching it for an hour drops the per-refresh call count
69
- // by one and avoids triggering rate limits when many refreshes pile up.
70
- let cachedUserIdEntry = null;
71
- function readCachedUserId() {
72
- if (!cachedUserIdEntry)
73
- return null;
74
- if (Date.now() - cachedUserIdEntry.fetchedAt > USER_ID_CACHE_TTL_MS) {
75
- cachedUserIdEntry = null;
76
- return null;
77
- }
78
- return cachedUserIdEntry.id;
79
- }
80
- function writeCachedUserId(id) {
81
- cachedUserIdEntry = { id, fetchedAt: Date.now() };
82
- }
83
- // Tracks projects that returned zero assigned items on the last successful
84
- // fetch — we skip their work-items endpoint on subsequent refreshes for a
85
- // while. This is the single biggest win against Plane's rate limit when the
86
- // user has many configured projects but only contributes to a few. New
87
- // assignments get picked up after the TTL expires.
88
- const emptyProjectsCache = new Map();
89
- function emptyKey(workspaceSlug, projectId) {
90
- return `${workspaceSlug}\x00${projectId}`;
91
- }
92
- function isKnownEmptyProject(workspaceSlug, projectId) {
93
- const entry = emptyProjectsCache.get(emptyKey(workspaceSlug, projectId));
94
- if (!entry)
95
- return false;
96
- if (Date.now() - entry.fetchedAt > EMPTY_PROJECT_TTL_MS) {
97
- emptyProjectsCache.delete(emptyKey(workspaceSlug, projectId));
98
- return false;
99
- }
100
- return true;
101
- }
102
- function markProjectEmpty(workspaceSlug, projectId) {
103
- emptyProjectsCache.set(emptyKey(workspaceSlug, projectId), { fetchedAt: Date.now() });
104
- }
105
- function clearProjectEmpty(workspaceSlug, projectId) {
106
- emptyProjectsCache.delete(emptyKey(workspaceSlug, projectId));
107
- }
108
- // Process-global throttle: serialises all Plane HTTP requests with a minimum
109
- // gap between them. Without this, parallel calls inside loadDashboard burst
110
- // past Plane's per-second cap and end up retrying for tens of seconds. With
111
- // a 300ms gap, 14 calls take ~4 seconds in the worst case — well within the
112
- // dashboard's tolerable response time, and well under any reasonable rate
113
- // limit.
114
- let throttleQueue = Promise.resolve();
115
- let lastRequestAt = 0;
116
- function throttle() {
117
- const wait = throttleQueue.then(async () => {
118
- const elapsed = Date.now() - lastRequestAt;
119
- if (elapsed < MIN_REQUEST_INTERVAL_MS) {
120
- await sleep(MIN_REQUEST_INTERVAL_MS - elapsed);
121
- }
122
- lastRequestAt = Date.now();
123
- });
124
- throttleQueue = wait;
125
- return wait;
126
- }
127
- const DEBUG_LOG_PATH = path.join(os.homedir(), ".mintree", "plane-debug.log");
128
- /**
129
- * Set `MINTREE_DEBUG=1` to enable Plane HTTP debug logging to
130
- * `~/.mintree/plane-debug.log`. Always-on stderr/stdout would corrupt the
131
- * Ink-rendered dashboard, so the log is file-only and opt-in.
132
- */
133
- function debugEnabled() {
134
- const v = process.env["MINTREE_DEBUG"];
135
- return v === "1" || v === "true";
136
- }
137
- function logDebug(message) {
138
- if (!debugEnabled())
139
- return;
140
- try {
141
- const dir = path.dirname(DEBUG_LOG_PATH);
142
- if (!fs.existsSync(dir))
143
- fs.mkdirSync(dir, { recursive: true });
144
- fs.appendFileSync(DEBUG_LOG_PATH, `[${new Date().toISOString()}] ${message}\n`);
145
- }
146
- catch {
147
- // Logging never crashes the dashboard.
148
- }
149
- }
150
- function isRetryableStatus(status) {
151
- // 0 → AbortError / network timeout / DNS / TLS
152
- // 429 → rate-limited
153
- // 5xx → server error
154
- return status === 0 || status === 429 || (status >= 500 && status < 600);
155
- }
156
- function sleep(ms) {
157
- return new Promise((resolve) => setTimeout(resolve, ms));
158
- }
159
- /**
160
- * Plucks the array of items out of a Plane list response. Tolerates three
161
- * shapes seen in the wild:
162
- * - { results: [...] } — the paginated work-items / projects endpoints
163
- * - { result: [...] } — the /states/ endpoint
164
- * - [ ... ] — bare array (some non-paginated endpoints)
165
- */
166
- function extractList(data) {
167
- if (Array.isArray(data))
168
- return data;
169
- if (data && typeof data === "object") {
170
- const d = data;
171
- if (Array.isArray(d.results))
172
- return d.results;
173
- if (Array.isArray(d.result))
174
- return d.result;
175
- }
176
- return [];
177
- }
178
- function extractCursor(data) {
179
- if (data && typeof data === "object") {
180
- const d = data;
181
- if (typeof d.next_cursor === "string")
182
- return d.next_cursor;
183
- }
184
- return null;
185
- }
186
- function resolveApiKey() {
187
- const env = process.env["PLANE_API_KEY"];
188
- if (env && env.length > 0)
189
- return env;
190
- const credsPath = path.join(os.homedir(), ".mintree", "credentials.json");
191
- try {
192
- if (!fs.existsSync(credsPath))
193
- return null;
194
- const parsed = JSON.parse(fs.readFileSync(credsPath, "utf-8"));
195
- const k = parsed?.plane?.apiKey;
196
- return typeof k === "string" && k.length > 0 ? k : null;
197
- }
198
- catch {
199
- return null;
200
- }
201
- }
202
- async function doPlaneRequest(apiUrl, apiKey, method, endpoint, body) {
203
- await throttle();
204
- const url = `${apiUrl.replace(/\/$/, "")}${endpoint}`;
205
- const controller = new AbortController();
206
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
207
- try {
208
- const res = await fetch(url, {
209
- method,
210
- headers: {
211
- "X-API-Key": apiKey,
212
- "Content-Type": "application/json",
213
- Accept: "application/json",
214
- },
215
- body: body !== undefined ? JSON.stringify(body) : undefined,
216
- signal: controller.signal,
217
- });
218
- clearTimeout(timer);
219
- if (!res.ok) {
220
- const text = await res.text().catch(() => "");
221
- const failure = interpretHttpError(res.status, text);
222
- // Pull a Retry-After header if Plane sent one — it can be either a
223
- // number of seconds or an HTTP date. We only honour the seconds
224
- // form (the date form is virtually never used by JSON APIs).
225
- if (res.status === 429) {
226
- const ra = res.headers.get("retry-after");
227
- if (ra) {
228
- const seconds = Number(ra);
229
- if (Number.isFinite(seconds) && seconds > 0) {
230
- failure.retryAfterMs = Math.min(seconds * 1000, RETRY_AFTER_CAP_MS);
231
- }
232
- }
233
- }
234
- return failure;
235
- }
236
- // 204 No Content — return empty object as T (callers shouldn't rely on
237
- // shape when they know the response is empty).
238
- if (res.status === 204)
239
- return { ok: true, data: {} };
240
- const data = (await res.json());
241
- return { ok: true, data };
242
- }
243
- catch (err) {
244
- clearTimeout(timer);
245
- if (err instanceof Error && err.name === "AbortError") {
246
- return { ok: false, status: 0, error: "Plane API request timed out" };
247
- }
248
- return {
249
- ok: false,
250
- status: 0,
251
- error: err instanceof Error ? err.message : String(err),
252
- };
253
- }
254
- }
255
- /**
256
- * Calls the Plane API with retry on transient errors. Retries up to
257
- * MAX_RETRIES times on 429 (rate-limited), 5xx (server error), and status
258
- * 0 (network timeout / abort). Permanent errors (401, 403, 404) are
259
- * returned immediately. Every failure path writes to the debug log when
260
- * MINTREE_DEBUG=1.
261
- *
262
- * The backoff is exponential with a tiny base (400ms → 800ms) so a brief
263
- * rate-limit window passes without making the dashboard feel sluggish.
264
- * Plane's published rate limits aren't aggressive; this is a safety net
265
- * for occasional bursts, not a workaround for sustained over-fetching.
266
- */
267
- async function planeRequest(apiUrl, apiKey, method, endpoint, body) {
268
- let attempt = 0;
269
- let lastResult = null;
270
- while (attempt <= MAX_RETRIES) {
271
- const result = await doPlaneRequest(apiUrl, apiKey, method, endpoint, body);
272
- if (result.ok) {
273
- if (attempt > 0) {
274
- logDebug(`recovered ${method} ${endpoint} after ${attempt} retry/retries`);
275
- }
276
- return result;
277
- }
278
- lastResult = result;
279
- if (!isRetryableStatus(result.status) || attempt === MAX_RETRIES) {
280
- logDebug(`failed ${method} ${endpoint} status=${result.status} error=${result.error}${result.hint ? ` hint=${result.hint}` : ""}`);
281
- return result;
282
- }
283
- // Prefer the server-supplied Retry-After when present; fall back to
284
- // exponential backoff. Capping is already applied when the header is
285
- // parsed in doPlaneRequest.
286
- const delay = result.retryAfterMs !== undefined
287
- ? result.retryAfterMs
288
- : RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
289
- logDebug(`retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms ${method} ${endpoint} (last status=${result.status}${result.retryAfterMs !== undefined ? ", server-Retry-After" : ""})`);
290
- await sleep(delay);
291
- attempt += 1;
292
- }
293
- // Defensive — loop above always returns inside.
294
- return (lastResult ?? {
295
- ok: false,
296
- status: 0,
297
- error: "planeRequest exhausted retries with no recorded error",
298
- });
299
- }
300
- function interpretHttpError(status, body) {
301
- if (status === 401 || status === 403) {
302
- return {
303
- ok: false,
304
- status,
305
- error: "Plane rejected the API key (401/403).",
306
- hint: "Verify PLANE_API_KEY or ~/.mintree/credentials.json#plane.apiKey",
307
- };
308
- }
309
- if (status === 404) {
310
- return {
311
- ok: false,
312
- status,
313
- error: "Plane workspace or project not found (404).",
314
- hint: "Check workspaceSlug and projects[].id in .mintree/metadata.json",
315
- };
316
- }
317
- const snippet = body.slice(0, 200).replace(/\s+/g, " ").trim();
318
- return {
319
- ok: false,
320
- status,
321
- error: snippet || `Plane API responded with HTTP ${status}`,
322
- };
323
- }
324
- function toIssueId(project, sequence) {
325
- return `${project.identifier}-${sequence}`;
326
- }
327
- function normaliseAssignees(raw) {
328
- if (!raw)
329
- return [];
330
- const out = [];
331
- for (const a of raw) {
332
- if (typeof a === "string") {
333
- out.push(a);
334
- }
335
- else if (a && typeof a === "object") {
336
- if (typeof a.id === "string")
337
- out.push(a.id);
338
- if (typeof a.member === "string")
339
- out.push(a.member);
340
- }
341
- }
342
- return out;
343
- }
344
- function normaliseState(raw) {
345
- if (!raw)
346
- return null;
347
- if (typeof raw === "string")
348
- return { id: raw };
349
- if (typeof raw === "object" && typeof raw.id === "string") {
350
- return { id: raw.id, name: raw.name, group: raw.group };
351
- }
352
- return null;
353
- }
354
- function mapWorkItemToProviderIssue(project, workspaceSlug, wi) {
355
- const labels = [];
356
- if (Array.isArray(wi.labels)) {
357
- for (const l of wi.labels) {
358
- if (typeof l === "string")
359
- labels.push({ name: l });
360
- else if (l && typeof l === "object" && typeof l.name === "string")
361
- labels.push({ name: l.name });
362
- }
363
- }
364
- const state = normaliseState(wi.state);
365
- const url = `https://app.plane.so/${workspaceSlug}/browse/${project.identifier}-${wi.sequence_id}/`;
366
- return {
367
- id: toIssueId(project, wi.sequence_id),
368
- title: wi.name,
369
- state: state?.name ?? "",
370
- url,
371
- labels,
372
- body: wi.description_stripped ?? wi.description_html ?? wi.description ?? "",
373
- createdAt: wi.created_at ?? "",
374
- updatedAt: wi.updated_at ?? "",
375
- };
376
- }
377
- export class PlaneProvider {
378
- repoRoot;
379
- kind = "plane";
380
- cachedUserId = null;
381
- // Per-instance cache so the dashboard's back-to-back listAssignedIssues +
382
- // fetchProjectAssignments don't double-fetch the work items. Keyed by
383
- // project.id. Resets each time createProvider() is called.
384
- workItemsByProject = null;
385
- statesByProject = null;
386
- constructor(repoRoot) {
387
- this.repoRoot = repoRoot;
388
- }
389
- getConfig() {
390
- return readMetadata(this.repoRoot).plane ?? null;
391
- }
392
- async getUserId(apiUrl, apiKey) {
393
- // Instance cache first (within a load), then module-level cache (across
394
- // loads). /users/me/ is otherwise the most-called endpoint after work-
395
- // items because every refresh built a new PlaneProvider.
396
- if (this.cachedUserId)
397
- return this.cachedUserId;
398
- const fromModule = readCachedUserId();
399
- if (fromModule) {
400
- this.cachedUserId = fromModule;
401
- return fromModule;
402
- }
403
- const r = await planeRequest(apiUrl, apiKey, "GET", "/api/v1/users/me/");
404
- if (!r.ok)
405
- return null;
406
- this.cachedUserId = r.data.id;
407
- writeCachedUserId(r.data.id);
408
- return r.data.id;
409
- }
410
- async fetchAssignedWorkItems(apiUrl, apiKey, workspaceSlug, projectId, userId) {
411
- if (this.workItemsByProject?.has(projectId)) {
412
- return this.workItemsByProject.get(projectId);
413
- }
414
- // Skip projects that were empty last time we successfully fetched
415
- // them. Cuts the call count dramatically for users who have many
416
- // projects configured but only contribute to a few. The cache
417
- // expires after EMPTY_PROJECT_TTL_MS so new assignments still get
418
- // picked up.
419
- if (isKnownEmptyProject(workspaceSlug, projectId)) {
420
- if (!this.workItemsByProject)
421
- this.workItemsByProject = new Map();
422
- this.workItemsByProject.set(projectId, []);
423
- return [];
424
- }
425
- const items = [];
426
- let cursor = null;
427
- // Hard cap to keep us safe from accidentally walking a 10k-item project.
428
- // 5 pages × 100 = 500 items is plenty for "issues assigned to me".
429
- const maxPages = 5;
430
- let pages = 0;
431
- do {
432
- const qs = new URLSearchParams({
433
- per_page: String(WORK_ITEMS_PER_PAGE),
434
- // expand=state turns the `state` UUID into a full object with
435
- // name/group/color (no extra API call needed). expand=labels
436
- // does the same for labels — without it the labels field is
437
- // just an array of UUIDs and the dashboard renders `[uuid]`
438
- // instead of `[name]` in the issue detail pane.
439
- expand: "state,labels",
440
- });
441
- if (cursor)
442
- qs.set("cursor", cursor);
443
- const endpoint = `/api/v1/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/work-items/?${qs.toString()}`;
444
- const r = await planeRequest(apiUrl, apiKey, "GET", endpoint);
445
- if (!r.ok)
446
- return null;
447
- const page = extractList(r.data);
448
- for (const wi of page) {
449
- const assignees = normaliseAssignees(wi.assignees);
450
- if (!assignees.includes(userId))
451
- continue;
452
- items.push(wi);
453
- }
454
- cursor = extractCursor(r.data);
455
- pages += 1;
456
- } while (cursor && pages < maxPages);
457
- if (!this.workItemsByProject)
458
- this.workItemsByProject = new Map();
459
- this.workItemsByProject.set(projectId, items);
460
- // Remember which projects had zero assignments for us — next refresh
461
- // can skip them entirely. Successful but non-empty fetches clear
462
- // any stale "empty" marker so we don't get stuck thinking a project
463
- // is empty after a new assignment lands.
464
- if (items.length === 0) {
465
- markProjectEmpty(workspaceSlug, projectId);
466
- }
467
- else {
468
- clearProjectEmpty(workspaceSlug, projectId);
469
- }
470
- return items;
471
- }
472
- async fetchStates(apiUrl, apiKey, workspaceSlug, projectId) {
473
- // Instance cache first — fastest path.
474
- if (this.statesByProject?.has(projectId)) {
475
- return this.statesByProject.get(projectId);
476
- }
477
- // Then the cross-instance module-level cache. Workflow states rarely
478
- // change, so a 1-hour TTL is plenty and saves an entire fetch per
479
- // project on every dashboard refresh.
480
- const cached = readStatesCache(workspaceSlug, projectId);
481
- if (cached) {
482
- if (!this.statesByProject)
483
- this.statesByProject = new Map();
484
- this.statesByProject.set(projectId, cached);
485
- return cached;
486
- }
487
- const endpoint = `/api/v1/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/states/?per_page=100`;
488
- const r = await planeRequest(apiUrl, apiKey, "GET", endpoint);
489
- if (!r.ok)
490
- return null;
491
- const sorted = extractList(r.data).sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
492
- if (!this.statesByProject)
493
- this.statesByProject = new Map();
494
- this.statesByProject.set(projectId, sorted);
495
- writeStatesCache(workspaceSlug, projectId, sorted);
496
- return sorted;
497
- }
498
- async listAssignedIssues() {
499
- const cfg = this.getConfig();
500
- if (!cfg || cfg.projects.length === 0)
501
- return [];
502
- const apiKey = resolveApiKey();
503
- if (!apiKey)
504
- return null;
505
- const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
506
- const userId = await this.getUserId(apiUrl, apiKey);
507
- if (!userId)
508
- return null;
509
- // Per-project failures shouldn't blow up the entire dashboard — a
510
- // transient HTTP error in one project would otherwise drop the user
511
- // back to the error screen even when the other 6 projects are fine.
512
- // Collect successful projects, count failures, and only null out when
513
- // EVERY configured project failed.
514
- const out = [];
515
- let failed = 0;
516
- const protectedGroups = new Set(cfg.protectedStateGroups ?? DEFAULT_PROTECTED_STATE_GROUPS);
517
- for (const project of cfg.projects) {
518
- const items = await this.fetchAssignedWorkItems(apiUrl, apiKey, cfg.workspaceSlug, project.id, userId);
519
- if (items === null) {
520
- failed += 1;
521
- continue;
522
- }
523
- // Drop work items in completed/cancelled groups — dashboard only shows
524
- // open work. Same intent as `gh issue list --state open`.
525
- for (const wi of items) {
526
- const state = normaliseState(wi.state);
527
- if (state?.group && protectedGroups.has(state.group))
528
- continue;
529
- out.push(mapWorkItemToProviderIssue(project, cfg.workspaceSlug, wi));
530
- }
531
- }
532
- if (failed === cfg.projects.length)
533
- return null;
534
- return out;
535
- }
536
- async fetchProjectAssignments() {
537
- const result = new Map();
538
- const cfg = this.getConfig();
539
- if (!cfg || cfg.projects.length === 0)
540
- return result;
541
- const apiKey = resolveApiKey();
542
- if (!apiKey)
543
- return null;
544
- const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
545
- const userId = await this.getUserId(apiUrl, apiKey);
546
- if (!userId)
547
- return null;
548
- // Track per-project failures so we can distinguish "all states fetches
549
- // failed" (return null → caller treats as load failure, keeps last-good
550
- // state) from "everything succeeded but the user has no work in any
551
- // project" (return empty map). Without this distinction a transient
552
- // Plane API hiccup silently drops project headers from the dashboard.
553
- let succeededAtLeastOne = false;
554
- for (const project of cfg.projects) {
555
- const states = await this.fetchStates(apiUrl, apiKey, cfg.workspaceSlug, project.id);
556
- if (!states)
557
- continue;
558
- succeededAtLeastOne = true;
559
- const stateById = new Map(states.map((s, idx) => [s.id, { state: s, order: idx }]));
560
- const items = await this.fetchAssignedWorkItems(apiUrl, apiKey, cfg.workspaceSlug, project.id, userId);
561
- if (!items)
562
- continue;
563
- const projectTitle = project.name ?? project.identifier;
564
- const projectUrl = `https://app.plane.so/${cfg.workspaceSlug}/projects/${project.id}/`;
565
- for (const wi of items) {
566
- const state = normaliseState(wi.state);
567
- const lookup = state ? stateById.get(state.id) : undefined;
568
- const issueId = toIssueId(project, wi.sequence_id);
569
- result.set(issueId, {
570
- projectTitle,
571
- projectUrl,
572
- projectNumber: 0,
573
- status: lookup?.state.name ?? state?.name ?? null,
574
- statusColor: lookup?.state.color ?? "yellow",
575
- statusOrder: lookup?.order ?? STATUS_ORDER_UNSET,
576
- });
577
- }
578
- }
579
- if (!succeededAtLeastOne)
580
- return null;
581
- return result;
582
- }
583
- async transitionIssueToInProgress(issueId) {
584
- const cfg = this.getConfig();
585
- if (!cfg) {
586
- return {
587
- kind: "error",
588
- message: "Plane config missing in .mintree/metadata.json",
589
- hint: "Run `mintree init --provider plane --workspace <slug>` first",
590
- };
591
- }
592
- const apiKey = resolveApiKey();
593
- if (!apiKey) {
594
- return {
595
- kind: "error",
596
- message: "PLANE_API_KEY not set",
597
- hint: "export PLANE_API_KEY=<your key> (or write ~/.mintree/credentials.json)",
598
- };
599
- }
600
- const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
601
- const dash = issueId.lastIndexOf("-");
602
- if (dash <= 0)
603
- return { kind: "skip-no-issue" };
604
- const identifier = issueId.slice(0, dash);
605
- const sequence = Number(issueId.slice(dash + 1));
606
- if (!Number.isFinite(sequence))
607
- return { kind: "skip-no-issue" };
608
- const project = cfg.projects.find((p) => p.identifier === identifier);
609
- if (!project)
610
- return { kind: "skip-no-project" };
611
- const userId = await this.getUserId(apiUrl, apiKey);
612
- if (!userId) {
613
- return {
614
- kind: "error",
615
- message: "Could not resolve current user from Plane API",
616
- hint: "Check that the API key has access to the workspace",
617
- };
618
- }
619
- const states = await this.fetchStates(apiUrl, apiKey, cfg.workspaceSlug, project.id);
620
- if (!states) {
621
- return { kind: "error", message: `Could not fetch states for project ${project.identifier}` };
622
- }
623
- const targetStateName = cfg.inProgressStateName;
624
- let targetState = targetStateName ? states.find((s) => s.name === targetStateName) : undefined;
625
- if (!targetState)
626
- targetState = states.find((s) => s.group === "started");
627
- if (!targetState) {
628
- return {
629
- kind: "skip-no-in-progress-option",
630
- projects: [project.name ?? project.identifier],
631
- };
632
- }
633
- const items = await this.fetchAssignedWorkItems(apiUrl, apiKey, cfg.workspaceSlug, project.id, userId);
634
- if (!items) {
635
- return { kind: "error", message: `Could not fetch work items for ${project.identifier}` };
636
- }
637
- const workItem = items.find((w) => w.sequence_id === sequence);
638
- if (!workItem)
639
- return { kind: "skip-no-issue" };
640
- const protectedGroups = new Set(cfg.protectedStateGroups ?? DEFAULT_PROTECTED_STATE_GROUPS);
641
- const currentState = normaliseState(workItem.state);
642
- if (currentState) {
643
- if (currentState.id === targetState.id) {
644
- return { kind: "noop-already", projectTitle: project.name ?? project.identifier };
645
- }
646
- if (currentState.group && protectedGroups.has(currentState.group)) {
647
- return {
648
- kind: "noop-protected",
649
- projectTitle: project.name ?? project.identifier,
650
- current: currentState.name ?? currentState.group,
651
- };
652
- }
653
- }
654
- const patchEndpoint = `/api/v1/workspaces/${encodeURIComponent(cfg.workspaceSlug)}/projects/${encodeURIComponent(project.id)}/work-items/${encodeURIComponent(workItem.id)}/`;
655
- const patch = await planeRequest(apiUrl, apiKey, "PATCH", patchEndpoint, {
656
- state: targetState.id,
657
- });
658
- if (!patch.ok) {
659
- return {
660
- kind: "error",
661
- message: patch.error,
662
- ...(patch.hint ? { hint: patch.hint } : {}),
663
- };
664
- }
665
- // Cache invalidation — the work item's state changed, so subsequent
666
- // queries within this process should see fresh data. Wipes the per-
667
- // project work-item cache; the next call refetches.
668
- this.workItemsByProject?.delete(project.id);
669
- return {
670
- kind: "transitioned",
671
- projectTitle: project.name ?? project.identifier,
672
- from: currentState?.name ?? null,
673
- to: targetState.name,
674
- };
675
- }
676
- }
677
- export async function checkPlaneSetup(repoRoot) {
678
- const cfg = readMetadata(repoRoot).plane;
679
- if (!cfg) {
680
- return {
681
- configured: false,
682
- hasApiKey: false,
683
- authOk: false,
684
- projects: [],
685
- hint: "Plane not configured. Run: mintree init --provider plane --workspace <slug>",
686
- };
687
- }
688
- const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
689
- const apiKey = resolveApiKey();
690
- if (!apiKey) {
691
- return {
692
- configured: true,
693
- hasApiKey: false,
694
- authOk: false,
695
- workspaceSlug: cfg.workspaceSlug,
696
- apiUrl,
697
- projects: cfg.projects.map((p) => ({ identifier: p.identifier, id: p.id, ok: false })),
698
- hint: "export PLANE_API_KEY=<key> or populate ~/.mintree/credentials.json#plane.apiKey",
699
- };
700
- }
701
- const me = await planeRequest(apiUrl, apiKey, "GET", "/api/v1/users/me/");
702
- if (!me.ok) {
703
- return {
704
- configured: true,
705
- hasApiKey: true,
706
- authOk: false,
707
- workspaceSlug: cfg.workspaceSlug,
708
- apiUrl,
709
- projects: cfg.projects.map((p) => ({ identifier: p.identifier, id: p.id, ok: false })),
710
- hint: me.hint ?? me.error,
711
- };
712
- }
713
- // Probe each configured project so the row can show per-project
714
- // reachability — most common misconfig is a wrong project UUID. Done
715
- // sequentially to keep the doctor's output deterministic.
716
- const projectResults = [];
717
- let allProjectsOk = true;
718
- for (const p of cfg.projects) {
719
- const endpoint = `/api/v1/workspaces/${encodeURIComponent(cfg.workspaceSlug)}/projects/${encodeURIComponent(p.id)}/`;
720
- const r = await planeRequest(apiUrl, apiKey, "GET", endpoint);
721
- if (r.ok) {
722
- projectResults.push({ identifier: p.identifier, id: p.id, ok: true });
723
- }
724
- else {
725
- allProjectsOk = false;
726
- projectResults.push({
727
- identifier: p.identifier,
728
- id: p.id,
729
- ok: false,
730
- error: r.hint ?? r.error,
731
- });
732
- }
733
- }
734
- const noProjects = cfg.projects.length === 0;
735
- return {
736
- configured: true,
737
- hasApiKey: true,
738
- authOk: true,
739
- user: me.data.display_name ?? me.data.email ?? me.data.id,
740
- workspaceSlug: cfg.workspaceSlug,
741
- apiUrl,
742
- projects: projectResults,
743
- hint: noProjects
744
- ? "No projects configured. Add at least one to .mintree/metadata.json#plane.projects[]"
745
- : !allProjectsOk
746
- ? "One or more configured projects could not be reached — check projects[].id"
747
- : undefined,
748
- };
749
- }