chapterhouse 0.5.2 → 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.
@@ -0,0 +1,50 @@
1
+ const THREAT_MODEL_PATTERNS = [
2
+ /(^|[\/._-])auth([\/._-]|$)/i,
3
+ /credential/i,
4
+ /(auth|access|bearer|refresh|session|payment|api)[-_.]?token|token[-_.]?(auth|key|secret|refresh|access|session|payment)/i,
5
+ /billing/i,
6
+ /subscription/i,
7
+ /api[-_]?key/i,
8
+ /(^|[\/._-])tiers?([\/._-]|$)/i,
9
+ ];
10
+ function normalizeChangedFiles(changedFiles) {
11
+ return changedFiles.map((file) => file.trim()).filter(Boolean);
12
+ }
13
+ export function findThreatModelMatches(changedFiles) {
14
+ return normalizeChangedFiles(changedFiles).filter((file) => THREAT_MODEL_PATTERNS.some((pattern) => pattern.test(file)));
15
+ }
16
+ export function hasThreatModelSection(prBody) {
17
+ const stripped = (prBody ?? "").replace(/<!--[\s\S]*?-->/g, "");
18
+ return /^##+\s+Threat Model\b/im.test(stripped);
19
+ }
20
+ export function evaluateThreatModelCheck(input) {
21
+ const matchedFiles = findThreatModelMatches(input.changedFiles);
22
+ if (matchedFiles.length === 0) {
23
+ return {
24
+ required: false,
25
+ valid: true,
26
+ matchedFiles: [],
27
+ message: "✅ No auth/credentials/billing file changes detected — a threat model section is not required.",
28
+ };
29
+ }
30
+ if (hasThreatModelSection(input.prBody)) {
31
+ return {
32
+ required: true,
33
+ valid: true,
34
+ matchedFiles,
35
+ message: "✅ Threat model section present for auth/credentials/billing changes.",
36
+ };
37
+ }
38
+ return {
39
+ required: true,
40
+ valid: false,
41
+ matchedFiles,
42
+ message: [
43
+ "⚠️ This PR modifies auth/credentials/billing files. Add a '## Threat Model' section to the PR description explaining the security implications.",
44
+ "",
45
+ "Matched files:",
46
+ ...matchedFiles.map((file) => `- ${file}`),
47
+ ].join("\n"),
48
+ };
49
+ }
50
+ //# sourceMappingURL=threat-model.js.map
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ async function loadThreatModelModule() {
6
+ const nonce = `${Date.now()}-${Math.random()}`;
7
+ const moduleUrl = new URL(`./threat-model.js?case=${nonce}`, import.meta.url);
8
+ const module = await import(moduleUrl.href).catch(() => null);
9
+ assert.ok(module, "expected threat-model module to exist");
10
+ return module;
11
+ }
12
+ test("requires a threat model section when auth, credential, token, or billing files change", async () => {
13
+ const { evaluateThreatModelCheck } = await loadThreatModelModule();
14
+ const result = evaluateThreatModelCheck({
15
+ changedFiles: [
16
+ "src/api/auth.ts",
17
+ "src/auth/session-token.ts",
18
+ "docs/architecture.md",
19
+ "src/billing/subscription-plan.ts",
20
+ ],
21
+ prBody: "## Summary\nAdds validation for token refresh.\n",
22
+ });
23
+ assert.equal(result.required, true);
24
+ assert.equal(result.valid, false);
25
+ assert.deepEqual(result.matchedFiles, [
26
+ "src/api/auth.ts",
27
+ "src/auth/session-token.ts",
28
+ "src/billing/subscription-plan.ts",
29
+ ]);
30
+ assert.match(result.message, /This PR modifies auth\/credentials\/billing files/i);
31
+ assert.match(result.message, /## Threat Model/);
32
+ });
33
+ test("accepts PRs with matching files once the threat model section is present", async () => {
34
+ const { evaluateThreatModelCheck } = await loadThreatModelModule();
35
+ const result = evaluateThreatModelCheck({
36
+ changedFiles: ["src/copilot/auth.ts", "src/config.ts"],
37
+ prBody: [
38
+ "## Summary",
39
+ "Hardens auth bootstrap behavior.",
40
+ "",
41
+ "## Threat Model",
42
+ "Tokens stay in-memory only; no new credentials are persisted.",
43
+ ].join("\n"),
44
+ });
45
+ assert.equal(result.required, true);
46
+ assert.equal(result.valid, true);
47
+ assert.deepEqual(result.matchedFiles, ["src/copilot/auth.ts"]);
48
+ });
49
+ test("skips the requirement when changed files are outside auth, credentials, and billing", async () => {
50
+ const { evaluateThreatModelCheck } = await loadThreatModelModule();
51
+ const result = evaluateThreatModelCheck({
52
+ changedFiles: ["src/wiki/index.ts", "web/src/routes/Chat.tsx"],
53
+ prBody: "## Summary\nUI cleanup only.\n",
54
+ });
55
+ assert.equal(result.required, false);
56
+ assert.equal(result.valid, true);
57
+ assert.deepEqual(result.matchedFiles, []);
58
+ assert.match(result.message, /not required/i);
59
+ });
60
+ test("does NOT flag lexer or NLP files with 'token' in the name (false-positive guard)", async () => {
61
+ const { evaluateThreatModelCheck } = await loadThreatModelModule();
62
+ const result = evaluateThreatModelCheck({
63
+ changedFiles: [
64
+ "src/parser/tokenizer.ts",
65
+ "src/nlp/token-embeddings.ts",
66
+ "src/utils/tokenize.ts",
67
+ "src/copilot/token-cache.ts",
68
+ "web/src/components/TokenDisplay.tsx",
69
+ ],
70
+ prBody: "## Summary\nRefactors NLP pipeline.\n",
71
+ });
72
+ assert.equal(result.required, false);
73
+ assert.equal(result.valid, true);
74
+ assert.deepEqual(result.matchedFiles, []);
75
+ assert.match(result.message, /not required/i);
76
+ });
77
+ test("correctly flags security-relevant token files (auth-token, session-token, payment-token)", async () => {
78
+ const { evaluateThreatModelCheck } = await loadThreatModelModule();
79
+ const securityFiles = [
80
+ "src/auth/session-token.ts",
81
+ "src/billing/payment-token.ts",
82
+ "src/credentials/access-token-store.ts",
83
+ "src/api/bearer-token-middleware.ts",
84
+ "src/copilot/refresh-token.ts",
85
+ "src/auth/token-secret.ts",
86
+ ];
87
+ for (const file of securityFiles) {
88
+ const result = evaluateThreatModelCheck({
89
+ changedFiles: [file],
90
+ prBody: "## Summary\nNo threat model section.\n",
91
+ });
92
+ assert.equal(result.required, true, `Expected ${file} to trigger threat-model gate`);
93
+ assert.deepEqual(result.matchedFiles, [file]);
94
+ }
95
+ });
96
+ test("hasThreatModelSection returns false when the section exists only inside an HTML comment (template placeholder)", async () => {
97
+ const { evaluateThreatModelCheck } = await loadThreatModelModule();
98
+ // Simulates a PR body where the author left the default template untouched —
99
+ // the Threat Model heading is present only inside an HTML comment block.
100
+ const templatePlaceholderBody = [
101
+ "## Summary",
102
+ "Adds a new auth flow.",
103
+ "",
104
+ "<!--",
105
+ "## Threat Model",
106
+ "",
107
+ "If this PR touches auth, credentials, API keys, billing tiers, or subscription logic — fill in the Threat Model section.",
108
+ "Otherwise delete it.",
109
+ "",
110
+ "- Risk:",
111
+ "- Mitigations:",
112
+ "- Reviewer focus:",
113
+ "-->",
114
+ ].join("\n");
115
+ const result = evaluateThreatModelCheck({
116
+ changedFiles: ["src/auth/session-token.ts"],
117
+ prBody: templatePlaceholderBody,
118
+ });
119
+ assert.equal(result.required, true);
120
+ assert.equal(result.valid, false, "A Threat Model section inside an HTML comment must NOT satisfy the gate");
121
+ assert.match(result.message, /## Threat Model/);
122
+ });
123
+ test("PR template reminds authors about the threat model section", () => {
124
+ const template = readFileSync(join(process.cwd(), ".github", "PULL_REQUEST_TEMPLATE.md"), "utf8");
125
+ assert.match(template, /Threat Model/);
126
+ assert.match(template, /If this PR touches auth, credentials, API keys, billing tiers, or subscription logic/i);
127
+ assert.match(template, /Otherwise delete it/i);
128
+ });
129
+ //# sourceMappingURL=threat-model.test.js.map
@@ -53,6 +53,21 @@ function yamlListItem(value) {
53
53
  function indexSafe(text) {
54
54
  return text.replace(/[\r\n|]/g, " ").trim();
55
55
  }
56
+ function sanitizeWikiUpdateError(err) {
57
+ if (err instanceof z.ZodError) {
58
+ return err.issues.map((issue) => issue.message).join("; ") || "Invalid wiki_update arguments.";
59
+ }
60
+ const message = err instanceof Error ? err.message : String(err);
61
+ if (message.startsWith("Wiki page frontmatter violates the required shape:")
62
+ || message.startsWith("Wiki path")
63
+ || message.startsWith("Wiki page paths must end in .md:")
64
+ || message.startsWith("Refused unsafe wiki path:")
65
+ || message.startsWith("Refused: only pages under pages/")
66
+ || message === "Wiki path is required") {
67
+ return message;
68
+ }
69
+ return "Wiki update failed. Check the page path and frontmatter, then try again.";
70
+ }
56
71
  function isTimeoutError(err) {
57
72
  const msg = err instanceof Error ? err.message : String(err);
58
73
  return /timeout|timed?\s*out/i.test(msg);
@@ -189,6 +204,13 @@ const memoryProposeArgsSchema = z.object({
189
204
  }
190
205
  });
191
206
  const memoryTierTableSchema = z.enum(["observation", "decision", "entity", "action_item"]);
207
+ const wikiUpdateArgsSchema = z.object({
208
+ path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/chapterhouse/index.md', 'pages/projects/chapterhouse/decisions.md', 'pages/people/brian/index.md')"),
209
+ title: z.string().describe("Page title for the index"),
210
+ summary: z.string().describe("One-line summary for the index"),
211
+ section: z.string().optional().describe("Index section (default: 'Knowledge')"),
212
+ content: z.string().describe("Full page content (markdown)"),
213
+ });
192
214
  function getCurrentQuarter(now = new Date()) {
193
215
  return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
194
216
  }
@@ -1661,46 +1683,48 @@ export function createTools(deps) {
1661
1683
  "topic overview or a facet name (e.g. 'decisions', 'feature-ideas') — exactly one topic level, " +
1662
1684
  "lowercase slugs only. Flat-category pages MUST be 'pages/<category>.md' (preferences, facts, " +
1663
1685
  "routines, decisions). Bad paths are rejected with a suggested correction.",
1664
- parameters: z.object({
1665
- path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/chapterhouse/index.md', 'pages/projects/chapterhouse/decisions.md', 'pages/people/brian/index.md')"),
1666
- title: z.string().describe("Page title for the index"),
1667
- summary: z.string().describe("One-line summary for the index"),
1668
- section: z.string().optional().describe("Index section (default: 'Knowledge')"),
1669
- content: z.string().describe("Full page content (markdown)"),
1670
- }),
1686
+ parameters: wikiUpdateArgsSchema,
1671
1687
  handler: async (args) => {
1672
- return withWikiWrite(async () => {
1673
- ensureWikiStructure();
1674
- assertPagePath(args.path);
1675
- const validation = validateWikiFrontmatter(args.content, {
1676
- allowedTags: loadTaxonomy(),
1677
- });
1678
- if (!validation.valid) {
1679
- throw new Error(validation.errors.map((error) => error.message).join("\n\n"));
1680
- }
1681
- writePage(args.path, args.content);
1682
- // Rebuild from disk so the index summary/tags/updated reflect the actual page.
1683
- const today = new Date().toISOString().slice(0, 10);
1684
- const rebuilt = buildIndexEntryForPage(args.path, {
1685
- section: args.section || "Knowledge",
1686
- updated: today,
1687
- });
1688
- if (rebuilt) {
1689
- rebuilt.section = args.section || "Knowledge";
1690
- addToIndex(rebuilt);
1691
- }
1692
- else {
1693
- addToIndex({
1694
- path: args.path,
1695
- title: args.title,
1696
- summary: indexSafe(args.summary).slice(0, 160),
1697
- section: args.section || "Knowledge",
1688
+ try {
1689
+ const parsedArgs = wikiUpdateArgsSchema.parse(args);
1690
+ return await withWikiWrite(async () => {
1691
+ ensureWikiStructure();
1692
+ assertPagePath(parsedArgs.path);
1693
+ const validation = validateWikiFrontmatter(parsedArgs.content, {
1694
+ allowedTags: loadTaxonomy(),
1695
+ });
1696
+ if (!validation.valid) {
1697
+ throw new Error(validation.errors.map((error) => error.message).join("\n\n"));
1698
+ }
1699
+ writePage(parsedArgs.path, parsedArgs.content);
1700
+ // Rebuild from disk so the index summary/tags/updated reflect the actual page.
1701
+ const today = new Date().toISOString().slice(0, 10);
1702
+ const rebuilt = buildIndexEntryForPage(parsedArgs.path, {
1703
+ section: parsedArgs.section || "Knowledge",
1698
1704
  updated: today,
1699
1705
  });
1700
- }
1701
- appendLog("update", `wiki_update: ${indexSafe(args.title)} (${args.path})`);
1702
- return `Wiki page updated: ${args.title} (${args.path})`;
1703
- });
1706
+ if (rebuilt) {
1707
+ rebuilt.section = parsedArgs.section || "Knowledge";
1708
+ addToIndex(rebuilt);
1709
+ }
1710
+ else {
1711
+ addToIndex({
1712
+ path: parsedArgs.path,
1713
+ title: parsedArgs.title,
1714
+ summary: indexSafe(parsedArgs.summary).slice(0, 160),
1715
+ section: parsedArgs.section || "Knowledge",
1716
+ updated: today,
1717
+ });
1718
+ }
1719
+ appendLog("update", `wiki_update: ${indexSafe(parsedArgs.title)} (${parsedArgs.path})`);
1720
+ return `Wiki page updated: ${parsedArgs.title} (${parsedArgs.path})`;
1721
+ });
1722
+ }
1723
+ catch (err) {
1724
+ const error = sanitizeWikiUpdateError(err);
1725
+ log.error({ err: err instanceof Error ? err.message : err, path: typeof args?.path === "string" ? args.path : undefined }, "wiki_update failed");
1726
+ return { error };
1727
+ }
1704
1728
  },
1705
1729
  }),
1706
1730
  defineTool("wiki_ingest", {
@@ -23,7 +23,7 @@ test.afterEach(async () => {
23
23
  rmSync(home, { recursive: true, force: true });
24
24
  }
25
25
  });
26
- test("wiki_update rejects content without required frontmatter", async () => {
26
+ test("wiki_update returns validation errors instead of throwing for invalid frontmatter", async () => {
27
27
  const toolsModule = await loadToolsModule();
28
28
  const tools = toolsModule.createTools({
29
29
  client: { async listModels() { return []; } },
@@ -31,14 +31,19 @@ test("wiki_update rejects content without required frontmatter", async () => {
31
31
  });
32
32
  const tool = tools.find((entry) => entry.name === "wiki_update");
33
33
  assert.ok(tool);
34
- await assert.rejects(tool.handler({
34
+ const result = await tool.handler({
35
35
  path: "pages/shared/chapterhouse.md",
36
36
  title: "Chapterhouse",
37
37
  summary: "Runtime notes",
38
38
  content: "# Chapterhouse\n\nRuntime notes.\n",
39
- }), /Wiki page frontmatter violates the required shape/i);
39
+ });
40
+ assert.deepEqual(result, {
41
+ error: "Wiki page frontmatter violates the required shape: missing YAML frontmatter. Use:\n---\ntitle: <title>\nsummary: <plain-text one-line summary, max 200 chars>\nupdated: YYYY-MM-DD\ntags: []\nrelated: []\n---",
42
+ });
43
+ const wikiFs = await readWikiArtifacts();
44
+ assert.equal(wikiFs.readPage("pages/shared/chapterhouse.md"), undefined);
40
45
  });
41
- test("wiki_update rejects malformed summaries and unknown tags", async () => {
46
+ test("wiki_update returns descriptive validation errors for malformed summaries and unknown tags", async () => {
42
47
  const toolsModule = await loadToolsModule();
43
48
  const tools = toolsModule.createTools({
44
49
  client: { async listModels() { return []; } },
@@ -46,7 +51,7 @@ test("wiki_update rejects malformed summaries and unknown tags", async () => {
46
51
  });
47
52
  const tool = tools.find((entry) => entry.name === "wiki_update");
48
53
  assert.ok(tool);
49
- await assert.rejects(tool.handler({
54
+ const result = await tool.handler({
50
55
  path: "pages/shared/chapterhouse.md",
51
56
  title: "Chapterhouse",
52
57
  summary: "Runtime notes",
@@ -60,7 +65,11 @@ tags: [engineering, made-up-tag]
60
65
 
61
66
  Runtime notes.
62
67
  `,
63
- }), /Add it to `pages\/_meta\/taxonomy\.md` first\./);
68
+ });
69
+ assert.equal(typeof result, "object");
70
+ assert.match(result.error, /invalid 'summary'/i);
71
+ assert.match(result.error, /unknown tag 'made-up-tag'/i);
72
+ assert.match(result.error, /pages\/_meta\/taxonomy\.md/);
64
73
  });
65
74
  test("wiki_update accepts valid frontmatter and refreshes the index entry", async () => {
66
75
  const toolsModule = await loadToolsModule();
package/dist/setup.js CHANGED
@@ -103,13 +103,16 @@ function upsertEnvLines(lines, updates) {
103
103
  function hasTokenEnv() {
104
104
  return Boolean(process.env.GITHUB_TOKEN?.trim() || process.env.COPILOT_TOKEN?.trim());
105
105
  }
106
- function hasGhAuth() {
106
+ function getGhAuthStatus() {
107
107
  try {
108
- execFileSync("gh", ["auth", "status"], { stdio: "ignore" });
109
- return true;
108
+ const output = execFileSync("gh", ["auth", "status"], {
109
+ encoding: "utf-8",
110
+ stdio: ["ignore", "pipe", "pipe"],
111
+ });
112
+ return output.trim();
110
113
  }
111
114
  catch {
112
- return false;
115
+ return null;
113
116
  }
114
117
  }
115
118
  async function showWikiLocation(rl) {
@@ -143,12 +146,19 @@ ${BOLD}╔═══════════════════════
143
146
  await ask(rl, `${DIM}Press Enter to continue...${RESET}`);
144
147
  console.log();
145
148
  if (mode === "personal") {
146
- if (!hasTokenEnv() && !hasGhAuth()) {
149
+ const ghAuthStatus = hasTokenEnv() ? null : getGhAuthStatus();
150
+ if (!hasTokenEnv() && !ghAuthStatus) {
147
151
  console.log(`${YELLOW}GitHub authentication is required before setup can continue.${RESET}`);
148
152
  console.log("Run `gh auth login` to authenticate with GitHub, then re-run setup.");
149
153
  console.log(`${DIM}If you already manage credentials via environment, set GITHUB_TOKEN or COPILOT_TOKEN before running setup.${RESET}`);
150
154
  return;
151
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
+ }
152
162
  console.log();
153
163
  await showWikiLocation(rl);
154
164
  }
@@ -3,6 +3,7 @@ import test from "node:test";
3
3
  async function runSetupScript(t, options = {}) {
4
4
  const output = [];
5
5
  const prompts = [];
6
+ const ghAuthInvocations = [];
6
7
  let writtenConfig = "";
7
8
  const answers = [...(options.answers ?? [])];
8
9
  t.mock.module("node:readline", {
@@ -40,11 +41,12 @@ async function runSetupScript(t, options = {}) {
40
41
  });
41
42
  t.mock.module("node:child_process", {
42
43
  namedExports: {
43
- execFileSync: () => {
44
+ execFileSync: (command, args) => {
45
+ ghAuthInvocations.push({ command, args });
44
46
  if (options.ghAuthStatus === "unauthenticated") {
45
47
  throw new Error("gh auth status failed");
46
48
  }
47
- return Buffer.from("github.com\n ✓ Logged in");
49
+ return "github.com\n ✓ Logged in";
48
50
  },
49
51
  },
50
52
  });
@@ -83,9 +85,10 @@ async function runSetupScript(t, options = {}) {
83
85
  finally {
84
86
  process.env = priorEnv;
85
87
  }
86
- return { output, prompts, writtenConfig };
88
+ return { output, prompts, writtenConfig, ghAuthInvocations };
87
89
  }
88
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.
89
92
  const result = await runSetupScript(t, {
90
93
  env: { CHAPTERHOUSE_MODE: "personal" },
91
94
  answers: ["", "", "1"],
@@ -98,7 +101,20 @@ test("personal setup uses existing gh auth and never prompts for tokens", async
98
101
  assert.match(result.writtenConfig, /^COPILOT_MODEL=claude-sonnet-4\.6$/m);
99
102
  assert.doesNotMatch(result.writtenConfig, /^GITHUB_TOKEN=/m);
100
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
+ });
101
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.
102
118
  const result = await runSetupScript(t, {
103
119
  env: { CHAPTERHOUSE_MODE: "personal", GITHUB_TOKEN: "ghp_env_token" },
104
120
  answers: ["", "", "1"],
@@ -110,6 +126,7 @@ test("personal setup accepts env var tokens silently without prompting", async (
110
126
  assert.doesNotMatch(result.writtenConfig, /^GITHUB_TOKEN=/m);
111
127
  });
112
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.
113
130
  const result = await runSetupScript(t, {
114
131
  env: { CHAPTERHOUSE_MODE: "personal" },
115
132
  answers: [""],
@@ -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