mintree 0.1.10 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -17
- package/dist/commands/dashboard.js +113 -46
- package/dist/commands/doctor.js +62 -19
- package/dist/commands/init.d.ts +14 -1
- package/dist/commands/init.js +72 -13
- package/dist/commands/worktree/clean.js +2 -19
- package/dist/commands/worktree/create.js +3 -2
- package/dist/commands/worktree/list.js +10 -22
- package/dist/commands/worktree/work.js +5 -4
- package/dist/lib/branch.d.ts +7 -4
- package/dist/lib/branch.js +15 -7
- package/dist/lib/dashboard.d.ts +5 -42
- package/dist/lib/dashboard.js +33 -189
- package/dist/lib/gh.d.ts +16 -0
- package/dist/lib/{github.js → gh.js} +9 -0
- package/dist/lib/metadata.d.ts +15 -0
- package/dist/lib/metadata.js +51 -0
- package/dist/lib/pr.d.ts +26 -0
- package/dist/lib/pr.js +49 -0
- package/dist/lib/providers/github.d.ts +33 -0
- package/dist/lib/providers/github.js +381 -0
- package/dist/lib/providers/index.d.ts +27 -0
- package/dist/lib/providers/index.js +83 -0
- package/dist/lib/providers/plane.d.ts +61 -0
- package/dist/lib/providers/plane.js +749 -0
- package/dist/lib/providers/types.d.ts +113 -0
- package/dist/lib/providers/types.js +12 -0
- package/dist/lib/session-signal.d.ts +3 -2
- package/dist/lib/session-signal.js +4 -3
- package/dist/lib/worktreeCreate.js +4 -1
- package/package.json +1 -1
- package/dist/lib/github.d.ts +0 -7
- package/dist/lib/githubProject.d.ts +0 -55
- package/dist/lib/githubProject.js +0 -277
|
@@ -0,0 +1,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
|
|
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
|
|
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(/^(
|
|
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
|
-
|
|
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
package/dist/lib/github.d.ts
DELETED
|
@@ -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
|
-
}
|