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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Shared types and the IssueProvider interface implemented by the
3
+ * github/plane providers. Keeping these in one file lets the dashboard and
4
+ * worktree commands talk to issues abstractly while each provider owns its
5
+ * own transport details.
6
+ *
7
+ * `IssueId` is a string in both providers — for GitHub it's the issue number
8
+ * stringified ("100"); for Plane it'll be the project-prefixed identifier
9
+ * ("BACK-100"). The branch convention encodes this same string verbatim, so
10
+ * worktree dir names round-trip through the IssueId without re-parsing.
11
+ */
12
+ export type IssueId = string;
13
+ /**
14
+ * A workflow issue normalised across providers. Shape mirrors what the GH
15
+ * `gh issue list --json` payload exposes minus the GH-specific `number`
16
+ * field, which is replaced by the universal `id` string.
17
+ */
18
+ export type ProviderIssue = {
19
+ id: IssueId;
20
+ title: string;
21
+ state: string;
22
+ url: string;
23
+ labels: {
24
+ name: string;
25
+ }[];
26
+ body: string;
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ };
30
+ /**
31
+ * The issue's membership on a project board (GitHub Projects v2 / Plane
32
+ * project), used to group the dashboard list. `status` is the workflow
33
+ * state's display name (null when the issue is on the board but no state is
34
+ * set). `statusOrder` gives the column index so the dashboard can order
35
+ * sub-groups the same way the board does. `statusColor` is an Ink-renderable
36
+ * colour string — a 16-colour name for GitHub's enum, or a #rrggbb hex for
37
+ * Plane states.
38
+ */
39
+ export type IssueProjectInfo = {
40
+ projectTitle: string;
41
+ projectUrl: string;
42
+ projectNumber: number;
43
+ status: string | null;
44
+ statusColor: string;
45
+ statusOrder: number;
46
+ };
47
+ /**
48
+ * Result of transitionIssueToInProgress. Discriminated so the caller can
49
+ * render a precise status message without inventing one. Provider-agnostic:
50
+ * the github and plane implementations both yield these shapes.
51
+ */
52
+ export type TransitionResult = {
53
+ kind: "transitioned";
54
+ projectTitle: string;
55
+ from: string | null;
56
+ to: string;
57
+ } | {
58
+ kind: "noop-already";
59
+ projectTitle: string;
60
+ } | {
61
+ kind: "noop-protected";
62
+ projectTitle: string;
63
+ current: string;
64
+ } | {
65
+ kind: "skip-no-repo";
66
+ } | {
67
+ kind: "skip-no-issue";
68
+ } | {
69
+ kind: "skip-no-project";
70
+ } | {
71
+ kind: "skip-ambiguous";
72
+ projects: string[];
73
+ } | {
74
+ kind: "skip-no-status-field";
75
+ projects: string[];
76
+ } | {
77
+ kind: "skip-no-in-progress-option";
78
+ projects: string[];
79
+ } | {
80
+ kind: "error";
81
+ message: string;
82
+ hint?: string;
83
+ };
84
+ export interface IssueProvider {
85
+ readonly kind: "github" | "plane";
86
+ /**
87
+ * Lists open issues assigned to the current user, scoped to whatever the
88
+ * provider considers the active context (GH: the current repo on origin;
89
+ * Plane: the configured workspace/projects). Returns null on transient
90
+ * failure (auth, network) — the dashboard renders an error hint.
91
+ */
92
+ listAssignedIssues(): Promise<ProviderIssue[] | null>;
93
+ /**
94
+ * Returns project/board membership for the assigned issues (same scope as
95
+ * listAssignedIssues — typically a single round-trip). The dashboard uses
96
+ * this to group rows by project → status.
97
+ *
98
+ * Return shapes:
99
+ * - non-empty map: the lookup succeeded and found project membership
100
+ * - empty map: the lookup succeeded but no issue is on any project
101
+ * - null: the lookup failed for ALL projects (transient API error,
102
+ * auth missing). Distinct from empty so the dashboard can treat
103
+ * null as a partial load failure and keep its last-good state.
104
+ */
105
+ fetchProjectAssignments(): Promise<Map<IssueId, IssueProjectInfo> | null>;
106
+ /**
107
+ * Moves the issue to its project's "In Progress" workflow state. Idempotent
108
+ * by design (returns noop-already when already there) and conservative on
109
+ * later stages (returns noop-protected when the issue is past In Progress,
110
+ * e.g. "In Review" / "Done").
111
+ */
112
+ transitionIssueToInProgress(issueId: IssueId): Promise<TransitionResult>;
113
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared types and the IssueProvider interface implemented by the
3
+ * github/plane providers. Keeping these in one file lets the dashboard and
4
+ * worktree commands talk to issues abstractly while each provider owns its
5
+ * own transport details.
6
+ *
7
+ * `IssueId` is a string in both providers — for GitHub it's the issue number
8
+ * stringified ("100"); for Plane it'll be the project-prefixed identifier
9
+ * ("BACK-100"). The branch convention encodes this same string verbatim, so
10
+ * worktree dir names round-trip through the IssueId without re-parsing.
11
+ */
12
+ export {};
@@ -16,9 +16,10 @@ export declare function extractRepoAndDir(cwd: string): {
16
16
  worktreeDir: string;
17
17
  } | null;
18
18
  /**
19
- * Pulls the issue number out of a `<issue>-<desc>` worktree directory name.
19
+ * Pulls the issue id out of a `<issue>-<desc>` worktree directory name.
20
20
  * Returns null when the directory name doesn't follow the convention (e.g.
21
- * a manually-created worktree dropped under .mintree/worktrees/).
21
+ * a manually-created worktree dropped under .mintree/worktrees/). The id
22
+ * is either bare digits (GitHub) or a `<PROJ>-\d+` Plane identifier.
22
23
  */
23
24
  export declare function issueIdFromWorktreeDir(worktreeDir: string): string | null;
24
25
  export type StatePayload = {
@@ -32,12 +32,13 @@ export function extractRepoAndDir(cwd) {
32
32
  return { repoRoot, worktreeDir };
33
33
  }
34
34
  /**
35
- * Pulls the issue number out of a `<issue>-<desc>` worktree directory name.
35
+ * Pulls the issue id out of a `<issue>-<desc>` worktree directory name.
36
36
  * Returns null when the directory name doesn't follow the convention (e.g.
37
- * a manually-created worktree dropped under .mintree/worktrees/).
37
+ * a manually-created worktree dropped under .mintree/worktrees/). The id
38
+ * is either bare digits (GitHub) or a `<PROJ>-\d+` Plane identifier.
38
39
  */
39
40
  export function issueIdFromWorktreeDir(worktreeDir) {
40
- const m = worktreeDir.match(/^(\d+)-/);
41
+ const m = worktreeDir.match(/^((?:[A-Z][A-Z0-9_]*-)?\d+)-/);
41
42
  return m && m[1] ? m[1] : null;
42
43
  }
43
44
  /**
@@ -224,7 +224,10 @@ export function runCreateDetached(opts) {
224
224
  hint: "Run `mintree init` first.",
225
225
  };
226
226
  }
227
- if (!/^\d+$/.test(opts.issueId)) {
227
+ // Same shape as BRANCH_REGEX's issueId capture: bare digits (GitHub) or
228
+ // `<PROJ>-\d+` (Plane). Otherwise the detached-worktree flow rejects
229
+ // valid Plane ids like "AUTH-6" when they reach this entry point.
230
+ if (!/^(?:[A-Z][A-Z0-9_]*-)?\d+$/.test(opts.issueId)) {
228
231
  return { ok: false, message: `Invalid issueId: ${opts.issueId}` };
229
232
  }
230
233
  if (!/^[a-z0-9][a-z0-9-]*$/.test(opts.descKebab)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.1.11",
3
+ "version": "0.2.0",
4
4
  "description": "Issue-driven git worktrees + Claude Code sessions for repos with an opinionated SDD+TDD flow.",
5
5
  "license": "MIT",
6
6
  "author": "Martin Mineo <mmineo@canarytechnologies.com>",
@@ -1,7 +0,0 @@
1
- export declare function ghCliAvailable(): Promise<boolean>;
2
- export declare function getGhUserLogin(): Promise<string | null>;
3
- /**
4
- * Returns "owner/name" for the GitHub repo of the current working directory,
5
- * or null if not a GitHub repo / `gh` can't reach the API.
6
- */
7
- export declare function getRepoFullName(): Promise<string | null>;
@@ -1,55 +0,0 @@
1
- export type TransitionResult = {
2
- kind: "transitioned";
3
- projectTitle: string;
4
- from: string | null;
5
- to: string;
6
- } | {
7
- kind: "noop-already";
8
- projectTitle: string;
9
- } | {
10
- kind: "noop-protected";
11
- projectTitle: string;
12
- current: string;
13
- } | {
14
- kind: "skip-no-repo";
15
- } | {
16
- kind: "skip-no-issue";
17
- } | {
18
- kind: "skip-no-project";
19
- } | {
20
- kind: "skip-ambiguous";
21
- projects: string[];
22
- } | {
23
- kind: "skip-no-status-field";
24
- projects: string[];
25
- } | {
26
- kind: "skip-no-in-progress-option";
27
- projects: string[];
28
- } | {
29
- kind: "error";
30
- message: string;
31
- hint?: string;
32
- };
33
- /**
34
- * Moves the GitHub issue to "In Progress" on its project. Auto-discovers the
35
- * project from the issue's projectItems; if there are several candidates and
36
- * `.mintree/metadata.json` doesn't pin one (via `project.url`), bails out
37
- * rather than guess.
38
- *
39
- * Skips silently when the status is already In Progress or one of the
40
- * protected statuses (In Review, Done by default) — to avoid clobbering
41
- * a PR-driven transition done by something else.
42
- */
43
- export declare function transitionIssueToInProgress(repoRoot: string, issueNumber: number | string): Promise<TransitionResult>;
44
- /**
45
- * Returns the gh CLI token scopes for github.com, or null when `gh` can't be
46
- * called / the user isn't authenticated. `gh auth status` writes the scopes
47
- * line to stderr; we capture both streams and grep for it.
48
- */
49
- export declare function getGhTokenScopes(): Promise<string[] | null>;
50
- export declare function hasProjectScope(scopes: string[]): boolean;
51
- export declare function describeTransition(result: TransitionResult): {
52
- kind: "ok" | "skip" | "warn";
53
- label: string;
54
- detail?: string;
55
- };
@@ -1,277 +0,0 @@
1
- import { execFile } from "child_process";
2
- import { promisify } from "util";
3
- import { getRepoFullName } from "./github.js";
4
- import { readMetadata } from "./metadata.js";
5
- const execFileAsync = promisify(execFile);
6
- const DEFAULT_STATUS_FIELD = "Status";
7
- const DEFAULT_IN_PROGRESS_OPTION = "In Progress";
8
- const DEFAULT_PROTECTED_STATUSES = ["In Review", "Done"];
9
- async function runGhGraphql(query, fields) {
10
- const args = ["api", "graphql", "-f", `query=${query}`];
11
- for (const [key, value] of fields) {
12
- if (typeof value === "number") {
13
- args.push("-F", `${key}=${value}`);
14
- }
15
- else {
16
- args.push("-f", `${key}=${value}`);
17
- }
18
- }
19
- const { stdout } = await execFileAsync("gh", args);
20
- return JSON.parse(stdout);
21
- }
22
- function readProjectConfig(repoRoot) {
23
- return readMetadata(repoRoot).project ?? {};
24
- }
25
- function parseProjectNumberFromUrl(url) {
26
- const m = url.match(/\/projects\/(\d+)/);
27
- return m && m[1] ? Number(m[1]) : null;
28
- }
29
- function interpretGhError(err) {
30
- const stderr = err && typeof err === "object" && "stderr" in err
31
- ? String(err.stderr)
32
- : err instanceof Error
33
- ? err.message
34
- : String(err);
35
- if (/INSUFFICIENT_SCOPES/i.test(stderr) || (/scope/i.test(stderr) && /project/i.test(stderr))) {
36
- return {
37
- kind: "error",
38
- message: "gh token is missing the `project` scope.",
39
- hint: "Run: gh auth refresh -s project",
40
- };
41
- }
42
- if (/Could not resolve to a Repository/i.test(stderr)) {
43
- return { kind: "skip-no-repo" };
44
- }
45
- if (/Could not resolve to an Issue/i.test(stderr)) {
46
- return { kind: "skip-no-issue" };
47
- }
48
- const firstLine = stderr.split("\n").find((line) => line.trim().length > 0) ?? "";
49
- return {
50
- kind: "error",
51
- message: firstLine.slice(0, 200) || "gh api graphql failed",
52
- };
53
- }
54
- /**
55
- * Moves the GitHub issue to "In Progress" on its project. Auto-discovers the
56
- * project from the issue's projectItems; if there are several candidates and
57
- * `.mintree/metadata.json` doesn't pin one (via `project.url`), bails out
58
- * rather than guess.
59
- *
60
- * Skips silently when the status is already In Progress or one of the
61
- * protected statuses (In Review, Done by default) — to avoid clobbering
62
- * a PR-driven transition done by something else.
63
- */
64
- export async function transitionIssueToInProgress(repoRoot, issueNumber) {
65
- const repo = await getRepoFullName();
66
- if (!repo)
67
- return { kind: "skip-no-repo" };
68
- const [owner, name] = repo.split("/");
69
- if (!owner || !name)
70
- return { kind: "skip-no-repo" };
71
- const cfg = readProjectConfig(repoRoot);
72
- const statusFieldName = cfg.statusField ?? DEFAULT_STATUS_FIELD;
73
- const inProgressOptionName = cfg.inProgressOption ?? DEFAULT_IN_PROGRESS_OPTION;
74
- const protectedStatuses = cfg.protectedStatuses ?? DEFAULT_PROTECTED_STATUSES;
75
- // The Status field name is interpolated into the query (not a variable)
76
- // because GraphQL field-argument names are not parameterizable through
77
- // the variables object. Escape any embedded quotes to keep the query valid.
78
- const escapedFieldName = statusFieldName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
79
- const query = `query($owner: String!, $repo: String!, $number: Int!) {
80
- repository(owner: $owner, name: $repo) {
81
- issue(number: $number) {
82
- id
83
- projectItems(first: 20, includeArchived: false) {
84
- nodes {
85
- id
86
- project {
87
- id
88
- title
89
- number
90
- url
91
- field(name: "${escapedFieldName}") {
92
- ... on ProjectV2SingleSelectField {
93
- id
94
- name
95
- options { id name }
96
- }
97
- }
98
- }
99
- fieldValues(first: 30) {
100
- nodes {
101
- ... on ProjectV2ItemFieldSingleSelectValue {
102
- name
103
- field {
104
- ... on ProjectV2SingleSelectField { name }
105
- }
106
- }
107
- }
108
- }
109
- }
110
- }
111
- }
112
- }
113
- }`;
114
- let raw;
115
- try {
116
- raw = (await runGhGraphql(query, [
117
- ["owner", owner],
118
- ["repo", name],
119
- ["number", Number(issueNumber)],
120
- ]));
121
- }
122
- catch (err) {
123
- return interpretGhError(err);
124
- }
125
- const issue = raw?.data?.repository?.issue;
126
- if (!issue)
127
- return { kind: "skip-no-issue" };
128
- let nodes = issue.projectItems.nodes;
129
- if (nodes.length === 0)
130
- return { kind: "skip-no-project" };
131
- // Honour an explicit project URL in the config before doing anything else.
132
- if (cfg.url) {
133
- const targetNumber = parseProjectNumberFromUrl(cfg.url);
134
- nodes = nodes.filter((n) => n.project.url === cfg.url || (targetNumber !== null && n.project.number === targetNumber));
135
- if (nodes.length === 0)
136
- return { kind: "skip-no-project" };
137
- }
138
- const withField = nodes.filter((n) => n.project.field !== null);
139
- if (withField.length === 0) {
140
- return { kind: "skip-no-status-field", projects: nodes.map((n) => n.project.title) };
141
- }
142
- const withOption = withField.filter((n) => n.project.field.options.some((o) => o.name === inProgressOptionName));
143
- if (withOption.length === 0) {
144
- return {
145
- kind: "skip-no-in-progress-option",
146
- projects: withField.map((n) => n.project.title),
147
- };
148
- }
149
- if (withOption.length > 1) {
150
- return { kind: "skip-ambiguous", projects: withOption.map((n) => n.project.title) };
151
- }
152
- const item = withOption[0];
153
- const project = item.project;
154
- const field = project.field;
155
- const option = field.options.find((o) => o.name === inProgressOptionName);
156
- if (!option) {
157
- // Defensive — already filtered above, but TypeScript can't see it.
158
- return { kind: "skip-no-in-progress-option", projects: [project.title] };
159
- }
160
- const currentStatus = item.fieldValues.nodes.find((v) => v.field?.name === statusFieldName)?.name ?? null;
161
- if (currentStatus === inProgressOptionName) {
162
- return { kind: "noop-already", projectTitle: project.title };
163
- }
164
- if (currentStatus !== null && protectedStatuses.includes(currentStatus)) {
165
- return { kind: "noop-protected", projectTitle: project.title, current: currentStatus };
166
- }
167
- const mutation = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
168
- updateProjectV2ItemFieldValue(input: {
169
- projectId: $projectId
170
- itemId: $itemId
171
- fieldId: $fieldId
172
- value: { singleSelectOptionId: $optionId }
173
- }) {
174
- projectV2Item { id }
175
- }
176
- }`;
177
- try {
178
- await runGhGraphql(mutation, [
179
- ["projectId", project.id],
180
- ["itemId", item.id],
181
- ["fieldId", field.id],
182
- ["optionId", option.id],
183
- ]);
184
- }
185
- catch (err) {
186
- return interpretGhError(err);
187
- }
188
- return {
189
- kind: "transitioned",
190
- projectTitle: project.title,
191
- from: currentStatus,
192
- to: inProgressOptionName,
193
- };
194
- }
195
- /**
196
- * Returns the gh CLI token scopes for github.com, or null when `gh` can't be
197
- * called / the user isn't authenticated. `gh auth status` writes the scopes
198
- * line to stderr; we capture both streams and grep for it.
199
- */
200
- export async function getGhTokenScopes() {
201
- try {
202
- const { stdout, stderr } = await execFileAsync("gh", ["auth", "status"]);
203
- const combined = `${stdout}\n${stderr}`;
204
- return parseScopesFromAuthStatus(combined);
205
- }
206
- catch (err) {
207
- const out = err && typeof err === "object" && "stdout" in err && "stderr" in err
208
- ? `${String(err.stdout)}\n${String(err.stderr)}`
209
- : "";
210
- const parsed = parseScopesFromAuthStatus(out);
211
- return parsed ?? null;
212
- }
213
- }
214
- function parseScopesFromAuthStatus(text) {
215
- const m = text.match(/Token scopes:\s*([^\n]+)/i);
216
- if (!m || !m[1])
217
- return null;
218
- return m[1]
219
- .split(",")
220
- .map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
221
- .filter(Boolean);
222
- }
223
- export function hasProjectScope(scopes) {
224
- return scopes.some((s) => s === "project" || s === "write:project");
225
- }
226
- export function describeTransition(result) {
227
- switch (result.kind) {
228
- case "transitioned":
229
- return {
230
- kind: "ok",
231
- label: `issue → ${result.to}`,
232
- detail: result.from ? `${result.projectTitle} (was: ${result.from})` : result.projectTitle,
233
- };
234
- case "noop-already":
235
- return {
236
- kind: "skip",
237
- label: "issue already In Progress",
238
- detail: result.projectTitle,
239
- };
240
- case "noop-protected":
241
- return {
242
- kind: "skip",
243
- label: `issue kept at ${result.current}`,
244
- detail: `${result.projectTitle} (status is protected)`,
245
- };
246
- case "skip-no-repo":
247
- return { kind: "skip", label: "no GitHub repo — skipping project update" };
248
- case "skip-no-issue":
249
- return { kind: "skip", label: "issue not found on GitHub — skipping project update" };
250
- case "skip-no-project":
251
- return { kind: "skip", label: "issue not on any project — skipping project update" };
252
- case "skip-ambiguous":
253
- return {
254
- kind: "warn",
255
- label: "multiple matching projects — skipping",
256
- detail: `set .mintree/metadata.json project.url to one of: ${result.projects.join(", ")}`,
257
- };
258
- case "skip-no-status-field":
259
- return {
260
- kind: "skip",
261
- label: "no Status field on project — skipping",
262
- detail: result.projects.join(", "),
263
- };
264
- case "skip-no-in-progress-option":
265
- return {
266
- kind: "skip",
267
- label: "no In Progress option on Status field — skipping",
268
- detail: result.projects.join(", "),
269
- };
270
- case "error":
271
- return {
272
- kind: "warn",
273
- label: "project update failed",
274
- detail: result.hint ? `${result.message} — ${result.hint}` : result.message,
275
- };
276
- }
277
- }