mintree 0.1.10 → 0.2.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.
- package/README.md +74 -17
- package/dist/commands/dashboard.js +113 -46
- package/dist/commands/doctor.js +62 -19
- package/dist/commands/init.d.ts +14 -1
- package/dist/commands/init.js +72 -13
- package/dist/commands/worktree/clean.js +2 -19
- package/dist/commands/worktree/create.js +3 -2
- package/dist/commands/worktree/list.js +10 -22
- package/dist/commands/worktree/work.js +5 -4
- package/dist/lib/branch.d.ts +7 -4
- package/dist/lib/branch.js +15 -7
- package/dist/lib/dashboard.d.ts +5 -42
- package/dist/lib/dashboard.js +33 -189
- package/dist/lib/gh.d.ts +16 -0
- package/dist/lib/{github.js → gh.js} +9 -0
- package/dist/lib/metadata.d.ts +15 -0
- package/dist/lib/metadata.js +51 -0
- package/dist/lib/pr.d.ts +26 -0
- package/dist/lib/pr.js +49 -0
- package/dist/lib/providers/github.d.ts +33 -0
- package/dist/lib/providers/github.js +381 -0
- package/dist/lib/providers/index.d.ts +27 -0
- package/dist/lib/providers/index.js +83 -0
- package/dist/lib/providers/plane.d.ts +61 -0
- package/dist/lib/providers/plane.js +749 -0
- package/dist/lib/providers/types.d.ts +113 -0
- package/dist/lib/providers/types.js +12 -0
- package/dist/lib/session-signal.d.ts +3 -2
- package/dist/lib/session-signal.js +4 -3
- package/dist/lib/worktreeCreate.js +4 -1
- package/package.json +1 -1
- package/dist/lib/github.d.ts +0 -7
- package/dist/lib/githubProject.d.ts +0 -55
- package/dist/lib/githubProject.js +0 -277
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GithubProvider — implements IssueProvider against GitHub Issues + Projects
|
|
3
|
+
* v2 via the `gh` CLI. All the GraphQL plumbing that previously lived in
|
|
4
|
+
* dashboard.ts (project assignment lookup) and githubProject.ts (status
|
|
5
|
+
* transition) is consolidated here so the rest of mintree can talk to issues
|
|
6
|
+
* through a stable, provider-agnostic interface.
|
|
7
|
+
*
|
|
8
|
+
* Stays gh-CLI-driven (not raw octokit) because gh transparently handles
|
|
9
|
+
* auth tokens, scope refresh, and the user's preferred login — mintree's
|
|
10
|
+
* doctor already validates that flow, and not having a second auth path
|
|
11
|
+
* means there's only one thing to break.
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from "child_process";
|
|
14
|
+
import { promisify } from "util";
|
|
15
|
+
import { tryExec } from "../exec.js";
|
|
16
|
+
import { getRepoFullName } from "../gh.js";
|
|
17
|
+
import { readMetadata } from "../metadata.js";
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
const DEFAULT_STATUS_FIELD = "Status";
|
|
20
|
+
const DEFAULT_IN_PROGRESS_OPTION = "In Progress";
|
|
21
|
+
const DEFAULT_PROTECTED_STATUSES = ["In Review", "Done"];
|
|
22
|
+
const ISSUE_LIST_LIMIT = 50;
|
|
23
|
+
// GitHub Projects v2 single-select options carry their own colour enum.
|
|
24
|
+
// Map each to the closest Ink/chalk colour; ORANGE and PINK have no 16-colour
|
|
25
|
+
// keyword so they use hex (truecolor terminals render them, others approximate).
|
|
26
|
+
const PROJECT_STATUS_COLORS = {
|
|
27
|
+
GRAY: "gray",
|
|
28
|
+
BLUE: "blue",
|
|
29
|
+
GREEN: "green",
|
|
30
|
+
YELLOW: "yellow",
|
|
31
|
+
ORANGE: "#d18616",
|
|
32
|
+
RED: "red",
|
|
33
|
+
PINK: "#d2a8ff",
|
|
34
|
+
PURPLE: "magenta",
|
|
35
|
+
};
|
|
36
|
+
const STATUS_ORDER_UNSET = 999;
|
|
37
|
+
function parseProjectNumberFromUrl(url) {
|
|
38
|
+
const m = url.match(/\/projects\/(\d+)/);
|
|
39
|
+
return m && m[1] ? Number(m[1]) : null;
|
|
40
|
+
}
|
|
41
|
+
async function runGhGraphql(query, fields) {
|
|
42
|
+
const args = ["api", "graphql", "-f", `query=${query}`];
|
|
43
|
+
for (const [key, value] of fields) {
|
|
44
|
+
if (typeof value === "number") {
|
|
45
|
+
args.push("-F", `${key}=${value}`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
args.push("-f", `${key}=${value}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const { stdout } = await execFileAsync("gh", args);
|
|
52
|
+
return JSON.parse(stdout);
|
|
53
|
+
}
|
|
54
|
+
async function ghGraphqlOrNull(query) {
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await execFileAsync("gh", ["api", "graphql", "-f", `query=${query}`]);
|
|
57
|
+
return JSON.parse(stdout);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function interpretGhError(err) {
|
|
64
|
+
const stderr = err && typeof err === "object" && "stderr" in err
|
|
65
|
+
? String(err.stderr)
|
|
66
|
+
: err instanceof Error
|
|
67
|
+
? err.message
|
|
68
|
+
: String(err);
|
|
69
|
+
if (/INSUFFICIENT_SCOPES/i.test(stderr) || (/scope/i.test(stderr) && /project/i.test(stderr))) {
|
|
70
|
+
return {
|
|
71
|
+
kind: "error",
|
|
72
|
+
message: "gh token is missing the `project` scope.",
|
|
73
|
+
hint: "Run: gh auth refresh -s project",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (/Could not resolve to a Repository/i.test(stderr)) {
|
|
77
|
+
return { kind: "skip-no-repo" };
|
|
78
|
+
}
|
|
79
|
+
if (/Could not resolve to an Issue/i.test(stderr)) {
|
|
80
|
+
return { kind: "skip-no-issue" };
|
|
81
|
+
}
|
|
82
|
+
const firstLine = stderr.split("\n").find((line) => line.trim().length > 0) ?? "";
|
|
83
|
+
return {
|
|
84
|
+
kind: "error",
|
|
85
|
+
message: firstLine.slice(0, 200) || "gh api graphql failed",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function pickProjectNode(nodes, configuredUrl) {
|
|
89
|
+
if (nodes.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
if (configuredUrl) {
|
|
92
|
+
const targetNumber = parseProjectNumberFromUrl(configuredUrl);
|
|
93
|
+
return (nodes.find((n) => n.project?.url === configuredUrl ||
|
|
94
|
+
(targetNumber !== null && n.project?.number === targetNumber)) ?? null);
|
|
95
|
+
}
|
|
96
|
+
return nodes[0] ?? null;
|
|
97
|
+
}
|
|
98
|
+
function toProjectInfo(node) {
|
|
99
|
+
const proj = node.project;
|
|
100
|
+
if (!proj)
|
|
101
|
+
return null;
|
|
102
|
+
const options = proj.field?.options ?? [];
|
|
103
|
+
const status = node.fieldValueByName?.name ?? null;
|
|
104
|
+
const optionIndex = status ? options.findIndex((o) => o.name === status) : -1;
|
|
105
|
+
const option = optionIndex >= 0 ? options[optionIndex] : undefined;
|
|
106
|
+
return {
|
|
107
|
+
projectTitle: proj.title ?? "(untitled project)",
|
|
108
|
+
projectUrl: proj.url ?? "",
|
|
109
|
+
projectNumber: proj.number ?? 0,
|
|
110
|
+
status,
|
|
111
|
+
statusColor: option?.color
|
|
112
|
+
? (PROJECT_STATUS_COLORS[option.color] ?? "yellow")
|
|
113
|
+
: status
|
|
114
|
+
? "yellow"
|
|
115
|
+
: "gray",
|
|
116
|
+
statusOrder: optionIndex >= 0 ? optionIndex : STATUS_ORDER_UNSET,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export class GithubProvider {
|
|
120
|
+
repoRoot;
|
|
121
|
+
kind = "github";
|
|
122
|
+
constructor(repoRoot) {
|
|
123
|
+
this.repoRoot = repoRoot;
|
|
124
|
+
}
|
|
125
|
+
readProjectConfig() {
|
|
126
|
+
return readMetadata(this.repoRoot).project ?? {};
|
|
127
|
+
}
|
|
128
|
+
async listAssignedIssues() {
|
|
129
|
+
const json = await tryExec(`gh issue list --assignee @me --state open --json number,title,state,url,labels,body,createdAt,updatedAt --limit ${ISSUE_LIST_LIMIT} 2>/dev/null`);
|
|
130
|
+
if (!json)
|
|
131
|
+
return null;
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(json);
|
|
134
|
+
if (!Array.isArray(parsed))
|
|
135
|
+
return null;
|
|
136
|
+
return parsed.map((raw) => ({
|
|
137
|
+
id: String(raw.number),
|
|
138
|
+
title: raw.title,
|
|
139
|
+
state: raw.state,
|
|
140
|
+
url: raw.url,
|
|
141
|
+
labels: raw.labels,
|
|
142
|
+
body: raw.body,
|
|
143
|
+
createdAt: raw.createdAt,
|
|
144
|
+
updatedAt: raw.updatedAt,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async fetchProjectAssignments() {
|
|
152
|
+
const result = new Map();
|
|
153
|
+
const repo = await getRepoFullName();
|
|
154
|
+
if (!repo)
|
|
155
|
+
return result;
|
|
156
|
+
const cfg = this.readProjectConfig();
|
|
157
|
+
const statusFieldName = cfg.statusField ?? DEFAULT_STATUS_FIELD;
|
|
158
|
+
const configuredUrl = cfg.url ?? null;
|
|
159
|
+
// The Status field name is interpolated into the query (not a variable)
|
|
160
|
+
// because it appears as a field argument; escape embedded quotes.
|
|
161
|
+
const escapedField = statusFieldName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
162
|
+
const searchQuery = `repo:${repo} is:issue is:open assignee:@me`.replace(/"/g, '\\"');
|
|
163
|
+
const query = `query {
|
|
164
|
+
search(query: "${searchQuery}", type: ISSUE, first: ${ISSUE_LIST_LIMIT}) {
|
|
165
|
+
nodes {
|
|
166
|
+
... on Issue {
|
|
167
|
+
number
|
|
168
|
+
projectItems(first: 10, includeArchived: false) {
|
|
169
|
+
nodes {
|
|
170
|
+
project {
|
|
171
|
+
title
|
|
172
|
+
number
|
|
173
|
+
url
|
|
174
|
+
field(name: "${escapedField}") {
|
|
175
|
+
... on ProjectV2SingleSelectField {
|
|
176
|
+
options { name color }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
fieldValueByName(name: "${escapedField}") {
|
|
181
|
+
... on ProjectV2ItemFieldSingleSelectValue { name }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}`;
|
|
189
|
+
const raw = (await ghGraphqlOrNull(query));
|
|
190
|
+
// Distinguish a failed call (gh missing the project scope, network
|
|
191
|
+
// error) from a successful call with no results — the former returns
|
|
192
|
+
// null so the dashboard treats it as a partial load failure and keeps
|
|
193
|
+
// its last-good state.
|
|
194
|
+
if (raw === null)
|
|
195
|
+
return null;
|
|
196
|
+
const nodes = raw?.data?.search?.nodes;
|
|
197
|
+
if (!Array.isArray(nodes))
|
|
198
|
+
return result;
|
|
199
|
+
for (const node of nodes) {
|
|
200
|
+
if (typeof node?.number !== "number")
|
|
201
|
+
continue;
|
|
202
|
+
const items = node.projectItems?.nodes ?? [];
|
|
203
|
+
const picked = pickProjectNode(items, configuredUrl);
|
|
204
|
+
if (!picked)
|
|
205
|
+
continue;
|
|
206
|
+
const info = toProjectInfo(picked);
|
|
207
|
+
if (info)
|
|
208
|
+
result.set(String(node.number), info);
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
async transitionIssueToInProgress(issueId) {
|
|
213
|
+
const repo = await getRepoFullName();
|
|
214
|
+
if (!repo)
|
|
215
|
+
return { kind: "skip-no-repo" };
|
|
216
|
+
const [owner, name] = repo.split("/");
|
|
217
|
+
if (!owner || !name)
|
|
218
|
+
return { kind: "skip-no-repo" };
|
|
219
|
+
const issueNumber = Number(issueId);
|
|
220
|
+
if (!Number.isFinite(issueNumber))
|
|
221
|
+
return { kind: "skip-no-issue" };
|
|
222
|
+
const cfg = this.readProjectConfig();
|
|
223
|
+
const statusFieldName = cfg.statusField ?? DEFAULT_STATUS_FIELD;
|
|
224
|
+
const inProgressOptionName = cfg.inProgressOption ?? DEFAULT_IN_PROGRESS_OPTION;
|
|
225
|
+
const protectedStatuses = cfg.protectedStatuses ?? DEFAULT_PROTECTED_STATUSES;
|
|
226
|
+
// The Status field name is interpolated into the query (not a variable)
|
|
227
|
+
// because GraphQL field-argument names are not parameterizable through
|
|
228
|
+
// the variables object. Escape any embedded quotes to keep the query valid.
|
|
229
|
+
const escapedFieldName = statusFieldName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
230
|
+
const query = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
231
|
+
repository(owner: $owner, name: $repo) {
|
|
232
|
+
issue(number: $number) {
|
|
233
|
+
id
|
|
234
|
+
projectItems(first: 20, includeArchived: false) {
|
|
235
|
+
nodes {
|
|
236
|
+
id
|
|
237
|
+
project {
|
|
238
|
+
id
|
|
239
|
+
title
|
|
240
|
+
number
|
|
241
|
+
url
|
|
242
|
+
field(name: "${escapedFieldName}") {
|
|
243
|
+
... on ProjectV2SingleSelectField {
|
|
244
|
+
id
|
|
245
|
+
name
|
|
246
|
+
options { id name }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
fieldValues(first: 30) {
|
|
251
|
+
nodes {
|
|
252
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
253
|
+
name
|
|
254
|
+
field {
|
|
255
|
+
... on ProjectV2SingleSelectField { name }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}`;
|
|
265
|
+
let raw;
|
|
266
|
+
try {
|
|
267
|
+
raw = (await runGhGraphql(query, [
|
|
268
|
+
["owner", owner],
|
|
269
|
+
["repo", name],
|
|
270
|
+
["number", issueNumber],
|
|
271
|
+
]));
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
return interpretGhError(err);
|
|
275
|
+
}
|
|
276
|
+
const issue = raw?.data?.repository?.issue;
|
|
277
|
+
if (!issue)
|
|
278
|
+
return { kind: "skip-no-issue" };
|
|
279
|
+
let nodes = issue.projectItems.nodes;
|
|
280
|
+
if (nodes.length === 0)
|
|
281
|
+
return { kind: "skip-no-project" };
|
|
282
|
+
// Honour an explicit project URL in the config before doing anything else.
|
|
283
|
+
if (cfg.url) {
|
|
284
|
+
const targetNumber = parseProjectNumberFromUrl(cfg.url);
|
|
285
|
+
nodes = nodes.filter((n) => n.project.url === cfg.url || (targetNumber !== null && n.project.number === targetNumber));
|
|
286
|
+
if (nodes.length === 0)
|
|
287
|
+
return { kind: "skip-no-project" };
|
|
288
|
+
}
|
|
289
|
+
const withField = nodes.filter((n) => n.project.field !== null);
|
|
290
|
+
if (withField.length === 0) {
|
|
291
|
+
return { kind: "skip-no-status-field", projects: nodes.map((n) => n.project.title) };
|
|
292
|
+
}
|
|
293
|
+
const withOption = withField.filter((n) => n.project.field.options.some((o) => o.name === inProgressOptionName));
|
|
294
|
+
if (withOption.length === 0) {
|
|
295
|
+
return {
|
|
296
|
+
kind: "skip-no-in-progress-option",
|
|
297
|
+
projects: withField.map((n) => n.project.title),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (withOption.length > 1) {
|
|
301
|
+
return { kind: "skip-ambiguous", projects: withOption.map((n) => n.project.title) };
|
|
302
|
+
}
|
|
303
|
+
const item = withOption[0];
|
|
304
|
+
const project = item.project;
|
|
305
|
+
const field = project.field;
|
|
306
|
+
const option = field.options.find((o) => o.name === inProgressOptionName);
|
|
307
|
+
if (!option) {
|
|
308
|
+
// Defensive — already filtered above, but TypeScript can't see it.
|
|
309
|
+
return { kind: "skip-no-in-progress-option", projects: [project.title] };
|
|
310
|
+
}
|
|
311
|
+
const currentStatus = item.fieldValues.nodes.find((v) => v.field?.name === statusFieldName)?.name ?? null;
|
|
312
|
+
if (currentStatus === inProgressOptionName) {
|
|
313
|
+
return { kind: "noop-already", projectTitle: project.title };
|
|
314
|
+
}
|
|
315
|
+
if (currentStatus !== null && protectedStatuses.includes(currentStatus)) {
|
|
316
|
+
return { kind: "noop-protected", projectTitle: project.title, current: currentStatus };
|
|
317
|
+
}
|
|
318
|
+
const mutation = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
319
|
+
updateProjectV2ItemFieldValue(input: {
|
|
320
|
+
projectId: $projectId
|
|
321
|
+
itemId: $itemId
|
|
322
|
+
fieldId: $fieldId
|
|
323
|
+
value: { singleSelectOptionId: $optionId }
|
|
324
|
+
}) {
|
|
325
|
+
projectV2Item { id }
|
|
326
|
+
}
|
|
327
|
+
}`;
|
|
328
|
+
try {
|
|
329
|
+
await runGhGraphql(mutation, [
|
|
330
|
+
["projectId", project.id],
|
|
331
|
+
["itemId", item.id],
|
|
332
|
+
["fieldId", field.id],
|
|
333
|
+
["optionId", option.id],
|
|
334
|
+
]);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
return interpretGhError(err);
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
kind: "transitioned",
|
|
341
|
+
projectTitle: project.title,
|
|
342
|
+
from: currentStatus,
|
|
343
|
+
to: inProgressOptionName,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Returns the gh CLI token scopes for github.com, or null when `gh` can't be
|
|
349
|
+
* called / the user isn't authenticated. `gh auth status` writes the scopes
|
|
350
|
+
* line to stderr; we capture both streams and grep for it.
|
|
351
|
+
*
|
|
352
|
+
* Kept as a standalone export (not part of IssueProvider) because it's
|
|
353
|
+
* consumed by doctor for the Project v2 scope row — a doctor-side concern,
|
|
354
|
+
* not part of the runtime issue flow.
|
|
355
|
+
*/
|
|
356
|
+
export async function getGhTokenScopes() {
|
|
357
|
+
try {
|
|
358
|
+
const { stdout, stderr } = await execFileAsync("gh", ["auth", "status"]);
|
|
359
|
+
const combined = `${stdout}\n${stderr}`;
|
|
360
|
+
return parseScopesFromAuthStatus(combined);
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
const out = err && typeof err === "object" && "stdout" in err && "stderr" in err
|
|
364
|
+
? `${String(err.stdout)}\n${String(err.stderr)}`
|
|
365
|
+
: "";
|
|
366
|
+
const parsed = parseScopesFromAuthStatus(out);
|
|
367
|
+
return parsed ?? null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function parseScopesFromAuthStatus(text) {
|
|
371
|
+
const m = text.match(/Token scopes:\s*([^\n]+)/i);
|
|
372
|
+
if (!m || !m[1])
|
|
373
|
+
return null;
|
|
374
|
+
return m[1]
|
|
375
|
+
.split(",")
|
|
376
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
|
377
|
+
.filter(Boolean);
|
|
378
|
+
}
|
|
379
|
+
export function hasProjectScope(scopes) {
|
|
380
|
+
return scopes.some((s) => s === "project" || s === "write:project");
|
|
381
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory + shared UI helpers for the issue providers. Callers ask for a
|
|
3
|
+
* provider given the repo's metadata; the factory picks the implementation
|
|
4
|
+
* based on `metadata.provider` (defaults to github for back-compat). All
|
|
5
|
+
* other code talks to `IssueProvider` without caring which backend is in
|
|
6
|
+
* use.
|
|
7
|
+
*/
|
|
8
|
+
import type { IssueProvider, TransitionResult } from "./types.js";
|
|
9
|
+
/**
|
|
10
|
+
* Returns the IssueProvider for this repo. Reads metadata.provider — when
|
|
11
|
+
* omitted (i.e. repos initialised before the provider field existed) we
|
|
12
|
+
* default to github so the change is invisible to existing users.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createProvider(repoRoot: string): IssueProvider;
|
|
15
|
+
/**
|
|
16
|
+
* Maps a TransitionResult to a UI-renderable row (icon kind + label +
|
|
17
|
+
* optional detail). Provider-agnostic — both providers produce the same
|
|
18
|
+
* TransitionResult shape — so the dashboard and create command share this
|
|
19
|
+
* single mapping.
|
|
20
|
+
*/
|
|
21
|
+
export declare function describeTransition(result: TransitionResult): {
|
|
22
|
+
kind: "ok" | "skip" | "warn";
|
|
23
|
+
label: string;
|
|
24
|
+
detail?: string;
|
|
25
|
+
};
|
|
26
|
+
export type { IssueProvider, TransitionResult } from "./types.js";
|
|
27
|
+
export type { ProviderIssue, IssueProjectInfo, IssueId } from "./types.js";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory + shared UI helpers for the issue providers. Callers ask for a
|
|
3
|
+
* provider given the repo's metadata; the factory picks the implementation
|
|
4
|
+
* based on `metadata.provider` (defaults to github for back-compat). All
|
|
5
|
+
* other code talks to `IssueProvider` without caring which backend is in
|
|
6
|
+
* use.
|
|
7
|
+
*/
|
|
8
|
+
import { readMetadata } from "../metadata.js";
|
|
9
|
+
import { GithubProvider } from "./github.js";
|
|
10
|
+
import { PlaneProvider } from "./plane.js";
|
|
11
|
+
/**
|
|
12
|
+
* Returns the IssueProvider for this repo. Reads metadata.provider — when
|
|
13
|
+
* omitted (i.e. repos initialised before the provider field existed) we
|
|
14
|
+
* default to github so the change is invisible to existing users.
|
|
15
|
+
*/
|
|
16
|
+
export function createProvider(repoRoot) {
|
|
17
|
+
const metadata = readMetadata(repoRoot);
|
|
18
|
+
const kind = metadata.provider ?? "github";
|
|
19
|
+
switch (kind) {
|
|
20
|
+
case "github":
|
|
21
|
+
return new GithubProvider(repoRoot);
|
|
22
|
+
case "plane":
|
|
23
|
+
return new PlaneProvider(repoRoot);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Maps a TransitionResult to a UI-renderable row (icon kind + label +
|
|
28
|
+
* optional detail). Provider-agnostic — both providers produce the same
|
|
29
|
+
* TransitionResult shape — so the dashboard and create command share this
|
|
30
|
+
* single mapping.
|
|
31
|
+
*/
|
|
32
|
+
export function describeTransition(result) {
|
|
33
|
+
switch (result.kind) {
|
|
34
|
+
case "transitioned":
|
|
35
|
+
return {
|
|
36
|
+
kind: "ok",
|
|
37
|
+
label: `issue → ${result.to}`,
|
|
38
|
+
detail: result.from ? `${result.projectTitle} (was: ${result.from})` : result.projectTitle,
|
|
39
|
+
};
|
|
40
|
+
case "noop-already":
|
|
41
|
+
return {
|
|
42
|
+
kind: "skip",
|
|
43
|
+
label: "issue already In Progress",
|
|
44
|
+
detail: result.projectTitle,
|
|
45
|
+
};
|
|
46
|
+
case "noop-protected":
|
|
47
|
+
return {
|
|
48
|
+
kind: "skip",
|
|
49
|
+
label: `issue kept at ${result.current}`,
|
|
50
|
+
detail: `${result.projectTitle} (status is protected)`,
|
|
51
|
+
};
|
|
52
|
+
case "skip-no-repo":
|
|
53
|
+
return { kind: "skip", label: "no GitHub repo — skipping project update" };
|
|
54
|
+
case "skip-no-issue":
|
|
55
|
+
return { kind: "skip", label: "issue not found — skipping project update" };
|
|
56
|
+
case "skip-no-project":
|
|
57
|
+
return { kind: "skip", label: "issue not on any project — skipping project update" };
|
|
58
|
+
case "skip-ambiguous":
|
|
59
|
+
return {
|
|
60
|
+
kind: "warn",
|
|
61
|
+
label: "multiple matching projects — skipping",
|
|
62
|
+
detail: `set .mintree/metadata.json project.url to one of: ${result.projects.join(", ")}`,
|
|
63
|
+
};
|
|
64
|
+
case "skip-no-status-field":
|
|
65
|
+
return {
|
|
66
|
+
kind: "skip",
|
|
67
|
+
label: "no Status field on project — skipping",
|
|
68
|
+
detail: result.projects.join(", "),
|
|
69
|
+
};
|
|
70
|
+
case "skip-no-in-progress-option":
|
|
71
|
+
return {
|
|
72
|
+
kind: "skip",
|
|
73
|
+
label: "no In Progress option on Status field — skipping",
|
|
74
|
+
detail: result.projects.join(", "),
|
|
75
|
+
};
|
|
76
|
+
case "error":
|
|
77
|
+
return {
|
|
78
|
+
kind: "warn",
|
|
79
|
+
label: "project update failed",
|
|
80
|
+
detail: result.hint ? `${result.message} — ${result.hint}` : result.message,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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 type { IssueId, IssueProjectInfo, IssueProvider, ProviderIssue, TransitionResult } from "./types.js";
|
|
24
|
+
export declare class PlaneProvider implements IssueProvider {
|
|
25
|
+
private readonly repoRoot;
|
|
26
|
+
readonly kind: "plane";
|
|
27
|
+
private cachedUserId;
|
|
28
|
+
private workItemsByProject;
|
|
29
|
+
private statesByProject;
|
|
30
|
+
constructor(repoRoot: string);
|
|
31
|
+
private getConfig;
|
|
32
|
+
private getUserId;
|
|
33
|
+
private fetchAssignedWorkItems;
|
|
34
|
+
private fetchStates;
|
|
35
|
+
listAssignedIssues(): Promise<ProviderIssue[] | null>;
|
|
36
|
+
fetchProjectAssignments(): Promise<Map<IssueId, IssueProjectInfo> | null>;
|
|
37
|
+
transitionIssueToInProgress(issueId: IssueId): Promise<TransitionResult>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Doctor-side snapshot of the Plane integration's health. Bundles the API-
|
|
41
|
+
* key resolution, `/users/me/` ping, and a per-configured-project
|
|
42
|
+
* existence check into one async call so the doctor row can render
|
|
43
|
+
* everything in one pass. Returns a structured result (not a boolean) so
|
|
44
|
+
* the UI can surface specific hints instead of generic failures.
|
|
45
|
+
*/
|
|
46
|
+
export type PlaneSetupCheck = {
|
|
47
|
+
configured: boolean;
|
|
48
|
+
hasApiKey: boolean;
|
|
49
|
+
authOk: boolean;
|
|
50
|
+
user?: string;
|
|
51
|
+
workspaceSlug?: string;
|
|
52
|
+
apiUrl?: string;
|
|
53
|
+
projects: Array<{
|
|
54
|
+
identifier: string;
|
|
55
|
+
id: string;
|
|
56
|
+
ok: boolean;
|
|
57
|
+
error?: string;
|
|
58
|
+
}>;
|
|
59
|
+
hint?: string;
|
|
60
|
+
};
|
|
61
|
+
export declare function checkPlaneSetup(repoRoot: string): Promise<PlaneSetupCheck>;
|