merge-steward 0.8.9 → 0.8.11

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.
@@ -16,6 +16,11 @@ export declare class GitHubPRClient implements GitHubPRApi {
16
16
  getStatus(prNumber: number): Promise<PRStatus>;
17
17
  listChecks(prNumber: number): Promise<CheckResult[]>;
18
18
  listChecksForRef(ref: string): Promise<CheckResult[]>;
19
+ listOpenPRsWithLabel(label: string): Promise<Array<{
20
+ number: number;
21
+ branch: string;
22
+ headSha: string;
23
+ }>>;
19
24
  deleteBranch(prNumber: number): Promise<void>;
20
25
  findPRByBranch(branch: string): Promise<number | null>;
21
26
  listLabels(prNumber: number): Promise<string[]>;
@@ -64,6 +64,24 @@ export class GitHubPRClient {
64
64
  return [];
65
65
  }
66
66
  }
67
+ async listOpenPRsWithLabel(label) {
68
+ const result = await exec("gh", [
69
+ "pr", "list",
70
+ "--repo", this.repoFullName,
71
+ "--label", label,
72
+ "--state", "open",
73
+ "--json", "number,headRefName,headRefOid",
74
+ ], { allowNonZero: true, githubRepoFullName: this.repoFullName });
75
+ if (result.exitCode !== 0)
76
+ return [];
77
+ try {
78
+ const data = JSON.parse(result.stdout);
79
+ return data.map((pr) => ({ number: pr.number, branch: pr.headRefName, headSha: pr.headRefOid }));
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
67
85
  async deleteBranch(prNumber) {
68
86
  const status = await this.getStatus(prNumber);
69
87
  await exec("gh", [
@@ -1,11 +1,17 @@
1
1
  import type { GitOperations, SpeculativeBranchBuilder } from "../interfaces.ts";
2
2
  import type { MergeResult } from "../types.ts";
3
+ export interface BotIdentity {
4
+ name: string;
5
+ email: string;
6
+ }
3
7
  export declare class ShellGitOperations implements GitOperations, SpeculativeBranchBuilder {
4
8
  private readonly clonePath;
5
9
  private readonly repoFullName;
6
10
  private readonly gitBin;
7
11
  private readonly worktreeBase;
12
+ private botIdentity;
8
13
  constructor(clonePath: string, repoFullName: string, gitBin?: string);
14
+ setBotIdentity(identity: BotIdentity): void;
9
15
  private git;
10
16
  /** Run a git command in a specific directory (worktree). */
11
17
  private gitIn;
@@ -19,7 +25,7 @@ export declare class ShellGitOperations implements GitOperations, SpeculativeBra
19
25
  * The worktree is removed after the merge; the branch ref persists
20
26
  * so the reconciler can push it and reference its SHA.
21
27
  */
22
- buildSpeculative(prBranch: string, baseBranch: string, specName: string): Promise<MergeResult>;
28
+ buildSpeculative(prBranch: string, baseBranch: string, specName: string, mergeMessage?: string): Promise<MergeResult>;
23
29
  /**
24
30
  * During a merge conflict, check if the only unmerged files are lockfiles
25
31
  * (package-lock.json). If so, resolve by regenerating from the merged
@@ -17,12 +17,16 @@ export class ShellGitOperations {
17
17
  repoFullName;
18
18
  gitBin;
19
19
  worktreeBase;
20
+ botIdentity;
20
21
  constructor(clonePath, repoFullName, gitBin = "git") {
21
22
  this.clonePath = clonePath;
22
23
  this.repoFullName = repoFullName;
23
24
  this.gitBin = gitBin;
24
25
  this.worktreeBase = `${clonePath}-worktrees`;
25
26
  }
27
+ setBotIdentity(identity) {
28
+ this.botIdentity = identity;
29
+ }
26
30
  git(args, opts) {
27
31
  return exec(this.gitBin, ["-C", this.clonePath, ...args], {
28
32
  timeoutMs: opts?.timeoutMs ?? 120_000,
@@ -68,7 +72,7 @@ export class ShellGitOperations {
68
72
  * The worktree is removed after the merge; the branch ref persists
69
73
  * so the reconciler can push it and reference its SHA.
70
74
  */
71
- async buildSpeculative(prBranch, baseBranch, specName) {
75
+ async buildSpeculative(prBranch, baseBranch, specName, mergeMessage) {
72
76
  const wtPath = join(this.worktreeBase, specName);
73
77
  // Clean up any leftover worktree/branch from a previous run.
74
78
  await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
@@ -77,11 +81,20 @@ export class ShellGitOperations {
77
81
  // Create isolated worktree with spec branch starting at baseBranch.
78
82
  mkdirSync(this.worktreeBase, { recursive: true });
79
83
  await this.git(["worktree", "add", "-B", specName, wtPath, baseBranch]);
84
+ // Override git identity so merge commits are attributed to the steward, not the clone owner.
85
+ if (this.botIdentity) {
86
+ await this.gitIn(wtPath, ["config", "user.name", this.botIdentity.name]);
87
+ await this.gitIn(wtPath, ["config", "user.email", this.botIdentity.email]);
88
+ }
80
89
  // Merge PR into spec branch inside the worktree.
81
90
  // PR branches are always remote refs — use explicit origin/ prefix
82
91
  // because git DWIM doesn't reliably resolve bare names in worktrees.
83
92
  const mergeRef = prBranch.startsWith("origin/") ? prBranch : `origin/${prBranch}`;
84
- const result = await this.gitIn(wtPath, ["merge", "--no-ff", mergeRef], { allowNonZero: true });
93
+ const mergeArgs = ["merge", "--no-ff"];
94
+ if (mergeMessage)
95
+ mergeArgs.push("-m", mergeMessage);
96
+ mergeArgs.push(mergeRef);
97
+ const result = await this.gitIn(wtPath, mergeArgs, { allowNonZero: true });
85
98
  if (result.exitCode !== 0) {
86
99
  const conflictFiles = parseConflicts(result.stderr);
87
100
  // Lockfile-only conflicts can be auto-resolved by regenerating.
@@ -21,6 +21,7 @@ export interface GitHubAppTokenManager extends RuntimeGitHubAuthProvider {
21
21
  start(): Promise<void>;
22
22
  stop(): void;
23
23
  }
24
+ export declare function resolveAppSlug(credentials: GitHubAppCredentials): Promise<string>;
24
25
  export declare function issueGitHubAppToken(credentials: GitHubAppCredentials, options?: {
25
26
  repoFullName?: string;
26
27
  }): Promise<{
@@ -30,6 +30,11 @@ function githubHeaders(token) {
30
30
  "X-GitHub-Api-Version": "2022-11-28",
31
31
  };
32
32
  }
33
+ export async function resolveAppSlug(credentials) {
34
+ const jwt = generateJwt(credentials.appId, credentials.privateKey);
35
+ const data = await fetchJson("https://api.github.com/app", { headers: githubHeaders(jwt) });
36
+ return data.slug;
37
+ }
33
38
  async function resolveInstallationIdForRepo(jwt, repoFullName) {
34
39
  const encodedRepo = repoFullName
35
40
  .split("/")
@@ -15,7 +15,7 @@ export interface GitOperations {
15
15
  */
16
16
  export interface SpeculativeBranchBuilder {
17
17
  /** Merge prBranch into baseBranch, store result as specName. */
18
- buildSpeculative(prBranch: string, baseBranch: string, specName: string): Promise<MergeResult>;
18
+ buildSpeculative(prBranch: string, baseBranch: string, specName: string, mergeMessage?: string): Promise<MergeResult>;
19
19
  /** Delete a speculative branch (cleanup after merge/eviction). */
20
20
  deleteSpeculative(specName: string): Promise<void>;
21
21
  }
@@ -44,6 +44,12 @@ export interface GitHubPRApi {
44
44
  findPRByBranch(branch: string): Promise<number | null>;
45
45
  /** Delete the PR's head branch from the remote (best-effort cleanup). */
46
46
  deleteBranch(prNumber: number): Promise<void>;
47
+ /** List open PRs with a specific label (for startup scan). */
48
+ listOpenPRsWithLabel(label: string): Promise<Array<{
49
+ number: number;
50
+ branch: string;
51
+ headSha: string;
52
+ }>>;
47
53
  }
48
54
  /**
49
55
  * Reports evictions to external systems. The production implementation
@@ -186,9 +186,11 @@ async function prepareEntry(ctx, entry, isHead, prevEntry) {
186
186
  // ── Build spec branch: merge PR into base ─────────────────────
187
187
  const specName = specBranchName(entry.id);
188
188
  emit(ctx, entry, "spec_build_started", { specBranch: specName, baseSha, ...(prevEntry ? { dependsOn: prevEntry.id } : {}) });
189
+ const branchSuffix = entry.branch.replace(/^.*\//, "").replace(/-/g, " ");
190
+ const mergeMessage = `Merge PR #${entry.prNumber}: ${branchSuffix}`;
189
191
  let result;
190
192
  try {
191
- result = await ctx.specBuilder.buildSpeculative(entry.branch, base, specName);
193
+ result = await ctx.specBuilder.buildSpeculative(entry.branch, base, specName, mergeMessage);
192
194
  }
193
195
  catch (err) {
194
196
  if (isHead) {
package/dist/server.js CHANGED
@@ -10,18 +10,20 @@ import { buildMultiRepoHttpServer } from "./http-multi.js";
10
10
  import { loadAllRepoConfigs } from "./install.js";
11
11
  import { parseHomeConfigObject } from "./steward-home.js";
12
12
  import { getMergeStewardPathLayout } from "./runtime-paths.js";
13
- import { createGitHubAppTokenManager, resolveGitHubAuthConfig } from "./github-auth.js";
13
+ import { createGitHubAppTokenManager, resolveGitHubAuthConfig, resolveAppSlug } from "./github-auth.js";
14
14
  import { discoverRepoSettings } from "./github-repo-discovery.js";
15
- import { resolveSecret } from "./resolve-secret.js";
15
+ import { resolveSecret, resolveSecretWithSource } from "./resolve-secret.js";
16
16
  import { setRuntimeGitHubAuthProvider } from "./exec.js";
17
17
  import { readFileSync, existsSync } from "node:fs";
18
- async function createRepoInstance(config, logger) {
18
+ async function createRepoInstance(config, logger, botIdentity) {
19
19
  const repoUrl = `https://github.com/${config.repoFullName}.git`;
20
20
  const clone = new CloneManager(config.clonePath, repoUrl, config.repoFullName, config.gitBin, logger);
21
21
  await clone.ensureClone();
22
22
  await clone.fetch();
23
23
  const store = new SqliteStore(config.database.path);
24
24
  const git = new ShellGitOperations(clone.path, config.repoFullName, config.gitBin);
25
+ if (botIdentity)
26
+ git.setBotIdentity(botIdentity);
25
27
  const ci = new GitHubActionsRunner(config.repoFullName, config.requiredChecks);
26
28
  const github = new GitHubPRClient(config.repoFullName);
27
29
  const eviction = new GitHubCheckRunReporter(config.repoFullName, config.server.bind, config.server.port, config.server.publicBaseUrl, config.admissionLabel, config.mergeQueueCheckName);
@@ -32,15 +34,21 @@ export async function startMultiServer() {
32
34
  const layout = getMergeStewardPathLayout();
33
35
  const homeRaw = existsSync(layout.configPath) ? readFileSync(layout.configPath, "utf8") : "{}";
34
36
  const home = parseHomeConfigObject(homeRaw, layout.configPath);
35
- const webhookSecret = resolveSecret("merge-steward-webhook-secret", "MERGE_STEWARD_WEBHOOK_SECRET");
37
+ const webhookSecretResolved = resolveSecretWithSource("merge-steward-webhook-secret", "MERGE_STEWARD_WEBHOOK_SECRET");
38
+ const webhookSecret = webhookSecretResolved.value;
36
39
  const bind = home.server.bind;
37
40
  const port = home.server.gateway_port ?? (home.server.port_base - 1);
38
41
  const publicBaseUrl = home.server.public_base_url;
39
42
  const logLevel = home.logging.level;
40
43
  const logger = pino({ level: logLevel });
44
+ logger.info({
45
+ webhookSecretSource: webhookSecretResolved.source,
46
+ webhookSecretPrefix: webhookSecret?.slice(0, 4) ?? "NONE",
47
+ }, "Webhook secret loaded");
41
48
  const configs = await loadAllRepoConfigs();
42
49
  const githubAuth = resolveGitHubAuthConfig();
43
50
  let githubAppTokenManager;
51
+ let botIdentity;
44
52
  let githubRuntimeStatus = {
45
53
  mode: "none",
46
54
  configured: false,
@@ -64,6 +72,17 @@ export async function startMultiServer() {
64
72
  setRuntimeGitHubAuthProvider(githubAppTokenManager);
65
73
  try {
66
74
  await githubAppTokenManager.start();
75
+ try {
76
+ const slug = await resolveAppSlug(githubAuth.credentials);
77
+ botIdentity = {
78
+ name: `${slug}[bot]`,
79
+ email: `${githubAuth.credentials.appId}+${slug}[bot]@users.noreply.github.com`,
80
+ };
81
+ logger.info({ botName: botIdentity.name }, "Resolved GitHub App bot identity");
82
+ }
83
+ catch {
84
+ logger.warn("Could not resolve GitHub App slug, merge commits will use clone owner identity");
85
+ }
67
86
  githubRuntimeStatus = {
68
87
  ...githubRuntimeStatus,
69
88
  ready: true,
@@ -97,7 +116,7 @@ export async function startMultiServer() {
97
116
  const instances = new Map();
98
117
  for (const config of configs) {
99
118
  logger.info({ repoId: config.repoId, repoFullName: config.repoFullName }, "Initializing repo");
100
- const instance = await createRepoInstance(config, logger.child({ repoId: config.repoId }));
119
+ const instance = await createRepoInstance(config, logger.child({ repoId: config.repoId }), botIdentity);
101
120
  instances.set(config.repoFullName, instance);
102
121
  }
103
122
  const app = await buildMultiRepoHttpServer({
@@ -132,6 +151,6 @@ export async function startMultiServer() {
132
151
  await app.listen({ host: bind, port });
133
152
  logger.info({ bind, port, repos: instances.size }, "merge-steward listening");
134
153
  for (const inst of instances.values()) {
135
- inst.service.start();
154
+ await inst.service.start();
136
155
  }
137
156
  }
package/dist/service.d.ts CHANGED
@@ -27,7 +27,7 @@ export declare class MergeStewardService {
27
27
  constructor(config: StewardConfig, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: import("./interfaces.ts").SpeculativeBranchBuilder, logger: Logger);
28
28
  /** Expose the GitHub client for webhook handler branch→PR lookups. */
29
29
  get githubApi(): GitHubPRApi;
30
- start(): void;
30
+ start(): Promise<void>;
31
31
  stop(): Promise<void>;
32
32
  enqueue(params: {
33
33
  prNumber: number;
package/dist/service.js CHANGED
@@ -42,9 +42,25 @@ export class MergeStewardService {
42
42
  get githubApi() {
43
43
  return this.github;
44
44
  }
45
- start() {
45
+ async start() {
46
46
  this.logger.info({ pollIntervalMs: this.config.pollIntervalMs }, "Steward service starting");
47
47
  this.scheduleNextTick();
48
+ // Best-effort: scan GitHub for PRs with the admission label that
49
+ // aren't already in the queue (recovers state after restart).
50
+ try {
51
+ const labeled = await this.github.listOpenPRsWithLabel(this.config.admissionLabel);
52
+ let admitted = 0;
53
+ for (const pr of labeled) {
54
+ if (await this.tryAdmit(pr.number, pr.branch, pr.headSha))
55
+ admitted++;
56
+ }
57
+ if (labeled.length > 0) {
58
+ this.logger.info({ scanned: labeled.length, admitted }, "Startup scan for labeled PRs complete");
59
+ }
60
+ }
61
+ catch (err) {
62
+ this.logger.warn({ err }, "Startup scan for labeled PRs failed");
63
+ }
48
64
  }
49
65
  async stop() {
50
66
  if (this.tickTimer) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
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": {