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.
@@ -1,43 +1,22 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { execFile } from "child_process";
4
- import { promisify } from "util";
5
- import { tryExec } from "./exec.js";
6
3
  import { listWorktrees, getWorktreesDir, isDirty, getAheadBehind, } from "./git.js";
7
4
  import { readMetadata } from "./metadata.js";
8
- import { getRepoFullName } from "./github.js";
9
- const execFileAsync = promisify(execFile);
10
- const ISSUE_LIST_LIMIT = 50;
5
+ import { fetchPrForBranch } from "./pr.js";
6
+ import { createProvider } from "./providers/index.js";
11
7
  /**
12
- * Fetches open issues assigned to the authenticated GitHub user for the
13
- * current cwd's repo. Returns null when `gh` isn't authenticated, the cwd
14
- * isn't a GitHub repo, or the API call fails the caller surfaces the
15
- * appropriate hint.
16
- */
17
- export async function fetchAssignedIssues() {
18
- 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`);
19
- if (!json)
20
- return null;
21
- try {
22
- const parsed = JSON.parse(json);
23
- if (!Array.isArray(parsed))
24
- return null;
25
- return parsed;
26
- }
27
- catch {
28
- return null;
29
- }
30
- }
31
- /**
32
- * Builds a map from issue id (number, as string) to the matching mintree
33
- * worktree. IssueId comes from the worktree dir name (`<issue>-<desc>`)
34
- * rather than the branch, so detached worktrees (created via the dashboard's
35
- * "current branch" mode) are included alongside the regular branch-based
36
- * ones. Worktrees outside `.mintree/worktrees/` are skipped.
8
+ * Builds a map from issue id (the canonical string "100" on GitHub,
9
+ * "BACK-100" on Plane once that lands) to the matching mintree worktree.
10
+ * IssueId comes from the worktree dir name (`<issue>-<desc>`) rather than
11
+ * the branch, so detached worktrees (created via the dashboard's "current
12
+ * branch" mode) are included alongside the regular branch-based ones.
13
+ * Worktrees outside `.mintree/worktrees/` are skipped.
37
14
  */
38
15
  function buildWorktreeIndex(repoRoot) {
39
16
  const worktreesRoot = path.resolve(getWorktreesDir(repoRoot));
40
- const dirNameRegex = /^(\d+)-/;
17
+ // Same shape as the BRANCH_REGEX issueId capture: bare digits (GitHub) or
18
+ // `<PROJ>-\d+` (Plane). Matches `100-foo` and `BACK-100-foo` alike.
19
+ const dirNameRegex = /^((?:[A-Z][A-Z0-9_]*-)?\d+)-/;
41
20
  const index = new Map();
42
21
  for (const w of listWorktrees(repoRoot)) {
43
22
  const wAbs = path.resolve(w.path);
@@ -84,153 +63,6 @@ function readSessionState(repoRoot, issueId) {
84
63
  function isSessionState(v) {
85
64
  return v === "active" || v === "idle" || v === "waiting" || v === "exited";
86
65
  }
87
- function shQuote(value) {
88
- return `'${value.replace(/'/g, `'\\''`)}'`;
89
- }
90
- /**
91
- * Looks up the most recent PR for a branch (any state). Returns null when
92
- * there's no PR or `gh` can't reach the API. Used to populate the detail
93
- * pane's "Pull Request" section.
94
- */
95
- async function fetchPrForBranch(branch) {
96
- const out = await tryExec(`gh pr list --head ${shQuote(branch)} --state all --json number,state,url --limit 1 2>/dev/null`);
97
- if (!out)
98
- return null;
99
- try {
100
- const arr = JSON.parse(out);
101
- if (Array.isArray(arr) && arr.length > 0 && arr[0])
102
- return arr[0];
103
- }
104
- catch {
105
- // fall through
106
- }
107
- return null;
108
- }
109
- // GitHub Projects v2 single-select options carry their own colour enum.
110
- // Map each to the closest Ink/chalk colour; ORANGE and PINK have no 16-colour
111
- // keyword so they use hex (truecolor terminals render them, others approximate).
112
- const PROJECT_STATUS_COLORS = {
113
- GRAY: "gray",
114
- BLUE: "blue",
115
- GREEN: "green",
116
- YELLOW: "yellow",
117
- ORANGE: "#d18616",
118
- RED: "red",
119
- PINK: "#d2a8ff",
120
- PURPLE: "magenta",
121
- };
122
- const STATUS_ORDER_UNSET = 999;
123
- function parseProjectNumberFromUrl(url) {
124
- const m = url.match(/\/projects\/(\d+)/);
125
- return m && m[1] ? Number(m[1]) : null;
126
- }
127
- /**
128
- * Runs a GraphQL query via `gh api graphql`. Returns null on any failure
129
- * (gh not authenticated, missing scope, network) — the caller degrades
130
- * gracefully to an ungrouped list.
131
- */
132
- async function ghGraphql(query) {
133
- try {
134
- const { stdout } = await execFileAsync("gh", ["api", "graphql", "-f", `query=${query}`]);
135
- return JSON.parse(stdout);
136
- }
137
- catch {
138
- return null;
139
- }
140
- }
141
- /**
142
- * Picks which project board an issue belongs to for grouping purposes. When
143
- * `.mintree/metadata.json` pins a project URL, only that board counts;
144
- * otherwise the first board the issue appears on wins.
145
- */
146
- function pickProjectNode(nodes, configuredUrl) {
147
- if (nodes.length === 0)
148
- return null;
149
- if (configuredUrl) {
150
- const targetNumber = parseProjectNumberFromUrl(configuredUrl);
151
- return (nodes.find((n) => n.project?.url === configuredUrl ||
152
- (targetNumber !== null && n.project?.number === targetNumber)) ?? null);
153
- }
154
- return nodes[0] ?? null;
155
- }
156
- function toProjectInfo(node) {
157
- const proj = node.project;
158
- if (!proj)
159
- return null;
160
- const options = proj.field?.options ?? [];
161
- const status = node.fieldValueByName?.name ?? null;
162
- const optionIndex = status ? options.findIndex((o) => o.name === status) : -1;
163
- const option = optionIndex >= 0 ? options[optionIndex] : undefined;
164
- return {
165
- projectTitle: proj.title ?? "(untitled project)",
166
- projectUrl: proj.url ?? "",
167
- projectNumber: proj.number ?? 0,
168
- status,
169
- statusColor: option?.color
170
- ? (PROJECT_STATUS_COLORS[option.color] ?? "yellow")
171
- : status
172
- ? "yellow"
173
- : "gray",
174
- statusOrder: optionIndex >= 0 ? optionIndex : STATUS_ORDER_UNSET,
175
- };
176
- }
177
- /**
178
- * Fetches, in a single GraphQL round-trip, which Projects v2 board (and
179
- * Status value) each open assigned issue belongs to. Returns an empty map
180
- * when the lookup fails — the dashboard then renders an ungrouped list.
181
- */
182
- async function fetchProjectAssignments(statusFieldName, configuredUrl) {
183
- const result = new Map();
184
- const repo = await getRepoFullName();
185
- if (!repo)
186
- return result;
187
- // The Status field name is interpolated into the query (not a variable)
188
- // because it appears as a field argument; escape embedded quotes.
189
- const escapedField = statusFieldName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
190
- const searchQuery = `repo:${repo} is:issue is:open assignee:@me`.replace(/"/g, '\\"');
191
- const query = `query {
192
- search(query: "${searchQuery}", type: ISSUE, first: ${ISSUE_LIST_LIMIT}) {
193
- nodes {
194
- ... on Issue {
195
- number
196
- projectItems(first: 10, includeArchived: false) {
197
- nodes {
198
- project {
199
- title
200
- number
201
- url
202
- field(name: "${escapedField}") {
203
- ... on ProjectV2SingleSelectField {
204
- options { name color }
205
- }
206
- }
207
- }
208
- fieldValueByName(name: "${escapedField}") {
209
- ... on ProjectV2ItemFieldSingleSelectValue { name }
210
- }
211
- }
212
- }
213
- }
214
- }
215
- }
216
- }`;
217
- const raw = (await ghGraphql(query));
218
- const nodes = raw?.data?.search?.nodes;
219
- if (!Array.isArray(nodes))
220
- return result;
221
- for (const node of nodes) {
222
- if (typeof node?.number !== "number")
223
- continue;
224
- const items = node.projectItems?.nodes ?? [];
225
- const picked = pickProjectNode(items, configuredUrl);
226
- if (!picked)
227
- continue;
228
- const info = toProjectInfo(picked);
229
- if (info)
230
- result.set(node.number, info);
231
- }
232
- return result;
233
- }
234
66
  /**
235
67
  * Orders the flat issue list so issues are contiguous by project, then by
236
68
  * Status (board column order), then newest issue first. The dashboard derives
@@ -260,7 +92,14 @@ function sortGroupedIssues(issues, configuredUrl) {
260
92
  return a.project.statusOrder - b.project.statusOrder;
261
93
  }
262
94
  }
263
- return b.issue.number - a.issue.number;
95
+ // Newest-first for issues — id is a numeric-or-prefixed string. Numeric
96
+ // compare falls back to localeCompare for non-numeric ids (Plane's
97
+ // "BACK-100" form).
98
+ const an = Number(a.issue.id);
99
+ const bn = Number(b.issue.id);
100
+ if (Number.isFinite(an) && Number.isFinite(bn))
101
+ return bn - an;
102
+ return b.issue.id.localeCompare(a.issue.id);
264
103
  });
265
104
  }
266
105
  /**
@@ -269,7 +108,8 @@ function sortGroupedIssues(issues, configuredUrl) {
269
108
  * `r` refresh — cheap because all the per-worktree probes are local.
270
109
  */
271
110
  export async function loadDashboard(repoRoot) {
272
- const issues = await fetchAssignedIssues();
111
+ const provider = createProvider(repoRoot);
112
+ const issues = await provider.listAssignedIssues();
273
113
  if (!issues)
274
114
  return null;
275
115
  const worktreesByIssue = buildWorktreeIndex(repoRoot);
@@ -288,24 +128,28 @@ export async function loadDashboard(repoRoot) {
288
128
  if (pr)
289
129
  prByBranch.set(w.branch, pr);
290
130
  });
291
- // Project membership comes from a single GraphQL query; fetch it alongside
292
- // the per-branch PR probes so neither blocks the other.
131
+ // Project membership comes from the provider in a single call; fetch it
132
+ // alongside the per-branch PR probes so neither blocks the other.
293
133
  const [, projectByIssue] = await Promise.all([
294
134
  Promise.all(prFetches),
295
- fetchProjectAssignments(projectCfg.statusField ?? "Status", configuredUrl),
135
+ provider.fetchProjectAssignments(),
296
136
  ]);
137
+ // Provider signals total failure (vs no projects configured) with null —
138
+ // treat as a partial load failure so the caller's resilient refresh
139
+ // keeps the last-good state instead of regressing to a flat list.
140
+ if (projectByIssue === null)
141
+ return null;
297
142
  const enriched = issues.map((issue) => {
298
- const issueId = String(issue.number);
299
- const worktreeRaw = worktreesByIssue.get(issueId) ?? null;
300
- const sessionId = metadata.issues[issueId]?.session_id;
143
+ const worktreeRaw = worktreesByIssue.get(issue.id) ?? null;
144
+ const sessionId = metadata.issues[issue.id]?.session_id;
301
145
  const worktree = worktreeRaw ? { ...worktreeRaw, sessionId } : null;
302
146
  const pr = worktree && worktree.branch ? (prByBranch.get(worktree.branch) ?? null) : null;
303
147
  return {
304
148
  issue,
305
149
  worktree,
306
- session: readSessionState(repoRoot, issueId),
150
+ session: readSessionState(repoRoot, issue.id),
307
151
  pr,
308
- project: projectByIssue.get(issue.number) ?? null,
152
+ project: projectByIssue.get(issue.id) ?? null,
309
153
  };
310
154
  });
311
155
  return sortGroupedIssues(enriched, configuredUrl);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Thin shell helpers around the `gh` CLI. These are deliberately
3
+ * provider-agnostic — even when mintree's issue provider is Plane, `gh` is
4
+ * still used to look up PR status for worktree branches, so doctor and a
5
+ * couple of dashboard surfaces need to know whether `gh` is reachable.
6
+ *
7
+ * The GitHub issue / Project v2 logic lives in `providers/github.ts` and
8
+ * uses these helpers internally; the Plane provider doesn't touch them.
9
+ */
10
+ export declare function ghCliAvailable(): Promise<boolean>;
11
+ export declare function getGhUserLogin(): Promise<string | null>;
12
+ /**
13
+ * Returns "owner/name" for the GitHub repo of the current working directory,
14
+ * or null if not a GitHub repo / `gh` can't reach the API.
15
+ */
16
+ export declare function getRepoFullName(): Promise<string | null>;
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Thin shell helpers around the `gh` CLI. These are deliberately
3
+ * provider-agnostic — even when mintree's issue provider is Plane, `gh` is
4
+ * still used to look up PR status for worktree branches, so doctor and a
5
+ * couple of dashboard surfaces need to know whether `gh` is reachable.
6
+ *
7
+ * The GitHub issue / Project v2 logic lives in `providers/github.ts` and
8
+ * uses these helpers internally; the Plane provider doesn't touch them.
9
+ */
1
10
  import { tryExec } from "./exec.js";
2
11
  export async function ghCliAvailable() {
3
12
  const out = await tryExec("which gh");
@@ -8,10 +8,25 @@ export type ProjectMeta = {
8
8
  inProgressOption?: string;
9
9
  protectedStatuses?: string[];
10
10
  };
11
+ export type ProviderKind = "github" | "plane";
12
+ export type PlaneProjectRef = {
13
+ id: string;
14
+ identifier: string;
15
+ name?: string;
16
+ };
17
+ export type PlaneMeta = {
18
+ apiUrl?: string;
19
+ workspaceSlug: string;
20
+ projects: PlaneProjectRef[];
21
+ inProgressStateName?: string;
22
+ protectedStateGroups?: string[];
23
+ };
11
24
  export type Metadata = {
12
25
  version: 1;
26
+ provider?: ProviderKind;
13
27
  issues: Record<string, IssueMeta>;
14
28
  project?: ProjectMeta;
29
+ plane?: PlaneMeta;
15
30
  };
16
31
  export declare function readMetadata(repoRoot: string): Metadata;
17
32
  export declare function writeMetadata(repoRoot: string, data: Metadata): void;
@@ -1,6 +1,53 @@
1
1
  import * as fs from "fs";
2
2
  import { getMetadataPath } from "./git.js";
3
3
  const EMPTY = { version: 1, issues: {} };
4
+ function sanitizeProvider(raw) {
5
+ if (raw === "github" || raw === "plane")
6
+ return raw;
7
+ return undefined;
8
+ }
9
+ function sanitizePlaneProject(raw) {
10
+ if (typeof raw !== "object" || raw === null)
11
+ return undefined;
12
+ const r = raw;
13
+ if (typeof r["id"] !== "string" || r["id"].length === 0)
14
+ return undefined;
15
+ if (typeof r["identifier"] !== "string" || r["identifier"].length === 0)
16
+ return undefined;
17
+ const out = { id: r["id"], identifier: r["identifier"] };
18
+ if (typeof r["name"] === "string" && r["name"].length > 0)
19
+ out.name = r["name"];
20
+ return out;
21
+ }
22
+ function sanitizePlane(raw) {
23
+ if (typeof raw !== "object" || raw === null)
24
+ return undefined;
25
+ const r = raw;
26
+ if (typeof r["workspaceSlug"] !== "string" || r["workspaceSlug"].length === 0)
27
+ return undefined;
28
+ const projectsRaw = Array.isArray(r["projects"]) ? r["projects"] : [];
29
+ const projects = [];
30
+ for (const p of projectsRaw) {
31
+ const sanitized = sanitizePlaneProject(p);
32
+ if (sanitized)
33
+ projects.push(sanitized);
34
+ }
35
+ const out = {
36
+ workspaceSlug: r["workspaceSlug"],
37
+ projects,
38
+ };
39
+ if (typeof r["apiUrl"] === "string" && r["apiUrl"].length > 0)
40
+ out.apiUrl = r["apiUrl"];
41
+ if (typeof r["inProgressStateName"] === "string" && r["inProgressStateName"].length > 0) {
42
+ out.inProgressStateName = r["inProgressStateName"];
43
+ }
44
+ if (Array.isArray(r["protectedStateGroups"])) {
45
+ const arr = r["protectedStateGroups"].filter((v) => typeof v === "string" && v.length > 0);
46
+ if (arr.length > 0)
47
+ out.protectedStateGroups = arr;
48
+ }
49
+ return out;
50
+ }
4
51
  function sanitizeProject(raw) {
5
52
  if (typeof raw !== "object" || raw === null)
6
53
  return undefined;
@@ -30,12 +77,16 @@ export function readMetadata(repoRoot) {
30
77
  if (typeof parsed !== "object" || parsed === null)
31
78
  return { ...EMPTY, issues: {} };
32
79
  const project = sanitizeProject(parsed.project);
80
+ const provider = sanitizeProvider(parsed.provider);
81
+ const plane = sanitizePlane(parsed.plane);
33
82
  return {
34
83
  version: 1,
35
84
  issues: typeof parsed.issues === "object" && parsed.issues !== null
36
85
  ? parsed.issues
37
86
  : {},
87
+ ...(provider ? { provider } : {}),
38
88
  ...(project ? { project } : {}),
89
+ ...(plane ? { plane } : {}),
39
90
  };
40
91
  }
41
92
  catch {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared `gh pr list` helpers. The dashboard, `worktree list --pr`, and
3
+ * `worktree clean` all need to look up the PR status of a branch, with the
4
+ * same `gh pr list --head <branch>` shape. Centralising them here avoids
5
+ * three copies of the shell-quote + JSON-parse dance going out of sync.
6
+ *
7
+ * PR detection stays gh-only even when the issue provider is Plane —
8
+ * mintree's worktree branches live on GitHub, and Plane has no concept of
9
+ * git PRs. Callers that aren't sure whether `gh` is available pass through
10
+ * `tryExec`-style failures as `null`, so the dashboard degrades to "no PR"
11
+ * rows instead of erroring.
12
+ */
13
+ export type PrState = "OPEN" | "CLOSED" | "MERGED";
14
+ export type PrInfo = {
15
+ number: number;
16
+ state: PrState;
17
+ url?: string;
18
+ };
19
+ /**
20
+ * Looks up the most recent PR for a branch (any state). Returns null when
21
+ * there's no PR or `gh` can't reach the API. `withUrl` controls whether the
22
+ * URL field is requested — dashboard wants it for display, list/clean don't.
23
+ */
24
+ export declare function fetchPrForBranch(branch: string, { withUrl }?: {
25
+ withUrl?: boolean;
26
+ }): Promise<PrInfo | null>;
package/dist/lib/pr.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared `gh pr list` helpers. The dashboard, `worktree list --pr`, and
3
+ * `worktree clean` all need to look up the PR status of a branch, with the
4
+ * same `gh pr list --head <branch>` shape. Centralising them here avoids
5
+ * three copies of the shell-quote + JSON-parse dance going out of sync.
6
+ *
7
+ * PR detection stays gh-only even when the issue provider is Plane —
8
+ * mintree's worktree branches live on GitHub, and Plane has no concept of
9
+ * git PRs. Callers that aren't sure whether `gh` is available pass through
10
+ * `tryExec`-style failures as `null`, so the dashboard degrades to "no PR"
11
+ * rows instead of erroring.
12
+ */
13
+ import { tryExec } from "./exec.js";
14
+ function shQuote(value) {
15
+ return `'${value.replace(/'/g, `'\\''`)}'`;
16
+ }
17
+ function normaliseState(s) {
18
+ const u = s.toUpperCase();
19
+ if (u === "OPEN" || u === "CLOSED" || u === "MERGED")
20
+ return u;
21
+ return null;
22
+ }
23
+ /**
24
+ * Looks up the most recent PR for a branch (any state). Returns null when
25
+ * there's no PR or `gh` can't reach the API. `withUrl` controls whether the
26
+ * URL field is requested — dashboard wants it for display, list/clean don't.
27
+ */
28
+ export async function fetchPrForBranch(branch, { withUrl = true } = {}) {
29
+ const fields = withUrl ? "number,state,url" : "number,state";
30
+ const out = await tryExec(`gh pr list --head ${shQuote(branch)} --state all --json ${fields} --limit 1 2>/dev/null`);
31
+ if (!out)
32
+ return null;
33
+ try {
34
+ const arr = JSON.parse(out);
35
+ if (!Array.isArray(arr) || arr.length === 0 || !arr[0])
36
+ return null;
37
+ const first = arr[0];
38
+ const state = normaliseState(first.state);
39
+ if (!state)
40
+ return null;
41
+ const result = { number: first.number, state };
42
+ if (withUrl && first.url)
43
+ result.url = first.url;
44
+ return result;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
@@ -0,0 +1,33 @@
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 type { IssueId, IssueProjectInfo, IssueProvider, ProviderIssue, TransitionResult } from "./types.js";
14
+ export declare class GithubProvider implements IssueProvider {
15
+ private readonly repoRoot;
16
+ readonly kind: "github";
17
+ constructor(repoRoot: string);
18
+ private readProjectConfig;
19
+ listAssignedIssues(): Promise<ProviderIssue[] | null>;
20
+ fetchProjectAssignments(): Promise<Map<IssueId, IssueProjectInfo> | null>;
21
+ transitionIssueToInProgress(issueId: IssueId): Promise<TransitionResult>;
22
+ }
23
+ /**
24
+ * Returns the gh CLI token scopes for github.com, or null when `gh` can't be
25
+ * called / the user isn't authenticated. `gh auth status` writes the scopes
26
+ * line to stderr; we capture both streams and grep for it.
27
+ *
28
+ * Kept as a standalone export (not part of IssueProvider) because it's
29
+ * consumed by doctor for the Project v2 scope row — a doctor-side concern,
30
+ * not part of the runtime issue flow.
31
+ */
32
+ export declare function getGhTokenScopes(): Promise<string[] | null>;
33
+ export declare function hasProjectScope(scopes: string[]): boolean;