chapterhouse 0.5.1 → 0.6.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.
Files changed (41) hide show
  1. package/.pr-types.json +14 -0
  2. package/README.md +6 -0
  3. package/dist/api/server.js +5 -3
  4. package/dist/cli.js +4 -2
  5. package/dist/config.js +75 -13
  6. package/dist/config.test.js +73 -0
  7. package/dist/copilot/memory-coordinator.js +234 -0
  8. package/dist/copilot/memory-coordinator.test.js +257 -0
  9. package/dist/copilot/orchestrator.js +31 -212
  10. package/dist/copilot/orchestrator.test.js +111 -0
  11. package/dist/copilot/pr-title.js +92 -0
  12. package/dist/copilot/pr-title.test.js +54 -0
  13. package/dist/copilot/router.js +43 -8
  14. package/dist/copilot/router.test.js +60 -18
  15. package/dist/copilot/threat-model.js +50 -0
  16. package/dist/copilot/threat-model.test.js +129 -0
  17. package/dist/copilot/tools.js +65 -39
  18. package/dist/copilot/tools.wiki.test.js +15 -6
  19. package/dist/daemon.js +7 -2
  20. package/dist/integrations/team-push.js +8 -1
  21. package/dist/integrations/teams-notify.js +8 -1
  22. package/dist/memory/housekeeping.js +73 -25
  23. package/dist/memory/housekeeping.test.js +95 -3
  24. package/dist/memory/inbox.test.js +178 -0
  25. package/dist/memory/tiering.test.js +323 -0
  26. package/dist/mode-context.js +28 -0
  27. package/dist/mode-context.test.js +42 -0
  28. package/dist/setup.js +162 -95
  29. package/dist/setup.test.js +139 -0
  30. package/dist/sprint-merge.js +168 -0
  31. package/dist/sprint-merge.test.js +131 -0
  32. package/dist/store/db.js +63 -0
  33. package/dist/store/db.test.js +279 -0
  34. package/dist/wiki/team-sync.js +8 -1
  35. package/package.json +6 -1
  36. package/web/dist/assets/{index-BfHqP3-C.js → index-B5oDsQ5y.js} +84 -84
  37. package/web/dist/assets/index-B5oDsQ5y.js.map +1 -0
  38. package/web/dist/assets/index-DknKAtDS.css +10 -0
  39. package/web/dist/index.html +2 -2
  40. package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
  41. package/web/dist/assets/index-_O6AoWOS.css +0 -10
package/dist/setup.js CHANGED
@@ -1,7 +1,8 @@
1
- import * as readline from "readline";
2
- import { existsSync, readFileSync, writeFileSync } from "fs";
1
+ import * as readline from "node:readline";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
4
  import { CopilotClient } from "@github/copilot-sdk";
4
- import { ensureChapterhouseHome, ENV_PATH, CHAPTERHOUSE_HOME } from "./paths.js";
5
+ import { CHAPTERHOUSE_HOME, ensureChapterhouseHome, ENV_PATH, WIKI_DIR } from "./paths.js";
5
6
  import { getExampleProjectPath } from "./home-path.js";
6
7
  const BOLD = "\x1b[1m";
7
8
  const DIM = "\x1b[2m";
@@ -21,11 +22,11 @@ async function fetchModels() {
21
22
  await client.start();
22
23
  const models = await client.listModels();
23
24
  return models
24
- .filter((m) => m.policy?.state === "enabled" && !m.name.includes("(Internal only)"))
25
- .map((m) => {
26
- const mult = m.billing?.multiplier;
25
+ .filter((model) => model.policy?.state === "enabled" && !model.name.includes("(Internal only)"))
26
+ .map((model) => {
27
+ const mult = model.billing?.multiplier;
27
28
  const desc = mult === 0 || mult === undefined ? "Included with Copilot" : `Premium (${mult}x)`;
28
- return { id: m.id, label: m.name, desc };
29
+ return { id: model.id, label: model.name, desc };
29
30
  });
30
31
  }
31
32
  catch {
@@ -35,7 +36,9 @@ async function fetchModels() {
35
36
  try {
36
37
  await client?.stop();
37
38
  }
38
- catch { /* best-effort */ }
39
+ catch {
40
+ // best-effort cleanup
41
+ }
39
42
  }
40
43
  }
41
44
  function ask(rl, question) {
@@ -43,107 +46,171 @@ function ask(rl, question) {
43
46
  }
44
47
  async function askPicker(rl, label, options, defaultId) {
45
48
  console.log(`${BOLD}${label}${RESET}\n`);
46
- const defaultIdx = Math.max(0, options.findIndex((o) => o.id === defaultId));
47
- for (let i = 0; i < options.length; i++) {
48
- const marker = i === defaultIdx ? `${GREEN}▸${RESET}` : " ";
49
- const tag = i === defaultIdx ? ` ${DIM}(default)${RESET}` : "";
50
- console.log(` ${marker} ${CYAN}${i + 1}${RESET} ${options[i].label}${tag}`);
51
- console.log(` ${DIM}${options[i].desc}${RESET}`);
49
+ const defaultIdx = Math.max(0, options.findIndex((option) => option.id === defaultId));
50
+ for (let index = 0; index < options.length; index++) {
51
+ const option = options[index];
52
+ const marker = index === defaultIdx ? `${GREEN}▸${RESET}` : " ";
53
+ const tag = index === defaultIdx ? ` ${DIM}(default)${RESET}` : "";
54
+ console.log(` ${marker} ${CYAN}${index + 1}${RESET} ${option.label}${tag}`);
55
+ console.log(` ${DIM}${option.desc}${RESET}`);
52
56
  }
53
57
  console.log();
54
58
  const input = await ask(rl, ` Pick a number ${DIM}(1-${options.length}, Enter for default)${RESET}: `);
55
- const num = parseInt(input.trim(), 10);
56
- if (num >= 1 && num <= options.length)
59
+ const num = Number.parseInt(input.trim(), 10);
60
+ if (num >= 1 && num <= options.length) {
57
61
  return options[num - 1].id;
62
+ }
58
63
  return options[defaultIdx].id;
59
64
  }
60
- async function main() {
65
+ function readExistingEnv() {
66
+ const lines = existsSync(ENV_PATH)
67
+ ? readFileSync(ENV_PATH, "utf-8").split("\n").filter((line) => line.length > 0)
68
+ : [];
69
+ const values = {};
70
+ for (const line of lines) {
71
+ const match = line.match(/^([A-Z_]+)=(.*)$/);
72
+ if (match) {
73
+ values[match[1]] = match[2];
74
+ }
75
+ }
76
+ return { lines, values };
77
+ }
78
+ function resolveSetupMode(existing) {
79
+ const configured = process.env.CHAPTERHOUSE_MODE?.trim() || existing.CHAPTERHOUSE_MODE?.trim() || "personal";
80
+ return configured === "team" ? "team" : "personal";
81
+ }
82
+ function upsertEnvLines(lines, updates) {
83
+ const pending = new Map(Object.entries(updates)
84
+ .filter(([, value]) => value.trim().length > 0));
85
+ const nextLines = lines.map((line) => {
86
+ const match = line.match(/^([A-Z_]+)=(.*)$/);
87
+ if (!match) {
88
+ return line;
89
+ }
90
+ const key = match[1];
91
+ const replacement = pending.get(key);
92
+ if (replacement === undefined) {
93
+ return line;
94
+ }
95
+ pending.delete(key);
96
+ return `${key}=${replacement}`;
97
+ });
98
+ for (const [key, value] of pending) {
99
+ nextLines.push(`${key}=${value}`);
100
+ }
101
+ return nextLines;
102
+ }
103
+ function hasTokenEnv() {
104
+ return Boolean(process.env.GITHUB_TOKEN?.trim() || process.env.COPILOT_TOKEN?.trim());
105
+ }
106
+ function getGhAuthStatus() {
107
+ try {
108
+ const output = execFileSync("gh", ["auth", "status"], {
109
+ encoding: "utf-8",
110
+ stdio: ["ignore", "pipe", "pipe"],
111
+ });
112
+ return output.trim();
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ async function showWikiLocation(rl) {
119
+ console.log(`${BOLD}Personal wiki location${RESET}`);
120
+ console.log(` ${CYAN}${WIKI_DIR}${RESET}`);
121
+ console.log(`${DIM} Chapterhouse stores your local wiki here.${RESET}\n`);
122
+ await ask(rl, `${DIM}Press Enter to continue...${RESET}`);
123
+ console.log();
124
+ }
125
+ export async function runSetup() {
61
126
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
62
- console.log(`
127
+ try {
128
+ console.log(`
63
129
  ${BOLD}╔══════════════════════════════════════════╗
64
130
  ║ 🤖 Chapterhouse Setup ║
65
131
  ╚══════════════════════════════════════════╝${RESET}
66
132
  `);
67
- console.log(`${DIM}Config directory: ${CHAPTERHOUSE_HOME}${RESET}\n`);
68
- ensureChapterhouseHome();
69
- // Load existing values if any
70
- const existing = {};
71
- if (existsSync(ENV_PATH)) {
72
- for (const line of readFileSync(ENV_PATH, "utf-8").split("\n")) {
73
- const match = line.match(/^([A-Z_]+)=(.*)$/);
74
- if (match)
75
- existing[match[1]] = match[2];
133
+ console.log(`${DIM}Config directory: ${CHAPTERHOUSE_HOME}${RESET}\n`);
134
+ ensureChapterhouseHome();
135
+ const existing = readExistingEnv();
136
+ const mode = resolveSetupMode(existing.values);
137
+ console.log(`${BOLD}${mode === "personal" ? "Personal setup" : "Team setup"}${RESET}`);
138
+ if (mode === "personal") {
139
+ console.log("We'll get your GitHub/Copilot auth, preferred model, and personal wiki location dialed in.");
140
+ console.log(`${DIM}Team-only integrations stay out of the way in personal mode.${RESET}`);
76
141
  }
77
- }
78
- // ── What is Chapterhouse ──────────────────────────────────────────
79
- console.log(`${BOLD}Meet Chapterhouse${RESET}`);
80
- console.log(`Chapterhouse is your team-level AI assistant — an always-on daemon that runs on`);
81
- console.log(`your machine. Open the web UI in your browser and talk in plain English;`);
82
- console.log(`Chapterhouse handles the rest.`);
83
- console.log();
84
- console.log(`${CYAN}What Chapterhouse can do out of the box:${RESET}`);
85
- console.log(` • Have conversations and answer questions`);
86
- console.log(` • Spin up Copilot CLI sessions to code, debug, and run commands`);
87
- console.log(` Manage multiple background tasks simultaneously`);
88
- console.log(` • Maintain a shared engineering wiki of everything it learns about your team`);
89
- console.log();
90
- console.log(`${CYAN}Skills — teach Chapterhouse anything:${RESET}`);
91
- console.log(` Chapterhouse has a skill system that lets it learn new capabilities. There's`);
92
- console.log(` an open source library of community skills it can install, or it can`);
93
- console.log(` write its own from scratch.`);
94
- console.log();
95
- await ask(rl, `${DIM}Press Enter to continue...${RESET}`);
96
- console.log();
97
- // ── Model picker ─────────────────────────────────────────
98
- console.log(`\n${BOLD}━━━ Default Model ━━━${RESET}\n`);
99
- console.log(`${DIM}Fetching available models from Copilot...${RESET}`);
100
- let models = await fetchModels();
101
- if (models.length === 0) {
102
- console.log(`${YELLOW} Could not fetch models (Copilot CLI may not be authenticated yet).${RESET}`);
103
- console.log(`${DIM} Showing a curated list — you can switch anytime after setup.${RESET}\n`);
104
- models = FALLBACK_MODELS;
105
- }
106
- else {
107
- console.log(`${GREEN} ✓ Found ${models.length} models${RESET}\n`);
108
- }
109
- console.log(`${DIM}You can switch models anytime in the web UI's Settings page.${RESET}\n`);
110
- const currentModel = existing.COPILOT_MODEL || "claude-sonnet-4.6";
111
- const model = await askPicker(rl, "Choose a default model:", models, currentModel);
112
- const modelLabel = models.find((m) => m.id === model)?.label || model;
113
- console.log(`\n${GREEN} ✓ Using ${modelLabel}${RESET}\n`);
114
- // ── Write config ─────────────────────────────────────────
115
- const apiPort = existing.API_PORT || "7788";
116
- const lines = [];
117
- lines.push(`API_PORT=${apiPort}`);
118
- lines.push(`COPILOT_MODEL=${model}`);
119
- writeFileSync(ENV_PATH, lines.join("\n") + "\n");
120
- // ── Done ─────────────────────────────────────────────────
121
- console.log(`
142
+ else {
143
+ console.log("We'll keep the shared team configuration intact and confirm your local defaults.");
144
+ }
145
+ console.log();
146
+ await ask(rl, `${DIM}Press Enter to continue...${RESET}`);
147
+ console.log();
148
+ if (mode === "personal") {
149
+ const ghAuthStatus = hasTokenEnv() ? null : getGhAuthStatus();
150
+ if (!hasTokenEnv() && !ghAuthStatus) {
151
+ console.log(`${YELLOW}GitHub authentication is required before setup can continue.${RESET}`);
152
+ console.log("Run `gh auth login` to authenticate with GitHub, then re-run setup.");
153
+ console.log(`${DIM}If you already manage credentials via environment, set GITHUB_TOKEN or COPILOT_TOKEN before running setup.${RESET}`);
154
+ return;
155
+ }
156
+ if (ghAuthStatus) {
157
+ console.log(`${BOLD}GitHub CLI auth status${RESET}`);
158
+ console.log(`${DIM}Verified with gh auth status:${RESET}`);
159
+ console.log(ghAuthStatus);
160
+ console.log();
161
+ }
162
+ console.log();
163
+ await showWikiLocation(rl);
164
+ }
165
+ console.log(`\n${BOLD}━━━ Default Model ━━━${RESET}\n`);
166
+ console.log(`${DIM}Fetching available models from Copilot...${RESET}`);
167
+ let models = await fetchModels();
168
+ if (models.length === 0) {
169
+ console.log(`${YELLOW} Could not fetch models (Copilot CLI may not be authenticated yet).${RESET}`);
170
+ console.log(`${DIM} Showing a curated list — you can switch anytime after setup.${RESET}\n`);
171
+ models = FALLBACK_MODELS;
172
+ }
173
+ else {
174
+ console.log(`${GREEN} Found ${models.length} models${RESET}\n`);
175
+ }
176
+ console.log(`${DIM}You can switch models anytime in the web UI's Settings page.${RESET}\n`);
177
+ const currentModel = existing.values.COPILOT_MODEL || "claude-sonnet-4.6";
178
+ const model = await askPicker(rl, "Choose a default model:", models, currentModel);
179
+ const modelLabel = models.find((entry) => entry.id === model)?.label || model;
180
+ console.log(`\n${GREEN} ✓ Using ${modelLabel}${RESET}\n`);
181
+ const apiPort = existing.values.API_PORT || "7788";
182
+ const nextEnv = upsertEnvLines(existing.lines, {
183
+ API_PORT: apiPort,
184
+ CHAPTERHOUSE_MODE: mode,
185
+ COPILOT_MODEL: model,
186
+ });
187
+ writeFileSync(ENV_PATH, `${nextEnv.join("\n")}\n`);
188
+ console.log(`
122
189
  ${GREEN}${BOLD}✅ Chapterhouse is ready!${RESET}
123
190
  ${DIM}Config saved to ${ENV_PATH}${RESET}
124
-
125
- ${BOLD}Get started:${RESET}
126
-
127
- ${CYAN}1.${RESET} Make sure Copilot CLI is authenticated:
128
- ${BOLD}copilot login${RESET}
129
-
130
- ${CYAN}2.${RESET} Start Chapterhouse:
131
- ${BOLD}chapterhouse start${RESET}
132
-
133
- ${CYAN}3.${RESET} Open the web UI:
134
- ${BOLD}http://localhost:${apiPort}${RESET}
135
-
136
- ${BOLD}Things to try:${RESET}
137
-
138
- ${DIM}"Start working on the auth bug in ${getExampleProjectPath()}"${RESET}
139
- ${DIM}"What sessions are running?"${RESET}
140
- ${DIM}"Find me a skill for checking Gmail"${RESET}
141
- ${DIM}"Switch to gpt-4.1"${RESET}
142
191
  `);
143
- rl.close();
192
+ if (mode === "personal") {
193
+ console.log(`${BOLD}Your personal wiki lives at:${RESET}`);
194
+ console.log(` ${CYAN}${WIKI_DIR}${RESET}\n`);
195
+ }
196
+ console.log(`${BOLD}Get started:${RESET}
197
+ `);
198
+ console.log(` ${CYAN}1.${RESET} If needed, authenticate GitHub CLI:`);
199
+ console.log(` ${BOLD}gh auth login${RESET}\n`);
200
+ console.log(` ${CYAN}2.${RESET} Make sure Copilot CLI is authenticated:`);
201
+ console.log(` ${BOLD}copilot login${RESET}\n`);
202
+ console.log(` ${CYAN}3.${RESET} Start Chapterhouse:`);
203
+ console.log(` ${BOLD}chapterhouse start${RESET}\n`);
204
+ console.log(` ${CYAN}4.${RESET} Open the web UI:`);
205
+ console.log(` ${BOLD}http://localhost:${apiPort}${RESET}\n`);
206
+ console.log(`${BOLD}Things to try:${RESET}\n`);
207
+ console.log(` ${DIM}"Start working on the auth bug in ${getExampleProjectPath()}"${RESET}`);
208
+ console.log(` ${DIM}"What sessions are running?"${RESET}`);
209
+ console.log(` ${DIM}"Find me a skill for checking Gmail"${RESET}`);
210
+ console.log(` ${DIM}"Switch to gpt-4.1"${RESET}`);
211
+ }
212
+ finally {
213
+ rl.close();
214
+ }
144
215
  }
145
- main().catch((err) => {
146
- console.error("Setup failed:", err);
147
- process.exit(1);
148
- });
149
216
  //# sourceMappingURL=setup.js.map
@@ -0,0 +1,139 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ async function runSetupScript(t, options = {}) {
4
+ const output = [];
5
+ const prompts = [];
6
+ const ghAuthInvocations = [];
7
+ let writtenConfig = "";
8
+ const answers = [...(options.answers ?? [])];
9
+ t.mock.module("node:readline", {
10
+ namedExports: {
11
+ createInterface: () => ({
12
+ question: (prompt, callback) => {
13
+ prompts.push(prompt);
14
+ callback(answers.shift() ?? "");
15
+ },
16
+ close: () => { },
17
+ }),
18
+ },
19
+ });
20
+ t.mock.module("fs", {
21
+ namedExports: {
22
+ existsSync: () => options.existingEnv !== undefined,
23
+ readFileSync: () => options.existingEnv ?? "",
24
+ writeFileSync: (_path, content) => {
25
+ writtenConfig = content;
26
+ },
27
+ },
28
+ });
29
+ t.mock.module("./paths.js", {
30
+ namedExports: {
31
+ ensureChapterhouseHome: () => { },
32
+ ENV_PATH: "/workspace/.chapterhouse/.env",
33
+ CHAPTERHOUSE_HOME: "/workspace/.chapterhouse",
34
+ WIKI_DIR: "/workspace/.chapterhouse/wiki",
35
+ },
36
+ });
37
+ t.mock.module("./home-path.js", {
38
+ namedExports: {
39
+ getExampleProjectPath: () => "/workspace/example-project",
40
+ },
41
+ });
42
+ t.mock.module("node:child_process", {
43
+ namedExports: {
44
+ execFileSync: (command, args) => {
45
+ ghAuthInvocations.push({ command, args });
46
+ if (options.ghAuthStatus === "unauthenticated") {
47
+ throw new Error("gh auth status failed");
48
+ }
49
+ return "github.com\n ✓ Logged in";
50
+ },
51
+ },
52
+ });
53
+ t.mock.module("@github/copilot-sdk", {
54
+ namedExports: {
55
+ CopilotClient: class {
56
+ async start() { }
57
+ async stop() { }
58
+ async listModels() {
59
+ return (options.modelIds ?? ["claude-sonnet-4.6"]).map((id) => ({
60
+ id,
61
+ name: id,
62
+ policy: { state: "enabled" },
63
+ }));
64
+ }
65
+ },
66
+ },
67
+ });
68
+ t.mock.method(console, "log", (...args) => {
69
+ output.push(args.join(" "));
70
+ });
71
+ t.mock.method(console, "error", (...args) => {
72
+ output.push(args.join(" "));
73
+ });
74
+ const priorEnv = process.env;
75
+ process.env = {
76
+ ...priorEnv,
77
+ ...options.env,
78
+ };
79
+ try {
80
+ const setupModule = await import(new URL(`./setup.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
81
+ const { runSetup } = setupModule;
82
+ assert.equal(typeof runSetup, "function", "setup.ts should export runSetup");
83
+ await runSetup?.();
84
+ }
85
+ finally {
86
+ process.env = priorEnv;
87
+ }
88
+ return { output, prompts, writtenConfig, ghAuthInvocations };
89
+ }
90
+ test("personal setup uses existing gh auth and never prompts for tokens", async (t) => {
91
+ // Security: the wizard must not ask users to paste long-lived credentials into an interactive prompt.
92
+ const result = await runSetupScript(t, {
93
+ env: { CHAPTERHOUSE_MODE: "personal" },
94
+ answers: ["", "", "1"],
95
+ ghAuthStatus: "authenticated",
96
+ });
97
+ assert.doesNotMatch(result.prompts.join("\n"), /token/i);
98
+ assert.match(result.output.join("\n"), /wiki location/i);
99
+ assert.doesNotMatch(result.output.join("\n"), /ADO|Teams webhook|Entra/i);
100
+ assert.match(result.writtenConfig, /^CHAPTERHOUSE_MODE=personal$/m);
101
+ assert.match(result.writtenConfig, /^COPILOT_MODEL=claude-sonnet-4\.6$/m);
102
+ assert.doesNotMatch(result.writtenConfig, /^GITHUB_TOKEN=/m);
103
+ });
104
+ test("personal setup checks gh auth status and surfaces the authenticated result", async (t) => {
105
+ // Security: showing CLI auth state keeps credentials in GitHub CLI instead of training users to paste tokens into Chapterhouse.
106
+ const result = await runSetupScript(t, {
107
+ env: { CHAPTERHOUSE_MODE: "personal" },
108
+ answers: ["", "", "1"],
109
+ ghAuthStatus: "authenticated",
110
+ });
111
+ assert.deepEqual(result.ghAuthInvocations, [{ command: "gh", args: ["auth", "status"] }]);
112
+ assert.match(result.output.join("\n"), /gh auth status/i);
113
+ assert.match(result.output.join("\n"), /Logged in/i);
114
+ assert.doesNotMatch(result.prompts.join("\n"), /token/i);
115
+ });
116
+ test("personal setup accepts env var tokens silently without prompting", async (t) => {
117
+ // Security: pre-configured environment credentials should be honored without re-exposing them through setup prompts.
118
+ const result = await runSetupScript(t, {
119
+ env: { CHAPTERHOUSE_MODE: "personal", GITHUB_TOKEN: "ghp_env_token" },
120
+ answers: ["", "", "1"],
121
+ ghAuthStatus: "unauthenticated",
122
+ });
123
+ assert.doesNotMatch(result.prompts.join("\n"), /token/i);
124
+ assert.match(result.writtenConfig, /^CHAPTERHOUSE_MODE=personal$/m);
125
+ assert.match(result.writtenConfig, /^COPILOT_MODEL=claude-sonnet-4\.6$/m);
126
+ assert.doesNotMatch(result.writtenConfig, /^GITHUB_TOKEN=/m);
127
+ });
128
+ test("personal setup instructs users to run gh auth login when not authenticated", async (t) => {
129
+ // Security: failed auth should route users to GitHub CLI login flow, not to an unsafe token entry screen.
130
+ const result = await runSetupScript(t, {
131
+ env: { CHAPTERHOUSE_MODE: "personal" },
132
+ answers: [""],
133
+ ghAuthStatus: "unauthenticated",
134
+ });
135
+ assert.doesNotMatch(result.prompts.join("\n"), /token/i);
136
+ assert.match(result.output.join("\n"), /Run `gh auth login` to authenticate with GitHub, then re-run setup/i);
137
+ assert.equal(result.writtenConfig, "");
138
+ });
139
+ //# sourceMappingURL=setup.test.js.map
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { resolve } from "node:path";
5
+ export const USAGE = `Usage:\n ./scripts/merge-sprint.sh [--dry-run|-n] <pr> [pr...]\n\nExamples:\n ./scripts/merge-sprint.sh 264 265 266\n ./scripts/merge-sprint.sh --dry-run 264 265 266\n ./scripts/merge-sprint.sh -n 264 265 266`;
6
+ function isHelpFlag(arg) {
7
+ return arg === "--help" || arg === "-h";
8
+ }
9
+ function isDryRunFlag(arg) {
10
+ return arg === "--dry-run" || arg === "-n";
11
+ }
12
+ function formatDryRunLine(description, command, args) {
13
+ return `[DRY RUN] ${description}: ${commandText(command, args)}`;
14
+ }
15
+ function defaultRunner(command, args) {
16
+ return execFileSync(command, args, {
17
+ encoding: "utf8",
18
+ stdio: ["ignore", "pipe", "pipe"],
19
+ }).trim();
20
+ }
21
+ function commandText(command, args) {
22
+ return [command, ...args].join(" ");
23
+ }
24
+ function parsePrNumber(value) {
25
+ const pr = Number.parseInt(value, 10);
26
+ if (!Number.isInteger(pr) || pr <= 0) {
27
+ throw new Error(`Invalid PR number: ${value}\n${USAGE}`);
28
+ }
29
+ return pr;
30
+ }
31
+ export function parseSprintMergeArgs(argv) {
32
+ let dryRun = false;
33
+ const prArgs = [];
34
+ for (const arg of argv) {
35
+ if (isHelpFlag(arg)) {
36
+ throw new Error(USAGE);
37
+ }
38
+ if (isDryRunFlag(arg)) {
39
+ dryRun = true;
40
+ continue;
41
+ }
42
+ prArgs.push(arg);
43
+ }
44
+ if (prArgs.length === 0) {
45
+ throw new Error(USAGE);
46
+ }
47
+ return {
48
+ dryRun,
49
+ prs: prArgs.map(parsePrNumber),
50
+ };
51
+ }
52
+ function parsePrStatus(raw) {
53
+ const parsed = JSON.parse(raw);
54
+ return {
55
+ state: parsed.state ?? "UNKNOWN",
56
+ mergeable: parsed.mergeable ?? "UNKNOWN",
57
+ reviewDecision: parsed.reviewDecision ?? null,
58
+ };
59
+ }
60
+ function formatError(error) {
61
+ if (error instanceof Error) {
62
+ return error.message;
63
+ }
64
+ return String(error);
65
+ }
66
+ export async function runSprintMerge(prs, options = {}) {
67
+ const runner = options.runner ?? defaultRunner;
68
+ const dryRun = options.dryRun ?? false;
69
+ const lines = [];
70
+ if (dryRun) {
71
+ lines.push(`[DRY RUN] Merge order: ${prs.map((pr) => `#${pr}`).join(" -> ")}`);
72
+ for (let index = 0; index < prs.length; index += 1) {
73
+ const pr = prs[index];
74
+ const remaining = prs.slice(index + 1);
75
+ let status;
76
+ try {
77
+ const raw = runner("gh", ["pr", "view", String(pr), "--json", "state,mergeable,reviewDecision"]);
78
+ status = parsePrStatus(raw);
79
+ }
80
+ catch (error) {
81
+ lines.push(`[DRY RUN] PR #${pr}: fetch failed ❌ — ${formatError(error)}`);
82
+ continue;
83
+ }
84
+ const ok = status.state === "OPEN" && status.mergeable === "MERGEABLE";
85
+ const statePart = status.state === "OPEN"
86
+ ? `OPEN, mergeable: ${status.mergeable}`
87
+ : status.state;
88
+ const reviewPart = status.reviewDecision ? `, reviewDecision: ${status.reviewDecision}` : "";
89
+ const suffix = ok ? " ✅" : " ❌ — would be skipped";
90
+ lines.push(`[DRY RUN] PR #${pr}: ${statePart}${reviewPart}${suffix}`);
91
+ if (!ok)
92
+ continue;
93
+ lines.push(formatDryRunLine(`Would merge #${pr} with`, "gh", ["pr", "merge", String(pr), "--squash"]));
94
+ lines.push(formatDryRunLine("Would fetch origin/main with", "git", ["fetch", "origin", "main"]));
95
+ for (const nextPr of remaining) {
96
+ lines.push(formatDryRunLine(`Would refresh #${nextPr} with`, "gh", ["pr", "update-branch", String(nextPr)]));
97
+ }
98
+ }
99
+ return lines;
100
+ }
101
+ for (let index = 0; index < prs.length; index += 1) {
102
+ const pr = prs[index];
103
+ const remaining = prs.slice(index + 1);
104
+ lines.push(`Processing #${pr}...`);
105
+ try {
106
+ const status = parsePrStatus(runner("gh", ["pr", "view", String(pr), "--json", "state,mergeable,reviewDecision"]));
107
+ lines.push(` Status #${pr}: state=${status.state}, mergeable=${status.mergeable}, reviewDecision=${status.reviewDecision ?? "NONE"}`);
108
+ if (status.state !== "OPEN" || status.mergeable !== "MERGEABLE") {
109
+ lines.push(`Skipped #${pr}: mergeable=${status.mergeable}, state=${status.state}`);
110
+ continue;
111
+ }
112
+ runner("gh", ["pr", "merge", String(pr), "--squash"]);
113
+ lines.push(`Merged #${pr}.`);
114
+ }
115
+ catch (error) {
116
+ lines.push(`Failed #${pr}: ${formatError(error)}`);
117
+ continue;
118
+ }
119
+ try {
120
+ runner("git", ["fetch", "origin", "main"]);
121
+ lines.push(" Fetched origin/main (remote tracking ref updated, local branch untouched).");
122
+ }
123
+ catch (error) {
124
+ lines.push(` Failed to sync local main after #${pr}: ${formatError(error)}`);
125
+ }
126
+ for (const nextPr of remaining) {
127
+ try {
128
+ runner("gh", ["pr", "update-branch", String(nextPr)]);
129
+ lines.push(` Refreshed #${nextPr}.`);
130
+ }
131
+ catch (error) {
132
+ lines.push(` Failed to refresh #${nextPr}: ${formatError(error)}`);
133
+ }
134
+ }
135
+ lines.push(`Done #${pr}.`);
136
+ }
137
+ return lines;
138
+ }
139
+ export async function main(argv = process.argv.slice(2)) {
140
+ if (argv.some(isHelpFlag)) {
141
+ console.log(USAGE);
142
+ return 0;
143
+ }
144
+ let args;
145
+ try {
146
+ args = parseSprintMergeArgs(argv);
147
+ }
148
+ catch (error) {
149
+ console.error(formatError(error));
150
+ return 1;
151
+ }
152
+ const lines = await runSprintMerge(args.prs, { dryRun: args.dryRun });
153
+ for (const line of lines) {
154
+ console.log(line);
155
+ }
156
+ return 0;
157
+ }
158
+ const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
159
+ const modulePath = fileURLToPath(import.meta.url);
160
+ if (invokedPath === modulePath) {
161
+ main().then((code) => {
162
+ process.exitCode = code;
163
+ }).catch((error) => {
164
+ console.error(formatError(error));
165
+ process.exitCode = 1;
166
+ });
167
+ }
168
+ //# sourceMappingURL=sprint-merge.js.map