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
|
@@ -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
|
-
}
|