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.
- package/dist/github/pr-client.d.ts +5 -0
- package/dist/github/pr-client.js +18 -0
- package/dist/github/shell-git.d.ts +7 -1
- package/dist/github/shell-git.js +15 -2
- package/dist/github-auth.d.ts +1 -0
- package/dist/github-auth.js +5 -0
- package/dist/interfaces.d.ts +7 -1
- package/dist/reconciler.js +3 -1
- package/dist/server.js +25 -6
- package/dist/service.d.ts +1 -1
- package/dist/service.js +17 -1
- package/package.json +1 -1
|
@@ -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[]>;
|
package/dist/github/pr-client.js
CHANGED
|
@@ -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
|
package/dist/github/shell-git.js
CHANGED
|
@@ -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
|
|
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.
|
package/dist/github-auth.d.ts
CHANGED
|
@@ -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<{
|
package/dist/github-auth.js
CHANGED
|
@@ -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("/")
|
package/dist/interfaces.d.ts
CHANGED
|
@@ -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
|
package/dist/reconciler.js
CHANGED
|
@@ -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
|
|
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) {
|