merge-steward 0.33.2 → 0.34.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/dist/cli/args.js CHANGED
@@ -58,16 +58,8 @@ export function validateFlags(parsed) {
58
58
  assertKnownFlags(parsed, "root", []);
59
59
  return;
60
60
  case "dashboard":
61
- case "dash":
62
- case "d":
63
61
  assertKnownFlags(parsed, "root", ["repo", "pr"]);
64
62
  return;
65
- case "attach":
66
- assertKnownFlags(parsed, "repos", ["base-branch", "label", "merge-queue-check-name", "refresh", "json"]);
67
- return;
68
- case "repos":
69
- assertKnownFlags(parsed, "repos", ["json"]);
70
- return;
71
63
  case "repo":
72
64
  switch (subcommand) {
73
65
  case undefined:
@@ -10,7 +10,7 @@ function deriveRepoId(repoFullName) {
10
10
  .replace(/^[^a-z0-9]+/, "")
11
11
  .replace(/[^a-z0-9]+$/, "");
12
12
  if (!normalized) {
13
- throw new UsageError(`Could not derive a repo id from ${repoFullName}. Pass an explicit <id>.`, "repos");
13
+ throw new UsageError(`Could not derive a repo id from ${repoFullName}. Pass an explicit <id>.`, "repo");
14
14
  }
15
15
  return normalized;
16
16
  }
@@ -18,13 +18,13 @@ function parseAttachTarget(parsed) {
18
18
  const first = parsed.positionals[1];
19
19
  const second = parsed.positionals[2];
20
20
  if (!first) {
21
- throw new UsageError("merge-steward attach requires <owner/repo> or <id> <owner/repo>.", "repos");
21
+ throw new UsageError("merge-steward repo attach requires <owner/repo> or <id> <owner/repo>.", "repo");
22
22
  }
23
23
  if (second) {
24
24
  return { repoId: first, repoFullName: second };
25
25
  }
26
26
  if (!first.includes("/")) {
27
- throw new UsageError("merge-steward attach requires <owner/repo> or <id> <owner/repo>.", "repos");
27
+ throw new UsageError("merge-steward repo attach requires <owner/repo> or <id> <owner/repo>.", "repo");
28
28
  }
29
29
  const existing = listRepoConfigs().find((repo) => repo.repoFullName === first);
30
30
  if (existing) {
@@ -33,7 +33,7 @@ function parseAttachTarget(parsed) {
33
33
  const repoId = deriveRepoId(first);
34
34
  const conflict = listRepoConfigs().find((repo) => repo.repoId === repoId && repo.repoFullName !== first);
35
35
  if (conflict) {
36
- throw new UsageError(`Derived repo id '${repoId}' is already used by ${conflict.repoFullName}. Pass an explicit <id>.`, "repos");
36
+ throw new UsageError(`Derived repo id '${repoId}' is already used by ${conflict.repoFullName}. Pass an explicit <id>.`, "repo");
37
37
  }
38
38
  return { repoId, repoFullName: first };
39
39
  }
@@ -47,7 +47,7 @@ export async function handleInit(parsed, stdout, runCommand) {
47
47
  "",
48
48
  "Next steps:",
49
49
  `1. Put the webhook secret and GitHub App credentials into ${home.serviceEnvPath} for dev or systemd-creds for prod`,
50
- "2. Run `merge-steward attach <owner/repo>`",
50
+ "2. Run `merge-steward repo attach <owner/repo>`",
51
51
  "3. Configure your GitHub App webhook URL to the webhook URL above",
52
52
  "4. Run `merge-steward doctor --repo <id>`",
53
53
  ].join("\n") + "\n");
@@ -9,7 +9,7 @@ export async function handleRepos(parsed, stdout) {
9
9
  return 0;
10
10
  }
11
11
  if (repos.length === 0) {
12
- writeOutput(stdout, "No attached repositories yet. Run `merge-steward attach <owner/repo>`.\n");
12
+ writeOutput(stdout, "No attached repositories yet. Run `merge-steward repo attach <owner/repo>`.\n");
13
13
  return 0;
14
14
  }
15
15
  writeOutput(stdout, repos
package/dist/cli/help.js CHANGED
@@ -78,10 +78,6 @@ function repoHelpText() {
78
78
  " --refresh Re-discover the base branch from GitHub",
79
79
  " --json Emit structured JSON",
80
80
  "",
81
- "Compatibility aliases:",
82
- " merge-steward attach ... Alias for `merge-steward repo attach ...`",
83
- " merge-steward repos ... Alias for `merge-steward repo list/show ...`",
84
- "",
85
81
  "Examples:",
86
82
  " merge-steward repo attach owner/repo",
87
83
  " merge-steward repo attach owner/repo --refresh",
@@ -120,7 +116,6 @@ function queueHelpText() {
120
116
  export function helpTextFor(topic) {
121
117
  switch (topic) {
122
118
  case "repo":
123
- case "repos":
124
119
  return repoHelpText();
125
120
  case "service":
126
121
  return serviceHelpText();
@@ -87,7 +87,7 @@ export function loadRepoConfigById(repoId) {
87
87
  const configuredHint = configured.length > 0
88
88
  ? ` Configured repos: ${configured.map((repo) => repo.repoId).join(", ")}.`
89
89
  : "";
90
- throw new UsageError(`Repo config not found for ${repoId}: ${configPath}. Run \`merge-steward attach ${repoId} <owner/repo>\` first.${configuredHint}`, "repos");
90
+ throw new UsageError(`Repo config not found for ${repoId}: ${configPath}. Run \`merge-steward repo attach ${repoId} <owner/repo>\` first.${configuredHint}`, "repo");
91
91
  }
92
92
  return {
93
93
  configPath,
@@ -2,7 +2,7 @@ export interface ParsedArgs {
2
2
  positionals: string[];
3
3
  flags: Map<string, string | boolean>;
4
4
  }
5
- export type HelpTopic = "root" | "repo" | "repos" | "service" | "queue";
5
+ export type HelpTopic = "root" | "repo" | "service" | "queue";
6
6
  export interface Output {
7
7
  write(chunk: string): boolean;
8
8
  }
package/dist/cli.js CHANGED
@@ -21,22 +21,19 @@ export async function runCli(argv, options) {
21
21
  return 0;
22
22
  }
23
23
  validateFlags(parsed);
24
- const requestedCommand = parsed.positionals[0] ?? "help";
25
- const command = requestedCommand === "dash" || requestedCommand === "d"
26
- ? "dashboard"
27
- : requestedCommand;
24
+ const command = parsed.positionals[0] ?? "help";
28
25
  if (hasHelpFlag(parsed) || command === "help") {
29
26
  const topic = command === "help"
30
27
  ? (parsed.positionals[1] ?? "root")
31
- : (command === "attach" || command === "repo" || command === "repos"
28
+ : (command === "repo"
32
29
  ? "repo"
33
30
  : command === "service" || command === "queue"
34
31
  ? command
35
32
  : "root");
36
- if (!["root", "attach", "repo", "repos", "service", "queue"].includes(topic)) {
33
+ if (!["root", "repo", "service", "queue"].includes(topic)) {
37
34
  throw new UsageError(`Unknown help topic: ${String(topic)}`);
38
35
  }
39
- writeOutput(stdout, `${helpTextFor(topic === "attach" ? "repo" : topic)}\n`);
36
+ writeOutput(stdout, `${helpTextFor(topic)}\n`);
40
37
  return 0;
41
38
  }
42
39
  switch (command) {
@@ -45,23 +42,19 @@ export async function runCli(argv, options) {
45
42
  return 0;
46
43
  case "init":
47
44
  return await (await import("./cli/commands/init.js")).handleInit(parsed, stdout, runCommand);
48
- case "attach":
49
- return await (await import("./cli/commands/attach.js")).handleAttach(parsed, stdout, runCommand);
50
- case "repos":
51
- return await (await import("./cli/commands/repos.js")).handleRepos(parsed, stdout);
52
45
  case "repo": {
53
46
  const subcommand = parsed.positionals[1] ?? "list";
54
47
  if (subcommand === "attach") {
55
- return await (await import("./cli/commands/attach.js")).handleAttach(rewriteParsedArgs(parsed, ["attach", ...parsed.positionals.slice(2)]), stdout, runCommand);
48
+ return await (await import("./cli/commands/attach.js")).handleAttach(rewriteParsedArgs(parsed, ["repo", ...parsed.positionals.slice(2)]), stdout, runCommand);
56
49
  }
57
50
  if (subcommand === "list") {
58
- return await (await import("./cli/commands/repos.js")).handleRepos(rewriteParsedArgs(parsed, ["repos", ...parsed.positionals.slice(2)]), stdout);
51
+ return await (await import("./cli/commands/repos.js")).handleRepos(rewriteParsedArgs(parsed, ["repo", ...parsed.positionals.slice(2)]), stdout);
59
52
  }
60
53
  if (subcommand === "show") {
61
54
  if (!parsed.positionals[2]) {
62
55
  throw new UsageError("merge-steward repo show requires <id>.", "repo");
63
56
  }
64
- return await (await import("./cli/commands/repos.js")).handleRepos(rewriteParsedArgs(parsed, ["repos", ...parsed.positionals.slice(2)]), stdout);
57
+ return await (await import("./cli/commands/repos.js")).handleRepos(rewriteParsedArgs(parsed, ["repo", ...parsed.positionals.slice(2)]), stdout);
65
58
  }
66
59
  throw new UsageError(`Unknown repo command: ${subcommand}`, "repo");
67
60
  }
@@ -1,7 +1,3 @@
1
1
  import type { DatabaseConnection } from "./shared.ts";
2
- /**
3
- * Create and upgrade the steward's SQLite schema. Idempotent — safe to call
4
- * on every startup. Migrations preserve active queue state, then remove
5
- * obsolete columns so the runtime has one candidate model.
6
- */
2
+ /** Create the current steward schema and reject any other schema version. */
7
3
  export declare function ensureSchema(connection: DatabaseConnection): void;
package/dist/db/schema.js CHANGED
@@ -1,9 +1,13 @@
1
- /**
2
- * Create and upgrade the steward's SQLite schema. Idempotent — safe to call
3
- * on every startup. Migrations preserve active queue state, then remove
4
- * obsolete columns so the runtime has one candidate model.
5
- */
1
+ /** Create the current steward schema and reject any other schema version. */
6
2
  export function ensureSchema(connection) {
3
+ const existingTables = connection.prepare(`
4
+ SELECT name FROM sqlite_master
5
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
6
+ `).all();
7
+ if (existingTables.length > 0) {
8
+ assertCurrentSchema(connection);
9
+ return;
10
+ }
7
11
  connection.exec(`
8
12
  CREATE TABLE IF NOT EXISTS queue_entries (
9
13
  id TEXT PRIMARY KEY,
@@ -22,11 +26,13 @@ export function ensureSchema(connection) {
22
26
  max_retries INTEGER NOT NULL DEFAULT 3,
23
27
  last_failed_base_sha TEXT,
24
28
  issue_key TEXT,
29
+ pr_title TEXT,
25
30
  candidate_kind TEXT,
26
31
  candidate_policy_fingerprint TEXT,
27
32
  candidate_ref TEXT,
28
33
  candidate_sha TEXT,
29
34
  candidate_based_on TEXT,
35
+ base_ref_name TEXT,
30
36
  wait_detail TEXT,
31
37
  post_merge_status TEXT,
32
38
  post_merge_sha TEXT,
@@ -73,45 +79,6 @@ export function ensureSchema(connection) {
73
79
  connection.exec(`
74
80
  CREATE INDEX IF NOT EXISTS idx_queue_events_entry
75
81
  ON queue_events(entry_id, id)
76
- `);
77
- ensureColumn(connection, "queue_entries", "post_merge_status", "TEXT");
78
- ensureColumn(connection, "queue_entries", "post_merge_sha", "TEXT");
79
- ensureColumn(connection, "queue_entries", "post_merge_summary", "TEXT");
80
- ensureColumn(connection, "queue_entries", "post_merge_checked_at", "TEXT");
81
- ensureColumn(connection, "queue_entries", "wait_detail", "TEXT");
82
- ensureColumn(connection, "queue_entries", "pr_title", "TEXT");
83
- ensureColumn(connection, "queue_entries", "candidate_kind", "TEXT");
84
- ensureColumn(connection, "queue_entries", "candidate_policy_fingerprint", "TEXT");
85
- ensureColumn(connection, "queue_entries", "candidate_ref", "TEXT");
86
- ensureColumn(connection, "queue_entries", "candidate_sha", "TEXT");
87
- ensureColumn(connection, "queue_entries", "candidate_based_on", "TEXT");
88
- ensureColumn(connection, "queue_entries", "base_ref_name", "TEXT");
89
- ensureColumn(connection, "queue_entries", "decided_at", "TEXT");
90
- migrateLegacyCandidateColumns(connection);
91
- // Retire patch-equivalence caches from older installations. Candidate
92
- // validity is SHA-bound; preserving these columns would imply otherwise.
93
- dropColumnIfPresent(connection, "queue_entries", "head_patch_id");
94
- dropColumnIfPresent(connection, "queue_entries", "spec_tree_id");
95
- connection.exec(`
96
- UPDATE queue_entries
97
- SET candidate_kind = 'integration'
98
- WHERE candidate_kind IS NULL
99
- AND candidate_sha IS NOT NULL
100
- AND candidate_ref IS NOT NULL
101
- `);
102
- connection.exec(`
103
- UPDATE queue_entries
104
- SET post_merge_status = 'pending'
105
- WHERE status = 'merged'
106
- AND post_merge_status IS NULL
107
- `);
108
- // Backfill decided_at for already-terminal rows so historic entries show a
109
- // sensible duration/age; updated_at is the best estimate we have for them.
110
- connection.exec(`
111
- UPDATE queue_entries
112
- SET decided_at = updated_at
113
- WHERE decided_at IS NULL
114
- AND status IN ('merged', 'evicted', 'dequeued')
115
82
  `);
116
83
  // Must match TERMINAL_STATUSES in types.ts: merged, evicted, dequeued
117
84
  connection.exec(`
@@ -119,56 +86,51 @@ export function ensureSchema(connection) {
119
86
  ON queue_entries(repo_id, pr_number)
120
87
  WHERE status NOT IN ('merged', 'evicted', 'dequeued')
121
88
  `);
89
+ assertCurrentSchema(connection);
122
90
  }
123
- function migrateLegacyCandidateColumns(connection) {
124
- const legacyColumns = [
125
- ["spec_branch", "candidate_ref"],
126
- ["spec_sha", "candidate_sha"],
127
- ["spec_based_on", "candidate_based_on"],
128
- ];
129
- for (const [legacy, candidate] of legacyColumns) {
130
- if (!hasColumn(connection, "queue_entries", legacy))
131
- continue;
132
- connection.exec(`
133
- UPDATE queue_entries
134
- SET ${candidate} = ${legacy}
135
- WHERE ${candidate} IS NULL
136
- AND ${legacy} IS NOT NULL
137
- `);
91
+ const QUEUE_ENTRY_COLUMNS = [
92
+ "id", "repo_id", "pr_number", "branch", "head_sha", "base_sha", "status",
93
+ "position", "priority", "generation", "ci_run_id", "ci_retries",
94
+ "retry_attempts", "max_retries", "last_failed_base_sha", "issue_key",
95
+ "pr_title", "candidate_kind", "candidate_policy_fingerprint", "candidate_ref",
96
+ "candidate_sha", "candidate_based_on", "base_ref_name", "wait_detail",
97
+ "post_merge_status", "post_merge_sha", "post_merge_summary",
98
+ "post_merge_checked_at", "decided_at", "enqueued_at", "updated_at",
99
+ ];
100
+ const QUEUE_INCIDENT_COLUMNS = [
101
+ "id", "entry_id", "at", "failure_class", "context_json", "outcome",
102
+ ];
103
+ const QUEUE_EVENT_COLUMNS = [
104
+ "id", "entry_id", "at", "from_status", "to_status", "detail", "base_sha",
105
+ ];
106
+ const REQUIRED_INDEXES = [
107
+ "idx_queue_entries_repo_status",
108
+ "idx_queue_entries_repo_position",
109
+ "idx_queue_incidents_entry",
110
+ "idx_queue_events_entry",
111
+ "idx_one_active_per_pr",
112
+ ];
113
+ function assertCurrentSchema(connection) {
114
+ assertExactColumns(connection, "queue_entries", QUEUE_ENTRY_COLUMNS);
115
+ assertExactColumns(connection, "queue_incidents", QUEUE_INCIDENT_COLUMNS);
116
+ assertExactColumns(connection, "queue_events", QUEUE_EVENT_COLUMNS);
117
+ const indexes = new Set(connection.prepare("SELECT name FROM sqlite_master WHERE type = 'index'").all()
118
+ .map((row) => String(row.name)));
119
+ const missing = REQUIRED_INDEXES.filter((index) => !indexes.has(index));
120
+ if (missing.length > 0) {
121
+ throw new Error(`Merge Steward database schema is incompatible (missing indexes: ${missing.join(", ")})`);
138
122
  }
139
- // Derive kind while the legacy ref is still available, then remove all
140
- // old candidate representations in this same startup migration.
141
- connection.exec(`
142
- UPDATE queue_entries
143
- SET candidate_kind = CASE
144
- WHEN candidate_sha = head_sha AND candidate_ref IS NULL THEN 'head'
145
- WHEN candidate_sha IS NOT NULL THEN 'integration'
146
- ELSE NULL
147
- END
148
- WHERE candidate_kind IS NULL
149
- `);
150
- for (const [legacy] of legacyColumns) {
151
- dropColumnIfPresent(connection, "queue_entries", legacy);
152
- }
153
- }
154
- function hasColumn(connection, table, column) {
155
- const rows = connection.prepare(`PRAGMA table_info(${table})`).all();
156
- for (const row of rows) {
157
- if (String(row.name) === column) {
158
- return true;
159
- }
160
- }
161
- return false;
162
123
  }
163
- function ensureColumn(connection, table, column, type) {
164
- if (hasColumn(connection, table, column)) {
124
+ function assertExactColumns(connection, table, expected) {
125
+ const actual = connection.prepare(`PRAGMA table_info(${table})`).all()
126
+ .map((row) => String(row.name));
127
+ const missing = expected.filter((column) => !actual.includes(column));
128
+ const unexpected = actual.filter((column) => !expected.includes(column));
129
+ if (missing.length === 0 && unexpected.length === 0)
165
130
  return;
166
- }
167
- connection.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
168
- }
169
- function dropColumnIfPresent(connection, table, column) {
170
- if (!hasColumn(connection, table, column)) {
171
- return;
172
- }
173
- connection.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`);
131
+ const details = [
132
+ ...(missing.length > 0 ? [`missing: ${missing.join(", ")}`] : []),
133
+ ...(unexpected.length > 0 ? [`unexpected: ${unexpected.join(", ")}`] : []),
134
+ ].join("; ");
135
+ throw new Error(`Merge Steward database schema is incompatible (${table}: ${details})`);
174
136
  }
package/dist/server.js CHANGED
@@ -85,7 +85,7 @@ export async function startMultiServer() {
85
85
  };
86
86
  setRuntimeGitHubAuthProvider(undefined);
87
87
  if (configs.length === 0) {
88
- logger.warn("No repo configs found. Run `merge-steward attach <owner/repo>` to add repos.");
88
+ logger.warn("No repo configs found. Run `merge-steward repo attach <owner/repo>` to add repos.");
89
89
  }
90
90
  if (githubAuth.mode === "app") {
91
91
  githubRuntimeStatus = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.33.2",
3
+ "version": "0.34.0",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,14 +0,0 @@
1
- import type { QueueEntry } from "../types.ts";
2
- /**
3
- * Build the display row list for the queue watch. In "active" mode,
4
- * includes active entries plus recently-terminal entries (within 60s),
5
- * deduplicated by prNumber (active wins over terminal for re-admissions).
6
- * In "all" mode, returns all entries as-is.
7
- */
8
- export declare function buildDisplayEntries(entries: QueueEntry[], filter: "active" | "all"): QueueEntry[];
9
- /**
10
- * Build the spec chain header entries. Always shows the current live
11
- * chain: active entries plus recently-merged entries (for cascade
12
- * visibility), regardless of the display filter. One entry per PR.
13
- */
14
- export declare function buildChainEntries(entries: QueueEntry[]): QueueEntry[];
@@ -1,69 +0,0 @@
1
- import { TERMINAL_STATUSES } from "../types.js";
2
- const RECENTLY_COMPLETED_MS = 60_000;
3
- function isPostMergeUnresolved(entry) {
4
- if (entry.status !== "merged")
5
- return false;
6
- return entry.postMergeStatus === null
7
- || entry.postMergeStatus === "pending"
8
- || entry.postMergeStatus === "unknown";
9
- }
10
- function isActiveFilterVisible(entry, cutoff) {
11
- if (!TERMINAL_STATUSES.includes(entry.status)) {
12
- return true;
13
- }
14
- // Keep a merged entry visible until main's own post-merge CI resolves so
15
- // the operator can watch the verification land. Once it's pass/fail we
16
- // fall back to the recently-completed window.
17
- if (isPostMergeUnresolved(entry)) {
18
- return true;
19
- }
20
- return new Date(entry.updatedAt).getTime() > cutoff;
21
- }
22
- function isLaterEntry(left, right) {
23
- if (left.position !== right.position) {
24
- return left.position > right.position;
25
- }
26
- return new Date(left.updatedAt).getTime() >= new Date(right.updatedAt).getTime();
27
- }
28
- function pickLatestPerPR(entries) {
29
- const byPR = new Map();
30
- for (const entry of entries) {
31
- const existing = byPR.get(entry.prNumber);
32
- if (!existing || isLaterEntry(entry, existing)) {
33
- byPR.set(entry.prNumber, entry);
34
- }
35
- }
36
- return [...byPR.values()];
37
- }
38
- /**
39
- * Build the display row list for the queue watch. In "active" mode,
40
- * includes active entries plus recently-terminal entries (within 60s),
41
- * deduplicated by prNumber (active wins over terminal for re-admissions).
42
- * In "all" mode, returns all entries as-is.
43
- */
44
- export function buildDisplayEntries(entries, filter) {
45
- if (filter !== "active")
46
- return entries;
47
- const cutoff = Date.now() - RECENTLY_COMPLETED_MS;
48
- return pickLatestPerPR(entries)
49
- .filter((entry) => {
50
- const isActive = !TERMINAL_STATUSES.includes(entry.status);
51
- return isActive || isActiveFilterVisible(entry, cutoff);
52
- })
53
- .sort((a, b) => a.position - b.position);
54
- }
55
- /**
56
- * Build the spec chain header entries. Always shows the current live
57
- * chain: active entries plus recently-merged entries (for cascade
58
- * visibility), regardless of the display filter. One entry per PR.
59
- */
60
- export function buildChainEntries(entries) {
61
- const cutoff = Date.now() - RECENTLY_COMPLETED_MS;
62
- return pickLatestPerPR(entries)
63
- .filter((entry) => {
64
- const isActive = !TERMINAL_STATUSES.includes(entry.status);
65
- const isRecent = !isActive && new Date(entry.updatedAt).getTime() > cutoff;
66
- return isActive || isRecent || isPostMergeUnresolved(entry);
67
- })
68
- .sort((a, b) => a.position - b.position);
69
- }