mintree 0.1.11 → 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.
@@ -6,8 +6,10 @@ import * as fs from "fs";
6
6
  import * as path from "path";
7
7
  import { createRequire } from "module";
8
8
  import { tryExec, getPath } from "../lib/exec.js";
9
- import { ghCliAvailable, getGhUserLogin, getRepoFullName } from "../lib/github.js";
10
- import { getGhTokenScopes, hasProjectScope } from "../lib/githubProject.js";
9
+ import { ghCliAvailable, getGhUserLogin, getRepoFullName } from "../lib/gh.js";
10
+ import { getGhTokenScopes, hasProjectScope } from "../lib/providers/github.js";
11
+ import { checkPlaneSetup } from "../lib/providers/plane.js";
12
+ import { readMetadata } from "../lib/metadata.js";
11
13
  import { resolveClaudeBinary } from "../lib/claude.js";
12
14
  import { findMainRepoRoot, getMintreeDir, getInitScriptPath, isGitIgnored, isExecutable, pathExists, } from "../lib/git.js";
13
15
  const require = createRequire(import.meta.url);
@@ -49,13 +51,17 @@ async function checkClaude() {
49
51
  path: resolved,
50
52
  };
51
53
  }
52
- async function checkGh() {
54
+ async function checkGh(provider) {
55
+ // When provider=plane, gh is only used for PR detection on worktree
56
+ // branches — still useful, but not strictly required for the issue flow.
57
+ const description = provider === "plane" ? "GitHub CLI (for PR status on worktrees)" : "GitHub CLI for issues + PRs";
58
+ const required = provider !== "plane";
53
59
  const binPath = await getPath("gh");
54
60
  if (!binPath) {
55
61
  return {
56
62
  name: "gh",
57
- description: "GitHub CLI for issues + PRs",
58
- required: true,
63
+ description,
64
+ required,
59
65
  installed: false,
60
66
  hint: "Install: brew install gh && gh auth login",
61
67
  };
@@ -65,8 +71,8 @@ async function checkGh() {
65
71
  if (!login) {
66
72
  return {
67
73
  name: "gh",
68
- description: "GitHub CLI for issues + PRs",
69
- required: true,
74
+ description,
75
+ required,
70
76
  installed: true,
71
77
  version: ver || "unknown",
72
78
  path: binPath,
@@ -75,8 +81,8 @@ async function checkGh() {
75
81
  }
76
82
  return {
77
83
  name: "gh",
78
- description: "GitHub CLI for issues + PRs",
79
- required: true,
84
+ description,
85
+ required,
80
86
  installed: true,
81
87
  version: ver || "unknown",
82
88
  path: binPath,
@@ -263,6 +269,11 @@ function ProjectScopeRow({ status }) {
263
269
  }
264
270
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.hasScope, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "GitHub Project v2 Scope" }), _jsx(Text, { dimColor: true, children: " - lets `w` move the issue to In Progress" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Token scopes: ", status.scopes.join(", ") || "(none)"] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })] }));
265
271
  }
272
+ function PlaneRow({ status }) {
273
+ const ok = status.configured && status.hasApiKey && status.authOk && status.projects.length > 0 && status.projects.every((p) => p.ok);
274
+ const required = status.configured;
275
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: ok, required: required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Plane" }), _jsx(Text, { dimColor: true, children: " - issue listing + In Progress transition" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["API key: ", status.hasApiKey ? "loaded" : "missing", status.authOk && status.user ? ` · user: ${status.user}` : ""] }), status.workspaceSlug && (_jsxs(Text, { dimColor: true, children: ["Workspace: ", status.workspaceSlug, status.apiUrl ? ` (${status.apiUrl})` : ""] })), status.projects.length > 0 ? (status.projects.map((p) => (_jsxs(Text, { dimColor: true, children: [p.ok ? "✓" : "✗", " project ", p.identifier, " (", p.id.slice(0, 8), "\u2026)", p.error ? ` — ${p.error}` : ""] }, p.id)))) : (_jsx(Text, { dimColor: true, children: "No projects configured" })), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })] }));
276
+ }
266
277
  function GithubIssuesRow({ gh }) {
267
278
  // Required only when we're inside a git repo. Outside one, the row is
268
279
  // purely informational (auth check) so doctor can stay green when run
@@ -299,15 +310,25 @@ export default function Doctor() {
299
310
  const [tools, setTools] = useState(null);
300
311
  const [gh, setGh] = useState(null);
301
312
  const [projectScope, setProjectScope] = useState(null);
313
+ const [plane, setPlane] = useState(null);
302
314
  const [rc, setRc] = useState(null);
303
315
  const [hooks, setHooks] = useState(null);
304
316
  const [setup, setSetup] = useState(null);
305
317
  const [shell, setShell] = useState(null);
318
+ // Provider drives which integration rows appear + tweaks the gh row's
319
+ // description/required flag. Read once on mount; doctor doesn't react to
320
+ // metadata changes mid-run.
321
+ const [provider, setProvider] = useState(null);
306
322
  useEffect(() => {
307
323
  (async () => {
324
+ const root = findMainRepoRoot();
325
+ const resolvedProvider = root
326
+ ? (readMetadata(root).provider ?? "github")
327
+ : "github";
328
+ setProvider(resolvedProvider);
308
329
  const toolResults = await Promise.all([
309
330
  checkTool("git", "Version control", true, "git --version | head -1", "Install: brew install git"),
310
- checkGh(),
331
+ checkGh(resolvedProvider),
311
332
  checkClaude(),
312
333
  checkTool("tmux", "Open worktrees in separate windows", false, "tmux -V", "Install: brew install tmux"),
313
334
  ]);
@@ -327,28 +348,50 @@ export default function Doctor() {
327
348
  version: process.version,
328
349
  };
329
350
  toolResults.unshift(nodeRow);
330
- const ghRes = await checkGithubIssues();
331
- const projectScopeRes = await checkProjectScope();
351
+ // GH-specific probes only matter when provider=github. For plane we
352
+ // still need *some* value in state so the loading guard resolves, but
353
+ // the row is hidden — populate with a default and skip the network.
354
+ const ghRes = resolvedProvider === "github"
355
+ ? await checkGithubIssues()
356
+ : { authenticated: false, inGitRepo: false };
357
+ const projectScopeRes = resolvedProvider === "github"
358
+ ? await checkProjectScope()
359
+ : { scopes: null, hasScope: false };
360
+ // Plane probes only run when provider=plane. Always set state so the
361
+ // loading guard resolves.
362
+ const planeRes = resolvedProvider === "plane" && root
363
+ ? await checkPlaneSetup(root)
364
+ : { configured: false, hasApiKey: false, authOk: false, projects: [] };
332
365
  setTools(toolResults);
333
366
  setGh(ghRes);
334
367
  setProjectScope(projectScopeRes);
368
+ setPlane(planeRes);
335
369
  setRc(checkRemoteControl());
336
370
  setHooks(checkSessionSignalHooks());
337
371
  setSetup(checkMintreeSetup());
338
372
  setShell(checkShellIntegration());
339
373
  })();
340
374
  }, []);
341
- const loading = !tools || !gh || !projectScope || !rc || !hooks || !setup || !shell;
375
+ const loading = !tools || !gh || !projectScope || !plane || !rc || !hooks || !setup || !shell || !provider;
342
376
  if (loading) {
343
377
  return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Checking system requirements..." })] }));
344
378
  }
345
379
  const requiredMissing = tools.filter((t) => t.required && (!t.installed || t.hint));
346
380
  const optionalMissing = tools.filter((t) => !t.required && !t.installed);
347
- // GitHub Issues only counts toward the required tally when we're inside a
348
- // git repo; otherwise the auth-only check is purely informational.
349
- const ghOk = gh.inGitRepo ? gh.authenticated && !!gh.repoName : true;
381
+ // Provider-specific OK check: when provider=github, the GH integration row
382
+ // must pass (auth + repo); when provider=plane, the Plane row must pass
383
+ // (api key + auth + at least one reachable project).
384
+ const providerOk = provider === "plane"
385
+ ? plane.configured &&
386
+ plane.hasApiKey &&
387
+ plane.authOk &&
388
+ plane.projects.length > 0 &&
389
+ plane.projects.every((p) => p.ok)
390
+ : gh.inGitRepo
391
+ ? gh.authenticated && !!gh.repoName
392
+ : true;
350
393
  const shellOk = shell.configured;
351
- const allRequired = requiredMissing.length === 0 && ghOk && shellOk;
352
- const requiredFailing = requiredMissing.length + (ghOk ? 0 : 1) + (shellOk ? 0 : 1);
353
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Mintree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((t) => (_jsx(ToolRow, { tool: t }, t.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), _jsx(GithubIssuesRow, { gh: gh }), _jsx(ProjectScopeRow, { status: projectScope }), _jsx(ShellRow, { status: shell }), _jsx(MintreeSetupRow, { status: setup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), _jsx(RemoteControlRow, { status: rc }), _jsx(SessionSignalRow, { status: hooks }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All required checks pass. mintree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredFailing, " required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
394
+ const allRequired = requiredMissing.length === 0 && providerOk && shellOk;
395
+ const requiredFailing = requiredMissing.length + (providerOk ? 0 : 1) + (shellOk ? 0 : 1);
396
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Mintree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((t) => (_jsx(ToolRow, { tool: t }, t.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), provider === "plane" ? (_jsx(PlaneRow, { status: plane })) : (_jsxs(_Fragment, { children: [_jsx(GithubIssuesRow, { gh: gh }), _jsx(ProjectScopeRow, { status: projectScope })] })), _jsx(ShellRow, { status: shell }), _jsx(MintreeSetupRow, { status: setup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), _jsx(RemoteControlRow, { status: rc }), _jsx(SessionSignalRow, { status: hooks }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All required checks pass. mintree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredFailing, " required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
354
397
  }
@@ -1,2 +1,15 @@
1
+ import { z } from "zod";
1
2
  export declare const description = "Initialize the current repo for mintree (creates .mintree/, updates .gitignore)";
2
- export default function Init(): import("react/jsx-runtime").JSX.Element;
3
+ export declare const options: z.ZodObject<{
4
+ provider: z.ZodDefault<z.ZodEnum<{
5
+ github: "github";
6
+ plane: "plane";
7
+ }>>;
8
+ workspace: z.ZodOptional<z.ZodString>;
9
+ apiUrl: z.ZodOptional<z.ZodString>;
10
+ }, z.core.$strip>;
11
+ type Props = {
12
+ options: z.infer<typeof options>;
13
+ };
14
+ export default function Init({ options: opts }: Props): import("react/jsx-runtime").JSX.Element;
15
+ export {};
@@ -3,12 +3,47 @@ import { useEffect, useState } from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import Spinner from "ink-spinner";
5
5
  import * as fs from "fs";
6
+ import { option } from "pastel";
7
+ import { z } from "zod";
6
8
  import { findMainRepoRoot, getMintreeDir, getMetadataPath, getWorktreesDir, getSessionStatesDir, ensureGitignoreEntries, isGitTracked, } from "../lib/git.js";
7
9
  export const description = "Initialize the current repo for mintree (creates .mintree/, updates .gitignore)";
8
- const METADATA_TEMPLATE = {
9
- version: 1,
10
- issues: {},
11
- };
10
+ export const options = z.object({
11
+ provider: z
12
+ .enum(["github", "plane"])
13
+ .default("github")
14
+ .describe(option({
15
+ description: "Issue provider to scaffold for (default: github)",
16
+ })),
17
+ workspace: z
18
+ .string()
19
+ .optional()
20
+ .describe(option({
21
+ description: "Plane workspace slug (required when --provider plane)",
22
+ })),
23
+ apiUrl: z
24
+ .string()
25
+ .optional()
26
+ .describe(option({
27
+ description: "Plane API URL (default: https://api.plane.so; override for self-hosted)",
28
+ })),
29
+ });
30
+ function buildMetadataTemplate(opts) {
31
+ const base = {
32
+ version: 1,
33
+ provider: opts.provider,
34
+ issues: {},
35
+ };
36
+ if (opts.provider === "plane") {
37
+ base["plane"] = {
38
+ apiUrl: opts.apiUrl ?? "https://api.plane.so",
39
+ workspaceSlug: opts.workspace ?? "FILL-IN-WORKSPACE-SLUG",
40
+ // Empty by default — user fills in their projects before mintree
41
+ // can list assigned work items. Doctor will surface this gap.
42
+ projects: [],
43
+ };
44
+ }
45
+ return base;
46
+ }
12
47
  function ensureDir(p, label, steps) {
13
48
  if (fs.existsSync(p)) {
14
49
  steps.push({ kind: "exists", label });
@@ -18,15 +53,16 @@ function ensureDir(p, label, steps) {
18
53
  steps.push({ kind: "created", label });
19
54
  }
20
55
  }
21
- function ensureMetadata(metadataPath, steps) {
56
+ function ensureMetadata(metadataPath, opts, steps) {
22
57
  if (fs.existsSync(metadataPath)) {
23
58
  steps.push({ kind: "exists", label: ".mintree/metadata.json" });
24
59
  return;
25
60
  }
26
- fs.writeFileSync(metadataPath, JSON.stringify(METADATA_TEMPLATE, null, 2) + "\n");
61
+ const template = buildMetadataTemplate(opts);
62
+ fs.writeFileSync(metadataPath, JSON.stringify(template, null, 2) + "\n");
27
63
  steps.push({ kind: "created", label: ".mintree/metadata.json" });
28
64
  }
29
- function runInit() {
65
+ function runInit(opts) {
30
66
  const root = findMainRepoRoot();
31
67
  if (!root) {
32
68
  return {
@@ -35,6 +71,10 @@ function runInit() {
35
71
  hint: "Run `git init` first, then re-run `mintree init`.",
36
72
  };
37
73
  }
74
+ if (opts.provider === "plane" && (!opts.workspace || opts.workspace.length === 0)) {
75
+ // Allow it to proceed with a FILL-IN placeholder so the user gets a
76
+ // working scaffold to edit, but flag it loudly via a warn step below.
77
+ }
38
78
  const steps = [];
39
79
  const mintreeDir = getMintreeDir(root);
40
80
  const worktreesDir = getWorktreesDir(root);
@@ -43,7 +83,7 @@ function runInit() {
43
83
  ensureDir(mintreeDir, ".mintree/", steps);
44
84
  ensureDir(worktreesDir, ".mintree/worktrees/", steps);
45
85
  ensureDir(sessionStatesDir, ".mintree/session-states/", steps);
46
- ensureMetadata(metadataPath, steps);
86
+ ensureMetadata(metadataPath, opts, steps);
47
87
  // metadata.json holds the per-issue session_id, which is local-only by
48
88
  // nature (each dev gets their own UUIDs from `claude`). Versioning it
49
89
  // would only generate noise + merge conflicts, so it's gitignored along
@@ -71,7 +111,22 @@ function runInit() {
71
111
  hint: "Run: git rm --cached .mintree/metadata.json && git commit -m 'chore: untrack mintree metadata'",
72
112
  });
73
113
  }
74
- return { ok: true, repoRoot: root, steps };
114
+ // Plane scaffolds are intentionally incomplete projects[] is empty and
115
+ // workspaceSlug may be a placeholder. Tell the user exactly what to fix
116
+ // before doctor will pass.
117
+ if (opts.provider === "plane") {
118
+ const needs = [];
119
+ if (!opts.workspace || opts.workspace.length === 0) {
120
+ needs.push("workspaceSlug");
121
+ }
122
+ needs.push("projects[] (add at least one { id, identifier, name? })");
123
+ steps.push({
124
+ kind: "warn",
125
+ label: "Plane scaffold needs manual edits",
126
+ hint: `Edit ${metadataPath} and fill in: ${needs.join(", ")}`,
127
+ });
128
+ }
129
+ return { ok: true, repoRoot: root, provider: opts.provider, steps };
75
130
  }
76
131
  function StepIcon({ kind }) {
77
132
  switch (kind) {
@@ -99,13 +154,17 @@ function stepDetail(kind) {
99
154
  return null;
100
155
  }
101
156
  }
102
- export default function Init() {
157
+ export default function Init({ options: opts }) {
103
158
  const [result, setResult] = useState(null);
104
159
  useEffect(() => {
105
160
  // Defer one tick so the initial render with the spinner gets to paint.
106
161
  setTimeout(() => {
107
162
  try {
108
- setResult(runInit());
163
+ setResult(runInit({
164
+ provider: opts.provider,
165
+ workspace: opts.workspace,
166
+ apiUrl: opts.apiUrl,
167
+ }));
109
168
  }
110
169
  catch (err) {
111
170
  setResult({
@@ -114,7 +173,7 @@ export default function Init() {
114
173
  });
115
174
  }
116
175
  }, 0);
117
- }, []);
176
+ }, [opts.provider, opts.workspace, opts.apiUrl]);
118
177
  if (!result) {
119
178
  return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Initializing mintree..." })] }));
120
179
  }
@@ -122,7 +181,7 @@ export default function Init() {
122
181
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", result.message] }), result.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", result.hint] }) }))] }));
123
182
  }
124
183
  const anyChange = result.steps.some((s) => s.kind === "created" || s.kind === "added");
125
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree init" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.repoRoot] })] }), result.steps.map((step, i) => {
184
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree init" }), _jsx(Text, { dimColor: true, children: ` · ${result.repoRoot}` }), _jsx(Text, { dimColor: true, children: ` · provider=${result.provider}` })] }), result.steps.map((step, i) => {
126
185
  const detail = stepDetail(step.kind);
127
186
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), detail && _jsxs(Text, { dimColor: true, children: [" (", detail, ")"] })] }), step.hint && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", step.hint] }) }))] }, i));
128
187
  }), _jsx(Box, { marginTop: 1, children: anyChange ? (_jsxs(Text, { color: "green", children: ["mintree initialized. Run ", _jsx(Text, { bold: true, children: "mintree doctor" }), " to verify the rest of your setup."] })) : (_jsx(Text, { color: "cyan", children: "mintree was already initialized \u2014 nothing to do." })) })] }));
@@ -6,7 +6,7 @@ import { option } from "pastel";
6
6
  import { z } from "zod";
7
7
  import * as path from "path";
8
8
  import { findMainRepoRoot, getMintreeDir, getWorktreesDir, listWorktrees, isDirty, removeWorktree, pathExists, } from "../../lib/git.js";
9
- import { tryExec } from "../../lib/exec.js";
9
+ import { fetchPrForBranch } from "../../lib/pr.js";
10
10
  export const description = "Remove worktrees whose PR is merged or closed";
11
11
  export const options = z.object({
12
12
  yes: z
@@ -22,23 +22,6 @@ export const options = z.object({
22
22
  description: "Include worktrees with uncommitted changes (clean is conservative by default)",
23
23
  })),
24
24
  });
25
- function shQuote(value) {
26
- return `'${value.replace(/'/g, `'\\''`)}'`;
27
- }
28
- async function fetchPr(branch) {
29
- const out = await tryExec(`gh pr list --head ${shQuote(branch)} --state all --json number,state --limit 1 2>/dev/null`);
30
- if (!out)
31
- return null;
32
- try {
33
- const arr = JSON.parse(out);
34
- if (Array.isArray(arr) && arr.length > 0 && arr[0])
35
- return arr[0];
36
- }
37
- catch {
38
- // fall through
39
- }
40
- return null;
41
- }
42
25
  async function loadCandidates(force) {
43
26
  const root = findMainRepoRoot();
44
27
  if (!root) {
@@ -67,7 +50,7 @@ async function loadCandidates(force) {
67
50
  if (ours.length === 0) {
68
51
  return { phase: "nothing", message: "No mintree worktrees in this repo. Nothing to clean." };
69
52
  }
70
- const prs = await Promise.all(ours.map((w) => fetchPr(w.branch)));
53
+ const prs = await Promise.all(ours.map((w) => fetchPrForBranch(w.branch, { withUrl: false })));
71
54
  const candidates = [];
72
55
  for (let i = 0; i < ours.length; i++) {
73
56
  const w = ours[i];
@@ -8,7 +8,7 @@ import { PERMISSION_MODES } from "../../lib/claude.js";
8
8
  import { runCreate } from "../../lib/worktreeCreate.js";
9
9
  import { buildCreateMarkers, emitMarkers } from "../../lib/markers.js";
10
10
  import { findMainRepoRoot } from "../../lib/git.js";
11
- import { transitionIssueToInProgress, describeTransition, } from "../../lib/githubProject.js";
11
+ import { createProvider, describeTransition } from "../../lib/providers/index.js";
12
12
  export const description = "Create a worktree for an issue branch";
13
13
  export const args = z.tuple([
14
14
  z.string().describe(argument({
@@ -89,7 +89,8 @@ export default function Create({ args, options }) {
89
89
  return;
90
90
  }
91
91
  try {
92
- const r = await transitionIssueToInProgress(root, result.issueId);
92
+ const provider = createProvider(root);
93
+ const r = await provider.transitionIssueToInProgress(result.issueId);
93
94
  if (!cancelled)
94
95
  setTransition(r);
95
96
  }
@@ -7,7 +7,7 @@ import { z } from "zod";
7
7
  import * as path from "path";
8
8
  import { findMainRepoRoot, getWorktreesDir, listWorktrees, isDirty, getAheadBehind, pathExists, getMintreeDir, } from "../../lib/git.js";
9
9
  import { readMetadata } from "../../lib/metadata.js";
10
- import { tryExec } from "../../lib/exec.js";
10
+ import { fetchPrForBranch } from "../../lib/pr.js";
11
11
  export const description = "List mintree-managed worktrees with dirty/ahead/PR status";
12
12
  export const options = z.object({
13
13
  pr: z
@@ -15,31 +15,15 @@ export const options = z.object({
15
15
  .default(false)
16
16
  .describe(option({ description: "Look up PR status for each branch via `gh` (slower)" })),
17
17
  });
18
- const ISSUE_ID_REGEX = /^[a-z]+\/(\d+)-/;
18
+ // Matches the BRANCH_REGEX shape from lib/branch.ts: either `\d+` (GitHub)
19
+ // or `<PROJ>-\d+` (Plane). Used to surface BACK-100 in the ISSUE column.
20
+ const ISSUE_ID_REGEX = /^[a-z]+\/((?:[A-Z][A-Z0-9_]*-)?\d+)-/;
19
21
  function extractIssueId(branch) {
20
22
  if (!branch)
21
23
  return null;
22
24
  const m = branch.match(ISSUE_ID_REGEX);
23
25
  return m && m[1] ? m[1] : null;
24
26
  }
25
- async function fetchPrStatus(branch) {
26
- const out = await tryExec(`gh pr list --head ${shQuote(branch)} --state all --json number,state --limit 1 2>/dev/null`);
27
- if (!out)
28
- return undefined;
29
- try {
30
- const arr = JSON.parse(out);
31
- if (Array.isArray(arr) && arr.length > 0 && arr[0]) {
32
- return { number: arr[0].number, state: arr[0].state };
33
- }
34
- }
35
- catch {
36
- // fall through
37
- }
38
- return undefined;
39
- }
40
- function shQuote(value) {
41
- return `'${value.replace(/'/g, `'\\''`)}'`;
42
- }
43
27
  async function load(checkPr) {
44
28
  const root = findMainRepoRoot();
45
29
  if (!root) {
@@ -82,9 +66,13 @@ async function load(checkPr) {
82
66
  };
83
67
  });
84
68
  if (checkPr) {
85
- const prResults = await Promise.all(rows.map((r) => r.branch === "(detached)" ? Promise.resolve(undefined) : fetchPrStatus(r.branch)));
69
+ const prResults = await Promise.all(rows.map((r) => r.branch === "(detached)"
70
+ ? Promise.resolve(null)
71
+ : fetchPrForBranch(r.branch, { withUrl: false })));
86
72
  rows.forEach((r, i) => {
87
- r.pr = prResults[i];
73
+ const pr = prResults[i];
74
+ if (pr)
75
+ r.pr = pr;
88
76
  });
89
77
  }
90
78
  return { phase: "ready", repoRoot: root, rows, checkedPr: checkPr };
@@ -68,13 +68,14 @@ function resolve(cwd) {
68
68
  // IssueId comes from the worktree dir name, not the branch — that way
69
69
  // detached-HEAD worktrees (the "current branch" path from the dashboard)
70
70
  // still resolve their session_id. Convention guarantees the dir is named
71
- // `<issueId>-<desc>` for both attached and detached creates.
72
- const issueIdMatch = worktreeDirName.match(/^(\d+)-/);
71
+ // `<issueId>-<desc>` for both attached and detached creates, where
72
+ // issueId is either bare digits (GitHub) or `<PROJ>-\d+` (Plane).
73
+ const issueIdMatch = worktreeDirName.match(/^((?:[A-Z][A-Z0-9_]*-)?\d+)-/);
73
74
  if (!issueIdMatch || !issueIdMatch[1]) {
74
75
  return {
75
76
  ok: false,
76
- message: `Worktree directory '${worktreeDirName}' doesn't start with an issue number.`,
77
- hint: "Expected `<issue>-<desc>` (e.g. 100-claude-md-inicial).",
77
+ message: `Worktree directory '${worktreeDirName}' doesn't start with an issue id.`,
78
+ hint: "Expected `<issue>-<desc>` (e.g. 100-claude-md-inicial or AUTH-6-legal-endpoint).",
78
79
  };
79
80
  }
80
81
  const issueId = issueIdMatch[1];
@@ -3,11 +3,14 @@
3
3
  *
4
4
  * <type>/<issue>-<kebab-desc>
5
5
  *
6
- * `<type>` is one of the 11 conventional prefixes; `<issue>` is the GitHub
7
- * issue number (digits only, no `#`); `<desc>` is lower-case kebab-case.
6
+ * `<type>` is one of the 11 conventional prefixes; `<issue>` is either a
7
+ * bare digit run (GitHub issue number — "100") or a project-prefixed Plane
8
+ * identifier ("BACK-100"); `<desc>` is lower-case kebab-case.
8
9
  *
9
- * Examples that PARSE: feat/100-readme-update, fix/55-upload-timeout
10
- * Examples that REJECT: feat/abc-foo, /100-foo, gh-100-foo, feat/100, feat/100-FooBar
10
+ * Examples that PARSE: feat/100-readme-update, fix/55-upload-timeout,
11
+ * feat/BACK-100-readme-update, fix/WEB-7-modal
12
+ * Examples that REJECT: feat/abc-foo, /100-foo, gh-100-foo, feat/100,
13
+ * feat/100-FooBar, feat/back-100-x (lowercase prefix)
11
14
  */
12
15
  export declare const ALLOWED_TYPES: readonly ["feat", "fix", "docs", "chore", "refactor", "test", "build", "ci", "perf", "style", "revert"];
13
16
  export type BranchType = (typeof ALLOWED_TYPES)[number];
@@ -3,11 +3,14 @@
3
3
  *
4
4
  * <type>/<issue>-<kebab-desc>
5
5
  *
6
- * `<type>` is one of the 11 conventional prefixes; `<issue>` is the GitHub
7
- * issue number (digits only, no `#`); `<desc>` is lower-case kebab-case.
6
+ * `<type>` is one of the 11 conventional prefixes; `<issue>` is either a
7
+ * bare digit run (GitHub issue number — "100") or a project-prefixed Plane
8
+ * identifier ("BACK-100"); `<desc>` is lower-case kebab-case.
8
9
  *
9
- * Examples that PARSE: feat/100-readme-update, fix/55-upload-timeout
10
- * Examples that REJECT: feat/abc-foo, /100-foo, gh-100-foo, feat/100, feat/100-FooBar
10
+ * Examples that PARSE: feat/100-readme-update, fix/55-upload-timeout,
11
+ * feat/BACK-100-readme-update, fix/WEB-7-modal
12
+ * Examples that REJECT: feat/abc-foo, /100-foo, gh-100-foo, feat/100,
13
+ * feat/100-FooBar, feat/back-100-x (lowercase prefix)
11
14
  */
12
15
  export const ALLOWED_TYPES = [
13
16
  "feat",
@@ -22,20 +25,25 @@ export const ALLOWED_TYPES = [
22
25
  "style",
23
26
  "revert",
24
27
  ];
25
- const BRANCH_REGEX = /^([a-z]+)\/(\d+)-([a-z0-9][a-z0-9-]*)$/;
28
+ // `<type>/<issueId>-<desc>` where issueId is either `\d+` (GitHub) or
29
+ // `<PROJ_PREFIX>-\d+` (Plane). The PROJ_PREFIX is uppercase letters/digits/
30
+ // underscores starting with a letter, mirroring Plane's project-identifier
31
+ // constraints. The full issueId captures group 2 verbatim so callers can
32
+ // round-trip it into the worktree dir name.
33
+ const BRANCH_REGEX = /^([a-z]+)\/((?:[A-Z][A-Z0-9_]*-)?\d+)-([a-z0-9][a-z0-9-]*)$/;
26
34
  export function parseBranch(branch) {
27
35
  const match = BRANCH_REGEX.exec(branch);
28
36
  if (!match) {
29
37
  return {
30
38
  error: `Invalid branch name: ${branch}`,
31
- hint: "Expected `<type>/<issue>-<kebab-desc>`. Example: feat/100-claude-md-inicial",
39
+ hint: "Expected `<type>/<issue>-<kebab-desc>`. Examples: feat/100-claude-md-inicial, feat/BACK-100-claude-md-inicial",
32
40
  };
33
41
  }
34
42
  const [, type, issueId, desc] = match;
35
43
  if (!type || !issueId || !desc) {
36
44
  return {
37
45
  error: `Invalid branch name: ${branch}`,
38
- hint: "Expected `<type>/<issue>-<kebab-desc>`. Example: feat/100-claude-md-inicial",
46
+ hint: "Expected `<type>/<issue>-<kebab-desc>`. Examples: feat/100-claude-md-inicial, feat/BACK-100-claude-md-inicial",
39
47
  };
40
48
  }
41
49
  if (!ALLOWED_TYPES.includes(type)) {
@@ -1,16 +1,8 @@
1
1
  import { type AheadBehind } from "./git.js";
2
- export type GhIssue = {
3
- number: number;
4
- title: string;
5
- state: string;
6
- url: string;
7
- labels: {
8
- name: string;
9
- }[];
10
- body: string;
11
- createdAt: string;
12
- updatedAt: string;
13
- };
2
+ import { type PrInfo } from "./pr.js";
3
+ import type { IssueProjectInfo, ProviderIssue } from "./providers/types.js";
4
+ export type { PrInfo, PrState } from "./pr.js";
5
+ export type { ProviderIssue, IssueProjectInfo, IssueId } from "./providers/types.js";
14
6
  export type WorktreeInfo = {
15
7
  path: string;
16
8
  branch: string | null;
@@ -24,42 +16,13 @@ export type SessionStateInfo = {
24
16
  at: string;
25
17
  message: string | null;
26
18
  };
27
- export type PrInfo = {
28
- number: number;
29
- state: "OPEN" | "CLOSED" | "MERGED";
30
- url: string;
31
- };
32
- /**
33
- * The issue's membership in a GitHub Projects v2 board, used to group the
34
- * dashboard list. `status` is the value of the board's single-select Status
35
- * field (null when the issue is on the board but has no status set).
36
- * `statusOrder` is the index of that option in the field definition, so the
37
- * dashboard can order status sub-groups the same way the board's columns are
38
- * ordered. `statusColor` is an Ink colour derived from the option's own
39
- * configured colour.
40
- */
41
- export type IssueProjectInfo = {
42
- projectTitle: string;
43
- projectUrl: string;
44
- projectNumber: number;
45
- status: string | null;
46
- statusColor: string;
47
- statusOrder: number;
48
- };
49
19
  export type DashboardIssue = {
50
- issue: GhIssue;
20
+ issue: ProviderIssue;
51
21
  worktree: WorktreeInfo | null;
52
22
  session: SessionStateInfo | null;
53
23
  pr: PrInfo | null;
54
24
  project: IssueProjectInfo | null;
55
25
  };
56
- /**
57
- * Fetches open issues assigned to the authenticated GitHub user for the
58
- * current cwd's repo. Returns null when `gh` isn't authenticated, the cwd
59
- * isn't a GitHub repo, or the API call fails — the caller surfaces the
60
- * appropriate hint.
61
- */
62
- export declare function fetchAssignedIssues(): Promise<GhIssue[] | null>;
63
26
  /**
64
27
  * Top-level loader: enriches each assigned issue with its worktree and
65
28
  * session snapshot. Designed to be called on dashboard mount and on every