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.
@@ -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>;