@ree-auz/nook 0.1.0 → 0.1.1

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.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +422 -410
  3. package/bin/nk.mjs +3 -3
  4. package/docs/setup-hooks.md +107 -107
  5. package/integrations/linear/README.md +84 -84
  6. package/integrations/linear/coding-tools.json +7 -7
  7. package/integrations/linear/open-issue.mjs +147 -147
  8. package/package.json +34 -34
  9. package/skills/nook/SKILL.md +71 -71
  10. package/src/agents.mjs +92 -92
  11. package/src/backup.mjs +74 -74
  12. package/src/ci.mjs +378 -378
  13. package/src/cli.mjs +151 -151
  14. package/src/commands/check-hooks.mjs +128 -128
  15. package/src/commands/clean.mjs +175 -175
  16. package/src/commands/doctor.mjs +100 -91
  17. package/src/commands/forget.mjs +57 -57
  18. package/src/commands/hooks.mjs +34 -27
  19. package/src/commands/init.mjs +154 -154
  20. package/src/commands/ls.mjs +107 -107
  21. package/src/commands/rm.mjs +190 -190
  22. package/src/commands/workspace.mjs +163 -163
  23. package/src/commands/write-hooks.mjs +185 -185
  24. package/src/compose-plan.mjs +228 -228
  25. package/src/compose.mjs +123 -123
  26. package/src/config.mjs +115 -115
  27. package/src/context.mjs +189 -189
  28. package/src/errors.mjs +8 -8
  29. package/src/forge.mjs +75 -75
  30. package/src/generate-hooks.mjs +367 -367
  31. package/src/git.mjs +86 -86
  32. package/src/home.mjs +48 -48
  33. package/src/hooks.mjs +135 -135
  34. package/src/names.mjs +65 -65
  35. package/src/occupancy-windows.ps1 +59 -59
  36. package/src/occupancy.mjs +131 -131
  37. package/src/ports.mjs +89 -45
  38. package/src/prompt.mjs +100 -100
  39. package/src/shared.mjs +97 -97
  40. package/src/status.mjs +30 -30
  41. package/src/tools.mjs +23 -23
  42. package/src/ui.mjs +60 -60
package/src/context.mjs CHANGED
@@ -1,189 +1,189 @@
1
- import { existsSync, renameSync } from "node:fs";
2
- import { basename, join, relative } from "node:path";
3
- import { applyChanges, loadConfig, saveConfig, syncInPlace, withLock, withLockSync } from "./config.mjs";
4
- import { NookError } from "./errors.mjs";
5
- import { homePaths, isInside, nookHome, samePath } from "./home.mjs";
6
- import { normalizeName } from "./names.mjs";
7
- import { gitTry, listWorktrees } from "./git.mjs";
8
- import { line, useColor } from "./ui.mjs";
9
-
10
- /** Shared state for one command run. */
11
- export function createContext({ env = process.env, cwd = process.cwd(), out = process.stdout, err = process.stderr } = {}) {
12
- const paths = homePaths(nookHome(env));
13
- const config = loadConfig(paths.config);
14
- const moveFailed = moveOldWorkspaceFolder(paths, config);
15
- // What this run changed is config minus base; on save it goes onto what is on disk now,
16
- // so changes another nk made meanwhile are kept.
17
- let base = structuredClone(config);
18
- const merge = () => {
19
- const disk = applyChanges(base, config, loadConfig(paths.config));
20
- saveConfig(paths.config, disk);
21
- syncInPlace(config, disk);
22
- base = structuredClone(config);
23
- };
24
- const color = useColor(err, env);
25
- const say = (kind) => (text) => err.write(`${line(kind, text, color)}\n`);
26
- if (moveFailed)
27
- say("warn")(`Could not rename ${paths.workspaces} to ${join(paths.home, "workspace")} (${moveFailed}); still using the old folder. Close programs open in it (terminals, editors, dev servers) and run nk again.`);
28
- return {
29
- env,
30
- cwd,
31
- paths,
32
- config,
33
- out,
34
- err,
35
- save() {
36
- withLockSync(paths.config, merge);
37
- },
38
- /**
39
- * Brings config up to date with disk, runs fn on it and saves, all under the lock: for choices
40
- * that must not clash with another nk running at the same time, such as a workspace's ports.
41
- */
42
- async update(fn) {
43
- return withLock(paths.config, async () => {
44
- merge();
45
- const result = await fn(config);
46
- merge();
47
- return result;
48
- });
49
- },
50
- print(text = "") {
51
- out.write(`${text}\n`);
52
- },
53
- note(text = "") {
54
- err.write(`${text}\n`);
55
- },
56
- step: say("step"),
57
- ok: say("ok"),
58
- warn: say("warn"),
59
- info: say("info"),
60
- item: say("item"),
61
- };
62
- }
63
-
64
- /**
65
- * Before 0.1 the folder was called "workspaces". Renames it once and tells git
66
- * where the workspaces went. When it cannot (a file is open on Windows), keeps
67
- * using the old folder and returns why.
68
- */
69
- function moveOldWorkspaceFolder(paths, config) {
70
- const old = join(paths.home, "workspaces");
71
- if (!existsSync(old) || existsSync(paths.workspaces)) return;
72
- const moves = [];
73
- for (const repo of Object.values(config.repos ?? {}))
74
- for (const ws of Object.values(repo.workspaces ?? {}))
75
- if (isInside(ws.path, old)) moves.push({ repo, ws, path: join(paths.workspaces, relative(old, ws.path)) });
76
- try {
77
- renameSync(old, paths.workspaces);
78
- } catch (error) {
79
- paths.workspaces = old;
80
- return error.code ?? error.message;
81
- }
82
- for (const move of moves) move.ws.path = move.path;
83
- for (const repo of new Set(moves.map((move) => move.repo)))
84
- gitTry(["worktree", "repair", ...moves.filter((move) => move.repo === repo).map((move) => move.path)], { cwd: repo.path });
85
- if (moves.length) saveConfig(paths.config, config);
86
- return null;
87
- }
88
-
89
- /** The registered repository `cwd` belongs to, or null. */
90
- export function repoFromCwd(ctx) {
91
- for (const [name, repo] of Object.entries(ctx.config.repos)) {
92
- if (isInside(ctx.cwd, join(ctx.paths.workspaces, name))) return name;
93
- if (isInside(ctx.cwd, repo.path)) return name;
94
- for (const ws of Object.values(repo.workspaces)) if (isInside(ctx.cwd, ws.path)) return name;
95
- }
96
- const top = gitTry(["rev-parse", "--show-toplevel"], { cwd: ctx.cwd });
97
- if (top.ok) {
98
- try {
99
- const [main] = listWorktrees(ctx.cwd);
100
- for (const [name, repo] of Object.entries(ctx.config.repos)) if (main && isInside(main.path, repo.path)) return name;
101
- } catch {
102
- /* Not a usable repository. */
103
- }
104
- }
105
- return null;
106
- }
107
-
108
- function requireRepo(ctx, name) {
109
- const repo = ctx.config.repos[name];
110
- if (!repo) throw new NookError(`Unknown repository "${name}". Registered: ${Object.keys(ctx.config.repos).join(", ") || "none"}`);
111
- return { name, repo };
112
- }
113
-
114
- /**
115
- * The repository a command acts on: --repo, else the one the current folder
116
- * belongs to (its main checkout or one of its workspaces). Like git and
117
- * worktrunk, nothing is guessed outside a repository.
118
- */
119
- export function resolveRepo(ctx, flag) {
120
- if (flag) return requireRepo(ctx, flag);
121
- const fromCwd = repoFromCwd(ctx);
122
- if (fromCwd) return requireRepo(ctx, fromCwd);
123
- if (!Object.keys(ctx.config.repos).length) throw new NookError("No repository yet. Run nk init inside a repository first");
124
- throw new NookError(
125
- `Not inside a registered repository. Run nk in the repository or one of its workspaces, or pass --repo (${Object.keys(ctx.config.repos).join(", ")})`,
126
- );
127
- }
128
-
129
- /**
130
- * Every worktree of a repository, as git lists them, joined with what Nook
131
- * recorded (port, creation time). Git is the source of truth, like worktrunk:
132
- * worktrees made by other tools show up too, without a port. The main
133
- * checkout comes first with `main: true`; workspaces Nook recorded whose
134
- * folder is gone come last.
135
- */
136
- export function repoWorkspaces(repoName, repo) {
137
- let trees = [];
138
- try {
139
- trees = listWorktrees(repo.path);
140
- } catch {
141
- /* Not a usable repository; nk doctor reports it. */
142
- }
143
- const items = [];
144
- const recorded = new Set();
145
- const taken = new Set(Object.keys(repo.workspaces));
146
- trees.forEach((tree, index) => {
147
- if (tree.bare) return;
148
- const found = Object.entries(repo.workspaces).find(([, ws]) => samePath(ws.path, tree.path));
149
- if (found) {
150
- recorded.add(found[0]);
151
- items.push({ repoName, repo, name: found[0], ws: { ...found[1], locked: tree.locked }, main: false, managed: true });
152
- return;
153
- }
154
- let name = normalizeName(tree.branch ?? basename(tree.path));
155
- if (index > 0 && (!name || taken.has(name))) name = normalizeName(basename(tree.path));
156
- taken.add(name);
157
- const ws = { path: tree.path, branch: tree.branch, locked: tree.locked };
158
- items.push({ repoName, repo, name, ws, main: index === 0, managed: false });
159
- });
160
- for (const [name, ws] of Object.entries(repo.workspaces))
161
- if (!recorded.has(name)) items.push({ repoName, repo, name, ws, main: false, managed: true });
162
- return items;
163
- }
164
-
165
- /** An existing workspace of the current repository (or --repo) by name: its branch or folder name, converted the same way. */
166
- export function resolveWorkspace(ctx, name, flag) {
167
- const { name: repoName, repo } = resolveRepo(ctx, flag);
168
- const items = repoWorkspaces(repoName, repo);
169
- const hit = items.find((item) => item.name === name) ?? items.find((item) => normalizeName(basename(item.ws.path)) === name);
170
- if (hit) return hit;
171
- throw new NookError(`No workspace named "${name}" in ${repoName}. Create it with: nk -b ${name}`);
172
- }
173
-
174
- /** The git branch of a workspace: its name, unless the branch had characters a name cannot use. */
175
- export function branchOf(name, ws) {
176
- return "branch" in ws ? ws.branch : name;
177
- }
178
-
179
- /** Variables hooks and agents see. */
180
- export function workspaceEnv(ctx, { repoName, repo, name, ws }) {
181
- return {
182
- ...ctx.env,
183
- NOOK_WORKSPACE: name,
184
- NOOK_PATH: ws.path,
185
- NOOK_REPO: repo.path,
186
- NOOK_SHARED: join(ctx.paths.shared, repoName),
187
- NOOK_PORT: String(ws.port ?? ""),
188
- };
189
- }
1
+ import { existsSync, renameSync } from "node:fs";
2
+ import { basename, join, relative } from "node:path";
3
+ import { applyChanges, loadConfig, saveConfig, syncInPlace, withLock, withLockSync } from "./config.mjs";
4
+ import { NookError } from "./errors.mjs";
5
+ import { homePaths, isInside, nookHome, samePath } from "./home.mjs";
6
+ import { normalizeName } from "./names.mjs";
7
+ import { gitTry, listWorktrees } from "./git.mjs";
8
+ import { line, useColor } from "./ui.mjs";
9
+
10
+ /** Shared state for one command run. */
11
+ export function createContext({ env = process.env, cwd = process.cwd(), out = process.stdout, err = process.stderr } = {}) {
12
+ const paths = homePaths(nookHome(env));
13
+ const config = loadConfig(paths.config);
14
+ const moveFailed = moveOldWorkspaceFolder(paths, config);
15
+ // What this run changed is config minus base; on save it goes onto what is on disk now,
16
+ // so changes another nk made meanwhile are kept.
17
+ let base = structuredClone(config);
18
+ const merge = () => {
19
+ const disk = applyChanges(base, config, loadConfig(paths.config));
20
+ saveConfig(paths.config, disk);
21
+ syncInPlace(config, disk);
22
+ base = structuredClone(config);
23
+ };
24
+ const color = useColor(err, env);
25
+ const say = (kind) => (text) => err.write(`${line(kind, text, color)}\n`);
26
+ if (moveFailed)
27
+ say("warn")(`Could not rename ${paths.workspaces} to ${join(paths.home, "workspace")} (${moveFailed}); still using the old folder. Close programs open in it (terminals, editors, dev servers) and run nk again.`);
28
+ return {
29
+ env,
30
+ cwd,
31
+ paths,
32
+ config,
33
+ out,
34
+ err,
35
+ save() {
36
+ withLockSync(paths.config, merge);
37
+ },
38
+ /**
39
+ * Brings config up to date with disk, runs fn on it and saves, all under the lock: for choices
40
+ * that must not clash with another nk running at the same time, such as a workspace's ports.
41
+ */
42
+ async update(fn) {
43
+ return withLock(paths.config, async () => {
44
+ merge();
45
+ const result = await fn(config);
46
+ merge();
47
+ return result;
48
+ });
49
+ },
50
+ print(text = "") {
51
+ out.write(`${text}\n`);
52
+ },
53
+ note(text = "") {
54
+ err.write(`${text}\n`);
55
+ },
56
+ step: say("step"),
57
+ ok: say("ok"),
58
+ warn: say("warn"),
59
+ info: say("info"),
60
+ item: say("item"),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Before 0.1 the folder was called "workspaces". Renames it once and tells git
66
+ * where the workspaces went. When it cannot (a file is open on Windows), keeps
67
+ * using the old folder and returns why.
68
+ */
69
+ function moveOldWorkspaceFolder(paths, config) {
70
+ const old = join(paths.home, "workspaces");
71
+ if (!existsSync(old) || existsSync(paths.workspaces)) return;
72
+ const moves = [];
73
+ for (const repo of Object.values(config.repos ?? {}))
74
+ for (const ws of Object.values(repo.workspaces ?? {}))
75
+ if (isInside(ws.path, old)) moves.push({ repo, ws, path: join(paths.workspaces, relative(old, ws.path)) });
76
+ try {
77
+ renameSync(old, paths.workspaces);
78
+ } catch (error) {
79
+ paths.workspaces = old;
80
+ return error.code ?? error.message;
81
+ }
82
+ for (const move of moves) move.ws.path = move.path;
83
+ for (const repo of new Set(moves.map((move) => move.repo)))
84
+ gitTry(["worktree", "repair", ...moves.filter((move) => move.repo === repo).map((move) => move.path)], { cwd: repo.path });
85
+ if (moves.length) saveConfig(paths.config, config);
86
+ return null;
87
+ }
88
+
89
+ /** The registered repository `cwd` belongs to, or null. */
90
+ export function repoFromCwd(ctx) {
91
+ for (const [name, repo] of Object.entries(ctx.config.repos)) {
92
+ if (isInside(ctx.cwd, join(ctx.paths.workspaces, name))) return name;
93
+ if (isInside(ctx.cwd, repo.path)) return name;
94
+ for (const ws of Object.values(repo.workspaces)) if (isInside(ctx.cwd, ws.path)) return name;
95
+ }
96
+ const top = gitTry(["rev-parse", "--show-toplevel"], { cwd: ctx.cwd });
97
+ if (top.ok) {
98
+ try {
99
+ const [main] = listWorktrees(ctx.cwd);
100
+ for (const [name, repo] of Object.entries(ctx.config.repos)) if (main && isInside(main.path, repo.path)) return name;
101
+ } catch {
102
+ /* Not a usable repository. */
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ function requireRepo(ctx, name) {
109
+ const repo = ctx.config.repos[name];
110
+ if (!repo) throw new NookError(`Unknown repository "${name}". Registered: ${Object.keys(ctx.config.repos).join(", ") || "none"}`);
111
+ return { name, repo };
112
+ }
113
+
114
+ /**
115
+ * The repository a command acts on: --repo, else the one the current folder
116
+ * belongs to (its main checkout or one of its workspaces). Like git and
117
+ * worktrunk, nothing is guessed outside a repository.
118
+ */
119
+ export function resolveRepo(ctx, flag) {
120
+ if (flag) return requireRepo(ctx, flag);
121
+ const fromCwd = repoFromCwd(ctx);
122
+ if (fromCwd) return requireRepo(ctx, fromCwd);
123
+ if (!Object.keys(ctx.config.repos).length) throw new NookError("No repository yet. Run nk init inside a repository first");
124
+ throw new NookError(
125
+ `Not inside a registered repository. Run nk in the repository or one of its workspaces, or pass --repo (${Object.keys(ctx.config.repos).join(", ")})`,
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Every worktree of a repository, as git lists them, joined with what Nook
131
+ * recorded (port, creation time). Git is the source of truth, like worktrunk:
132
+ * worktrees made by other tools show up too, without a port. The main
133
+ * checkout comes first with `main: true`; workspaces Nook recorded whose
134
+ * folder is gone come last.
135
+ */
136
+ export function repoWorkspaces(repoName, repo) {
137
+ let trees = [];
138
+ try {
139
+ trees = listWorktrees(repo.path);
140
+ } catch {
141
+ /* Not a usable repository; nk doctor reports it. */
142
+ }
143
+ const items = [];
144
+ const recorded = new Set();
145
+ const taken = new Set(Object.keys(repo.workspaces));
146
+ trees.forEach((tree, index) => {
147
+ if (tree.bare) return;
148
+ const found = Object.entries(repo.workspaces).find(([, ws]) => samePath(ws.path, tree.path));
149
+ if (found) {
150
+ recorded.add(found[0]);
151
+ items.push({ repoName, repo, name: found[0], ws: { ...found[1], locked: tree.locked }, main: false, managed: true });
152
+ return;
153
+ }
154
+ let name = normalizeName(tree.branch ?? basename(tree.path));
155
+ if (index > 0 && (!name || taken.has(name))) name = normalizeName(basename(tree.path));
156
+ taken.add(name);
157
+ const ws = { path: tree.path, branch: tree.branch, locked: tree.locked };
158
+ items.push({ repoName, repo, name, ws, main: index === 0, managed: false });
159
+ });
160
+ for (const [name, ws] of Object.entries(repo.workspaces))
161
+ if (!recorded.has(name)) items.push({ repoName, repo, name, ws, main: false, managed: true });
162
+ return items;
163
+ }
164
+
165
+ /** An existing workspace of the current repository (or --repo) by name: its branch or folder name, converted the same way. */
166
+ export function resolveWorkspace(ctx, name, flag) {
167
+ const { name: repoName, repo } = resolveRepo(ctx, flag);
168
+ const items = repoWorkspaces(repoName, repo);
169
+ const hit = items.find((item) => item.name === name) ?? items.find((item) => normalizeName(basename(item.ws.path)) === name);
170
+ if (hit) return hit;
171
+ throw new NookError(`No workspace named "${name}" in ${repoName}. Create it with: nk -b ${name}`);
172
+ }
173
+
174
+ /** The git branch of a workspace: its name, unless the branch had characters a name cannot use. */
175
+ export function branchOf(name, ws) {
176
+ return "branch" in ws ? ws.branch : name;
177
+ }
178
+
179
+ /** Variables hooks and agents see. */
180
+ export function workspaceEnv(ctx, { repoName, repo, name, ws }) {
181
+ return {
182
+ ...ctx.env,
183
+ NOOK_WORKSPACE: name,
184
+ NOOK_PATH: ws.path,
185
+ NOOK_REPO: repo.path,
186
+ NOOK_SHARED: join(ctx.paths.shared, repoName),
187
+ NOOK_PORT: String(ws.port ?? ""),
188
+ };
189
+ }
package/src/errors.mjs CHANGED
@@ -1,8 +1,8 @@
1
- /** An expected failure: printed as one line, without a stack trace. */
2
- export class NookError extends Error {
3
- constructor(message, { code = 1 } = {}) {
4
- super(message);
5
- this.name = "NookError";
6
- this.code = code;
7
- }
8
- }
1
+ /** An expected failure: printed as one line, without a stack trace. */
2
+ export class NookError extends Error {
3
+ constructor(message, { code = 1 } = {}) {
4
+ super(message);
5
+ this.name = "NookError";
6
+ this.code = code;
7
+ }
8
+ }
package/src/forge.mjs CHANGED
@@ -1,75 +1,75 @@
1
- import spawn from "cross-spawn";
2
- import { which } from "./agents.mjs";
3
-
4
- const FAILED = new Set(["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
5
- const PENDING = new Set(["PENDING", "EXPECTED", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED"]);
6
-
7
- /** One word for a PR's checks: passed, failed, running or no-ci. */
8
- export function ciState(checks = []) {
9
- if (!checks.length) return "no-ci";
10
- let running = false;
11
- for (const check of checks) {
12
- const result = check.conclusion || check.state || "";
13
- if (FAILED.has(result)) return "failed";
14
- if ((check.status && check.status !== "COMPLETED") || PENDING.has(result)) running = true;
15
- }
16
- return running ? "running" : "passed";
17
- }
18
-
19
- /** Newest PR per head branch, from `gh pr list --json` output (newest first). */
20
- export function parsePrList(text) {
21
- const prs = new Map();
22
- for (const pr of JSON.parse(text)) {
23
- if (prs.has(pr.headRefName)) continue;
24
- prs.set(pr.headRefName, {
25
- number: pr.number,
26
- state: pr.state.toLowerCase(),
27
- head: pr.headRefOid,
28
- url: pr.url,
29
- ci: pr.state === "OPEN" ? ciState(pr.statusCheckRollup) : null,
30
- });
31
- }
32
- return prs;
33
- }
34
-
35
- function explain(output) {
36
- if (/auth login|not logged|authentication/i.test(output)) return "gh is not logged in (run gh auth login)";
37
- if (/none of the git remotes|not a git repository|no git remotes/i.test(output)) return null;
38
- return `gh failed: ${output.split(/\r?\n/)[0] || "no output"}`;
39
- }
40
-
41
- /**
42
- * PRs of a repository from GitHub, keyed by branch, through the gh CLI.
43
- * Resolves to { prs } or { error } (null error: the repository is not on GitHub).
44
- */
45
- export function githubPrs(repoPath, env = process.env) {
46
- const gh = which("gh", env);
47
- if (!gh) return Promise.resolve({ error: "gh is not installed" });
48
- const fields = "number,state,headRefName,headRefOid,url,statusCheckRollup";
49
- return new Promise((done) => {
50
- const child = spawn(gh, ["pr", "list", "--state", "all", "--limit", "200", "--json", fields], {
51
- cwd: repoPath,
52
- env: { ...env, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" },
53
- windowsHide: true,
54
- });
55
- let out = "";
56
- let err = "";
57
- const timer = setTimeout(() => child.kill(), 20000);
58
- child.stdout.on("data", (chunk) => (out += chunk));
59
- child.stderr.on("data", (chunk) => (err += chunk));
60
- child.on("error", (error) => {
61
- clearTimeout(timer);
62
- done({ error: `gh failed: ${error.message}` });
63
- });
64
- child.on("close", (code, signal) => {
65
- clearTimeout(timer);
66
- if (signal) return done({ error: "gh timed out" });
67
- if (code !== 0) return done({ error: explain(err.trim()) });
68
- try {
69
- done({ prs: parsePrList(out) });
70
- } catch {
71
- done({ error: "gh returned output nk could not read" });
72
- }
73
- });
74
- });
75
- }
1
+ import spawn from "cross-spawn";
2
+ import { which } from "./agents.mjs";
3
+
4
+ const FAILED = new Set(["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
5
+ const PENDING = new Set(["PENDING", "EXPECTED", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED"]);
6
+
7
+ /** One word for a PR's checks: passed, failed, running or no-ci. */
8
+ export function ciState(checks = []) {
9
+ if (!checks.length) return "no-ci";
10
+ let running = false;
11
+ for (const check of checks) {
12
+ const result = check.conclusion || check.state || "";
13
+ if (FAILED.has(result)) return "failed";
14
+ if ((check.status && check.status !== "COMPLETED") || PENDING.has(result)) running = true;
15
+ }
16
+ return running ? "running" : "passed";
17
+ }
18
+
19
+ /** Newest PR per head branch, from `gh pr list --json` output (newest first). */
20
+ export function parsePrList(text) {
21
+ const prs = new Map();
22
+ for (const pr of JSON.parse(text)) {
23
+ if (prs.has(pr.headRefName)) continue;
24
+ prs.set(pr.headRefName, {
25
+ number: pr.number,
26
+ state: pr.state.toLowerCase(),
27
+ head: pr.headRefOid,
28
+ url: pr.url,
29
+ ci: pr.state === "OPEN" ? ciState(pr.statusCheckRollup) : null,
30
+ });
31
+ }
32
+ return prs;
33
+ }
34
+
35
+ function explain(output) {
36
+ if (/auth login|not logged|authentication/i.test(output)) return "gh is not logged in (run gh auth login)";
37
+ if (/none of the git remotes|not a git repository|no git remotes/i.test(output)) return null;
38
+ return `gh failed: ${output.split(/\r?\n/)[0] || "no output"}`;
39
+ }
40
+
41
+ /**
42
+ * PRs of a repository from GitHub, keyed by branch, through the gh CLI.
43
+ * Resolves to { prs } or { error } (null error: the repository is not on GitHub).
44
+ */
45
+ export function githubPrs(repoPath, env = process.env) {
46
+ const gh = which("gh", env);
47
+ if (!gh) return Promise.resolve({ error: "gh is not installed" });
48
+ const fields = "number,state,headRefName,headRefOid,url,statusCheckRollup";
49
+ return new Promise((done) => {
50
+ const child = spawn(gh, ["pr", "list", "--state", "all", "--limit", "200", "--json", fields], {
51
+ cwd: repoPath,
52
+ env: { ...env, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" },
53
+ windowsHide: true,
54
+ });
55
+ let out = "";
56
+ let err = "";
57
+ const timer = setTimeout(() => child.kill(), 20000);
58
+ child.stdout.on("data", (chunk) => (out += chunk));
59
+ child.stderr.on("data", (chunk) => (err += chunk));
60
+ child.on("error", (error) => {
61
+ clearTimeout(timer);
62
+ done({ error: `gh failed: ${error.message}` });
63
+ });
64
+ child.on("close", (code, signal) => {
65
+ clearTimeout(timer);
66
+ if (signal) return done({ error: "gh timed out" });
67
+ if (code !== 0) return done({ error: explain(err.trim()) });
68
+ try {
69
+ done({ prs: parsePrList(out) });
70
+ } catch {
71
+ done({ error: "gh returned output nk could not read" });
72
+ }
73
+ });
74
+ });
75
+ }