@bridge_gpt/mcp-server 0.2.59 → 0.2.60
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/README.md +173 -51
- package/build/agent-guidance.generated.js +1 -1
- package/build/agent-notes.js +7 -1
- package/build/commands.generated.js +4 -4
- package/build/conductor/bridge-api-client.js +53 -2
- package/build/conductor/doctor.js +255 -53
- package/build/conductor/epic-runtime.js +11 -2
- package/build/conductor/install-doctor.js +24 -2
- package/build/conductor/local-merge.js +319 -26
- package/build/conductor/merge-execution-contract.js +61 -0
- package/build/conductor/merge-execution-eligibility.js +129 -0
- package/build/conductor/readiness-sections.js +159 -2
- package/build/conductor/readiness.js +95 -14
- package/build/conductor/recovery-operations.js +8 -2
- package/build/connect-bitbucket-api.js +6 -1
- package/build/connect-bitbucket.js +398 -88
- package/build/credential-store.js +16 -0
- package/build/docs.generated.js +2 -2
- package/build/doctor.js +183 -9
- package/build/drive-epic.js +23 -0
- package/build/epic-implementer-bin.js +10 -10
- package/build/epic-implementer-claude-hook-bin.js +1 -1
- package/build/epic-implementer-env.js +43 -14
- package/build/executor/ci-rerun-job.js +269 -0
- package/build/executor/cli.js +31 -0
- package/build/executor/credentials.js +11 -0
- package/build/executor/env.js +60 -0
- package/build/executor/index.js +21 -4
- package/build/executor/job-runner.js +126 -0
- package/build/executor/job-types.js +12 -2
- package/build/executor/merge-job.js +11 -2
- package/build/executor/service-lifecycle.js +136 -0
- package/build/executor/service-unit.js +427 -9
- package/build/executor/types.js +5 -0
- package/build/index.js +194 -179
- package/build/install-bridge-conductor.js +201 -8
- package/build/install-bridge.js +356 -30
- package/build/mcp-install-state.js +42 -4
- package/build/merge-pull-request.js +103 -10
- package/build/merge-workflow-codeowners.js +224 -0
- package/build/merge-workflow-config.js +133 -0
- package/build/merge-workflow-drift.js +66 -0
- package/build/merge-workflow-install.js +638 -0
- package/build/merge-workflow.js +572 -0
- package/build/pipelines.generated.js +2 -2
- package/build/plane/cli.js +24 -2
- package/build/plane/preflight.js +68 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +5 -0
- package/build/setup-epic.js +163 -9
- package/build/version.generated.js +3 -3
- package/package.json +3 -3
- package/build/conduct-epic/bridge-client.js +0 -773
- package/build/conduct-epic/checkpoint-store.js +0 -542
- package/build/conduct-epic/cli.js +0 -3145
- package/build/conduct-epic/cut-protocol.js +0 -392
- package/build/conduct-epic/lock.js +0 -302
- package/build/conduct-epic/pr-state.js +0 -286
- package/build/conduct-epic/spawn.js +0 -113
- package/build/conductor/cli.js +0 -1278
- package/build/conductor-bundle-cli.js +0 -256
- package/build/direct-ticket-tools.js +0 -70
- package/build/executor/reconcile-overlap-governance.js +0 -129
- package/build/implement-epic/bridge-client.js +0 -773
- package/build/implement-epic/checkpoint-store.js +0 -542
- package/build/implement-epic/cli.js +0 -3158
- package/build/implement-epic/cut-protocol.js +0 -392
- package/build/implement-epic/lock.js +0 -302
- package/build/implement-epic/pr-state.js +0 -286
- package/build/implement-epic/spawn.js +0 -113
- package/build/plan-epic-conductor-eligibility.js +0 -213
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `install-bridge conductor --merge-workflow` — the generate-and-PR installer
|
|
3
|
+
* (BAPI-1136).
|
|
4
|
+
*
|
|
5
|
+
* One command generates the Epic Implementer merge workflow plus its two review
|
|
6
|
+
* artifacts, commits them on a managed branch, pushes with the CUSTOMER's own
|
|
7
|
+
* git identity, and opens a pull request with the customer's own `gh`. It never
|
|
8
|
+
* merges that pull request, and Bridge never writes to the repository at all.
|
|
9
|
+
* The customer's review IS the security control: this workflow is the one file
|
|
10
|
+
* that can perform a privileged merge, so a human deciding to merge it is the
|
|
11
|
+
* whole point rather than an inconvenience on the way to automation.
|
|
12
|
+
*
|
|
13
|
+
* ## Domain logic lives here, not in the CLI
|
|
14
|
+
*
|
|
15
|
+
* `install-bridge.ts` parses arguments and builds seams. Everything below — the
|
|
16
|
+
* preflight, the four-way classification, the consent summary, the branch /
|
|
17
|
+
* commit / push / PR sequence — is here so it is unit-testable with in-memory
|
|
18
|
+
* fakes and so the CLI layer stays thin.
|
|
19
|
+
*
|
|
20
|
+
* ## Read-only until it is not
|
|
21
|
+
*
|
|
22
|
+
* The ENTIRE preflight completes before the first branch, file write,
|
|
23
|
+
* install-state write, commit, push, or pull request. That ordering is the
|
|
24
|
+
* design: a failure discovered halfway through leaves a branch, or a file, or a
|
|
25
|
+
* pushed ref in a customer's repository that they now have to understand and
|
|
26
|
+
* clean up. Nothing here can partially install.
|
|
27
|
+
*
|
|
28
|
+
* ## Four content states, and why `identical` is not the only no-op
|
|
29
|
+
*
|
|
30
|
+
* A file at the managed path is one of four things: absent, byte-identical to
|
|
31
|
+
* what this build renders, a complete older version Bridge itself published, or
|
|
32
|
+
* something else. Only the first and third are installable. "Something else"
|
|
33
|
+
* includes a hand-edit, a truncation, a newer version from a future build, and a
|
|
34
|
+
* file that merely claims a lower version in its marker — all of them get the
|
|
35
|
+
* same fail-closed treatment, because this installer cannot tell a benign edit
|
|
36
|
+
* from a hostile one and must not guess about a file that can merge code.
|
|
37
|
+
*
|
|
38
|
+
* ## Secret discipline
|
|
39
|
+
*
|
|
40
|
+
* No path here accepts, prompts for, reads, writes, or prints a credential. The
|
|
41
|
+
* Bridge API key is referenced by NAME only. Raw `git` and `gh` output never
|
|
42
|
+
* reaches the operator: every failure is a stable named reason plus one
|
|
43
|
+
* remediation sentence, because subprocess text is unbounded and can carry a
|
|
44
|
+
* remote URL with an embedded token.
|
|
45
|
+
*/
|
|
46
|
+
import { MERGE_WORKFLOW_EXPECTED_VERSION, MERGE_WORKFLOW_PATH, } from "./conductor/merge-execution-contract.js";
|
|
47
|
+
import { MERGE_WORKFLOW_API_KEY_SECRET_NAME, } from "./merge-workflow-config.js";
|
|
48
|
+
import { MERGE_WORKFLOW_CLASSIFICATION_TEXT, classifyMergeWorkflowContent, } from "./merge-workflow-drift.js";
|
|
49
|
+
import { MERGE_WORKFLOW_CODEOWNERS_RELPATH, MERGE_WORKFLOW_GUIDANCE_RELPATH, MERGE_WORKFLOW_MANAGED_RELPATHS, MergeWorkflowCodeOwnerError, renderMergeWorkflowCodeownersFragment, renderMergeWorkflowGuidance, validateMergeWorkflowCodeOwner, } from "./merge-workflow-codeowners.js";
|
|
50
|
+
import { renderEpicImplementerMergeWorkflow } from "./merge-workflow.js";
|
|
51
|
+
import { MCP_INSTALL_STATE_RELPATH } from "./mcp-install-state.js";
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Managed identity
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/** The managed branch this installer creates, for a fresh installation. */
|
|
56
|
+
export const MERGE_WORKFLOW_INSTALL_BRANCH = "bridge/merge-workflow-v" + String(MERGE_WORKFLOW_EXPECTED_VERSION);
|
|
57
|
+
/** The managed branch this installer creates when upgrading an older version. */
|
|
58
|
+
export const MERGE_WORKFLOW_UPGRADE_BRANCH = "bridge/merge-workflow-upgrade-v" + String(MERGE_WORKFLOW_EXPECTED_VERSION);
|
|
59
|
+
/** Fixed, secret-free commit subject for a fresh installation. */
|
|
60
|
+
export const MERGE_WORKFLOW_INSTALL_COMMIT_SUBJECT = "Add the Bridge Epic Implementer merge workflow";
|
|
61
|
+
/** Fixed, secret-free commit subject for an upgrade. */
|
|
62
|
+
export const MERGE_WORKFLOW_UPGRADE_COMMIT_SUBJECT = "Upgrade the Bridge Epic Implementer merge workflow";
|
|
63
|
+
/** Pull request title for a fresh installation. */
|
|
64
|
+
export const MERGE_WORKFLOW_INSTALL_PR_TITLE = MERGE_WORKFLOW_INSTALL_COMMIT_SUBJECT;
|
|
65
|
+
/** Pull request title for an upgrade. */
|
|
66
|
+
export const MERGE_WORKFLOW_UPGRADE_PR_TITLE = MERGE_WORKFLOW_UPGRADE_COMMIT_SUBJECT;
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Outcomes
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
/**
|
|
71
|
+
* Every named failure this installer can report.
|
|
72
|
+
*
|
|
73
|
+
* Closed on purpose: each value is printed verbatim as the first token of a
|
|
74
|
+
* stderr line, so the vocabulary is an operator-facing contract, and the set has
|
|
75
|
+
* to be greppable rather than assembled from subprocess text.
|
|
76
|
+
*/
|
|
77
|
+
export const MERGE_WORKFLOW_INSTALL_FAILURE_REASONS = [
|
|
78
|
+
/** `git` is not on PATH. */
|
|
79
|
+
"git_unavailable",
|
|
80
|
+
/** `gh` is not on PATH. */
|
|
81
|
+
"gh_unavailable",
|
|
82
|
+
/** The requested directory is not the root of the active git worktree. */
|
|
83
|
+
"not_repository_root",
|
|
84
|
+
/** The worktree or index carries uncommitted changes. */
|
|
85
|
+
"worktree_not_clean",
|
|
86
|
+
/** The repository default branch could not be resolved, or the two sources disagreed. */
|
|
87
|
+
"default_branch_unresolved",
|
|
88
|
+
/** The push remote is missing, or is not a canonical github.com repository. */
|
|
89
|
+
"remote_not_github",
|
|
90
|
+
/** The local, remote, and `gh` repository identities do not agree. */
|
|
91
|
+
"repository_identity_mismatch",
|
|
92
|
+
/** `--codeowner` is missing or invalid. */
|
|
93
|
+
"codeowner_invalid",
|
|
94
|
+
/** The installed workflow is a hand-edit or an unrecognized version. */
|
|
95
|
+
"workflow_modified_or_unrecognized",
|
|
96
|
+
/** A companion artifact exists with contents this installer did not write. */
|
|
97
|
+
"companion_artifact_conflict",
|
|
98
|
+
/** An open managed pull request exists but does not match the expected shape. */
|
|
99
|
+
"existing_pr_unverifiable",
|
|
100
|
+
/** A filesystem write failed. */
|
|
101
|
+
"write_failed",
|
|
102
|
+
/** A git branch, stage, commit, or push failed. */
|
|
103
|
+
"git_failed",
|
|
104
|
+
/** Opening the pull request failed. */
|
|
105
|
+
"pr_open_failed",
|
|
106
|
+
/** A required write surface was declined, or no prompt was available. */
|
|
107
|
+
"consent_declined",
|
|
108
|
+
];
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Path safety
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
/**
|
|
113
|
+
* Reject any managed path that is not a plain, repository-relative POSIX path.
|
|
114
|
+
*
|
|
115
|
+
* The three managed paths are constants, so this can never fire in production —
|
|
116
|
+
* which is exactly why it is here. It is the assertion that keeps a future edit
|
|
117
|
+
* from introducing an absolute path, a `..` escape, or a Windows separator into
|
|
118
|
+
* the one code path that writes files and stages them for commit.
|
|
119
|
+
*/
|
|
120
|
+
export function isSafeManagedRelPath(relPath) {
|
|
121
|
+
if (typeof relPath !== "string" || relPath.length === 0)
|
|
122
|
+
return false;
|
|
123
|
+
if (relPath.includes("\\"))
|
|
124
|
+
return false;
|
|
125
|
+
if (relPath.startsWith("/"))
|
|
126
|
+
return false;
|
|
127
|
+
if (/^[A-Za-z]:/.test(relPath))
|
|
128
|
+
return false;
|
|
129
|
+
const segments = relPath.split("/");
|
|
130
|
+
if (segments.some((s) => s.length === 0 || s === "." || s === ".."))
|
|
131
|
+
return false;
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
function joinCwd(cwd, rel) {
|
|
135
|
+
const base = cwd.endsWith("/") ? cwd.slice(0, -1) : cwd;
|
|
136
|
+
return `${base}/${rel}`;
|
|
137
|
+
}
|
|
138
|
+
function isEnoent(err) {
|
|
139
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
140
|
+
}
|
|
141
|
+
/** Read a file, mapping "absent" to `null` and any other error to a throw. */
|
|
142
|
+
async function readOrNull(deps, absPath) {
|
|
143
|
+
try {
|
|
144
|
+
return await deps.readFile(absPath);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
if (isEnoent(err))
|
|
148
|
+
return null;
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Remote identity
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
/**
|
|
156
|
+
* Parse `owner/name` out of a git remote URL, accepting ONLY canonical
|
|
157
|
+
* github.com forms.
|
|
158
|
+
*
|
|
159
|
+
* GitHub Enterprise and every non-GitHub host return `null`. This feature is
|
|
160
|
+
* GitHub.com-only: the workflow it installs uses `${{ github.token }}` and the
|
|
161
|
+
* GitHub REST surface, and quietly "succeeding" against a host where the result
|
|
162
|
+
* cannot work would open a pull request that can never actuate anything.
|
|
163
|
+
*/
|
|
164
|
+
export function parseGithubRemote(remoteUrl) {
|
|
165
|
+
if (typeof remoteUrl !== "string")
|
|
166
|
+
return null;
|
|
167
|
+
const trimmed = remoteUrl.trim();
|
|
168
|
+
if (trimmed.length === 0)
|
|
169
|
+
return null;
|
|
170
|
+
const patterns = [
|
|
171
|
+
/^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
172
|
+
/^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
173
|
+
/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
174
|
+
];
|
|
175
|
+
for (const pattern of patterns) {
|
|
176
|
+
const m = pattern.exec(trimmed);
|
|
177
|
+
if (m)
|
|
178
|
+
return `${m[1]}/${m[2]}`;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
/** One remediation sentence per named failure. Printed after the reason token. */
|
|
183
|
+
const REMEDIATION = {
|
|
184
|
+
git_unavailable: "Install git and re-run; this command creates the branch with your own git.",
|
|
185
|
+
gh_unavailable: "Install the GitHub CLI (`gh`) and authenticate it; this command opens the pull request as you.",
|
|
186
|
+
not_repository_root: "Run this command from the root of the repository that should receive the workflow.",
|
|
187
|
+
worktree_not_clean: "Commit or set aside your local changes first; this command refuses to stage anything it did not generate.",
|
|
188
|
+
default_branch_unresolved: "Check that the repository has a default branch and that `gh` can read it, then re-run.",
|
|
189
|
+
remote_not_github: "Point the push remote at a github.com repository; this feature is GitHub.com-only.",
|
|
190
|
+
repository_identity_mismatch: "Make the local remote and the `gh` default repository agree, then re-run.",
|
|
191
|
+
codeowner_invalid: "Pass exactly one real reviewer as `--codeowner @org/team` or `--codeowner @user`.",
|
|
192
|
+
workflow_modified_or_unrecognized: "Review the existing file by hand; this command never overwrites a workflow Bridge did not write.",
|
|
193
|
+
companion_artifact_conflict: "Review the differing file by hand and remove or reconcile it, then re-run.",
|
|
194
|
+
existing_pr_unverifiable: "Inspect the open Bridge merge-workflow pull request by hand and close it if it is not the one you want.",
|
|
195
|
+
write_failed: "Check directory permissions for the repository and re-run.",
|
|
196
|
+
git_failed: "Check the reported branch with git by hand; nothing already created has been removed.",
|
|
197
|
+
pr_open_failed: "The branch is pushed; open the pull request by hand, or re-run this command to reuse it.",
|
|
198
|
+
consent_declined: "Re-run in an interactive terminal and accept the write to continue.",
|
|
199
|
+
};
|
|
200
|
+
function fail(reason) {
|
|
201
|
+
return { outcome: "failed", reason, remediation: REMEDIATION[reason] };
|
|
202
|
+
}
|
|
203
|
+
async function commandSucceeds(run, args) {
|
|
204
|
+
try {
|
|
205
|
+
const result = await run(args);
|
|
206
|
+
return result.status === 0;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function captureStdout(run, args) {
|
|
213
|
+
try {
|
|
214
|
+
const result = await run(args);
|
|
215
|
+
if (result.status !== 0)
|
|
216
|
+
return null;
|
|
217
|
+
return result.stdout.trim();
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Run the complete read-only preflight.
|
|
225
|
+
*
|
|
226
|
+
* Every check here is a read. Nothing it calls creates a branch, writes a file,
|
|
227
|
+
* touches install state, commits, pushes, or opens a pull request — which is
|
|
228
|
+
* what lets the caller treat a failure as "nothing happened".
|
|
229
|
+
*
|
|
230
|
+
* The progress lines are emitted in a fixed order — repository/worktree, default
|
|
231
|
+
* branch, remote, git, gh, code owner — so two runs on different machines
|
|
232
|
+
* produce comparable output.
|
|
233
|
+
*/
|
|
234
|
+
export async function runMergeWorkflowPreflight(deps, options) {
|
|
235
|
+
// Managed paths are constants; this guards a future edit, not today's input.
|
|
236
|
+
for (const relPath of MERGE_WORKFLOW_MANAGED_RELPATHS) {
|
|
237
|
+
if (!isSafeManagedRelPath(relPath))
|
|
238
|
+
return { ok: false, result: fail("write_failed") };
|
|
239
|
+
}
|
|
240
|
+
if (!(await commandSucceeds(deps.runGit, ["--version"]))) {
|
|
241
|
+
return { ok: false, result: fail("git_unavailable") };
|
|
242
|
+
}
|
|
243
|
+
if (!(await commandSucceeds(deps.runGh, ["--version"]))) {
|
|
244
|
+
return { ok: false, result: fail("gh_unavailable") };
|
|
245
|
+
}
|
|
246
|
+
const repositoryRoot = await captureStdout(deps.runGit, ["rev-parse", "--show-toplevel"]);
|
|
247
|
+
if (!repositoryRoot)
|
|
248
|
+
return { ok: false, result: fail("not_repository_root") };
|
|
249
|
+
const normalizedCwd = deps.cwd.endsWith("/") ? deps.cwd.slice(0, -1) : deps.cwd;
|
|
250
|
+
const normalizedRoot = repositoryRoot.endsWith("/")
|
|
251
|
+
? repositoryRoot.slice(0, -1)
|
|
252
|
+
: repositoryRoot;
|
|
253
|
+
if (normalizedRoot !== normalizedCwd) {
|
|
254
|
+
return { ok: false, result: fail("not_repository_root") };
|
|
255
|
+
}
|
|
256
|
+
deps.log(` repository root: ${normalizedRoot}`);
|
|
257
|
+
// A clean worktree is required BEFORE a branch exists, so an unrelated
|
|
258
|
+
// customer change can never be swept into the managed commit below.
|
|
259
|
+
//
|
|
260
|
+
// `--untracked-files=all` rather than the default: git otherwise COLLAPSES an
|
|
261
|
+
// untracked directory to a single `?? .bridge/` entry, and the one path that
|
|
262
|
+
// must be tolerated below could not then be distinguished from its neighbours.
|
|
263
|
+
const status = await captureStdout(deps.runGit, ["status", "--porcelain", "--untracked-files=all"]);
|
|
264
|
+
if (status === null)
|
|
265
|
+
return { ok: false, result: fail("git_failed") };
|
|
266
|
+
const dirty = status
|
|
267
|
+
.split("\n")
|
|
268
|
+
.map((line) => line.trim())
|
|
269
|
+
.filter((line) => line.length > 0)
|
|
270
|
+
// The install manifest is Bridge's OWN record, written by this command and
|
|
271
|
+
// deliberately never staged. Counting it as a customer change made a
|
|
272
|
+
// successful install poison the very re-run the ticket requires to be a
|
|
273
|
+
// clean no-op: the first run left the file behind, and the second refused
|
|
274
|
+
// with `worktree_not_clean`. Exactly one path is tolerated, by full name, so
|
|
275
|
+
// any other file under `.bridge/` still makes the worktree dirty.
|
|
276
|
+
.filter((line) => !line.endsWith(MCP_INSTALL_STATE_RELPATH));
|
|
277
|
+
if (dirty.length > 0)
|
|
278
|
+
return { ok: false, result: fail("worktree_not_clean") };
|
|
279
|
+
deps.log(" worktree: clean");
|
|
280
|
+
const ghDefaultBranch = await captureStdout(deps.runGh, [
|
|
281
|
+
"repo",
|
|
282
|
+
"view",
|
|
283
|
+
"--json",
|
|
284
|
+
"defaultBranchRef",
|
|
285
|
+
"--jq",
|
|
286
|
+
".defaultBranchRef.name",
|
|
287
|
+
]);
|
|
288
|
+
if (!ghDefaultBranch)
|
|
289
|
+
return { ok: false, result: fail("default_branch_unresolved") };
|
|
290
|
+
deps.log(` default branch: ${ghDefaultBranch}`);
|
|
291
|
+
const remoteName = "origin";
|
|
292
|
+
const remoteUrl = await captureStdout(deps.runGit, ["remote", "get-url", remoteName]);
|
|
293
|
+
if (!remoteUrl)
|
|
294
|
+
return { ok: false, result: fail("remote_not_github") };
|
|
295
|
+
const remoteRepository = parseGithubRemote(remoteUrl);
|
|
296
|
+
if (!remoteRepository)
|
|
297
|
+
return { ok: false, result: fail("remote_not_github") };
|
|
298
|
+
deps.log(` remote: ${remoteName} -> ${remoteRepository}`);
|
|
299
|
+
const ghRepository = await captureStdout(deps.runGh, [
|
|
300
|
+
"repo",
|
|
301
|
+
"view",
|
|
302
|
+
"--json",
|
|
303
|
+
"nameWithOwner",
|
|
304
|
+
"--jq",
|
|
305
|
+
".nameWithOwner",
|
|
306
|
+
]);
|
|
307
|
+
if (!ghRepository)
|
|
308
|
+
return { ok: false, result: fail("repository_identity_mismatch") };
|
|
309
|
+
if (ghRepository !== remoteRepository) {
|
|
310
|
+
return { ok: false, result: fail("repository_identity_mismatch") };
|
|
311
|
+
}
|
|
312
|
+
deps.log(` gh: authenticated for ${ghRepository}`);
|
|
313
|
+
let codeOwner;
|
|
314
|
+
try {
|
|
315
|
+
codeOwner = validateMergeWorkflowCodeOwner(options.codeOwner);
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
if (err instanceof MergeWorkflowCodeOwnerError) {
|
|
319
|
+
return { ok: false, result: fail("codeowner_invalid") };
|
|
320
|
+
}
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
deps.log(` code owner: ${codeOwner}`);
|
|
324
|
+
// --- content classification -------------------------------------------
|
|
325
|
+
let existingWorkflow;
|
|
326
|
+
try {
|
|
327
|
+
existingWorkflow = await readOrNull(deps, joinCwd(normalizedRoot, MERGE_WORKFLOW_PATH));
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return { ok: false, result: fail("write_failed") };
|
|
331
|
+
}
|
|
332
|
+
const classification = classifyMergeWorkflowContent(existingWorkflow);
|
|
333
|
+
deps.log(` workflow: ${MERGE_WORKFLOW_CLASSIFICATION_TEXT[classification]}`);
|
|
334
|
+
if (classification === "modified_or_unrecognized") {
|
|
335
|
+
return { ok: false, result: fail("workflow_modified_or_unrecognized") };
|
|
336
|
+
}
|
|
337
|
+
// Companion artifacts are inspected BEFORE any write. An existing file whose
|
|
338
|
+
// bytes differ from what this owner would produce is a conflict, not a target:
|
|
339
|
+
// it may be a previous install for a DIFFERENT code owner, or a customer edit.
|
|
340
|
+
const companions = [
|
|
341
|
+
[MERGE_WORKFLOW_CODEOWNERS_RELPATH, renderMergeWorkflowCodeownersFragment(codeOwner)],
|
|
342
|
+
[MERGE_WORKFLOW_GUIDANCE_RELPATH, renderMergeWorkflowGuidance(codeOwner)],
|
|
343
|
+
];
|
|
344
|
+
let allCompanionsIdentical = true;
|
|
345
|
+
for (const [relPath, expected] of companions) {
|
|
346
|
+
let existing;
|
|
347
|
+
try {
|
|
348
|
+
existing = await readOrNull(deps, joinCwd(normalizedRoot, relPath));
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return { ok: false, result: fail("write_failed") };
|
|
352
|
+
}
|
|
353
|
+
if (existing === null) {
|
|
354
|
+
allCompanionsIdentical = false;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (existing !== expected) {
|
|
358
|
+
return { ok: false, result: fail("companion_artifact_conflict") };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// The aggregate no-op requires EVERY managed artifact to match. A workflow that
|
|
362
|
+
// is already identical while its guidance is missing is still an installation.
|
|
363
|
+
const effective = classification === "identical" && !allCompanionsIdentical ? "not_installed" : classification;
|
|
364
|
+
return {
|
|
365
|
+
ok: true,
|
|
366
|
+
preflight: {
|
|
367
|
+
repositoryRoot: normalizedRoot,
|
|
368
|
+
defaultBranch: ghDefaultBranch,
|
|
369
|
+
remoteName,
|
|
370
|
+
repository: ghRepository,
|
|
371
|
+
codeOwner,
|
|
372
|
+
classification: effective,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Find an open pull request already carrying this installation.
|
|
378
|
+
*
|
|
379
|
+
* Looked up by the deterministic managed BRANCH identity rather than by title:
|
|
380
|
+
* a title is prose anyone can change, while the branch is the thing this command
|
|
381
|
+
* would otherwise try to create.
|
|
382
|
+
*/
|
|
383
|
+
async function findExistingManagedPr(deps, branch) {
|
|
384
|
+
const raw = await captureStdout(deps.runGh, [
|
|
385
|
+
"pr",
|
|
386
|
+
"list",
|
|
387
|
+
"--state",
|
|
388
|
+
"open",
|
|
389
|
+
"--head",
|
|
390
|
+
branch,
|
|
391
|
+
"--json",
|
|
392
|
+
"url,headRefName,baseRefName,files",
|
|
393
|
+
"--limit",
|
|
394
|
+
"1",
|
|
395
|
+
]);
|
|
396
|
+
if (!raw)
|
|
397
|
+
return null;
|
|
398
|
+
try {
|
|
399
|
+
const parsed = JSON.parse(raw);
|
|
400
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
401
|
+
return null;
|
|
402
|
+
const first = parsed[0];
|
|
403
|
+
const files = Array.isArray(first.files)
|
|
404
|
+
? first.files
|
|
405
|
+
.map((f) => (f && typeof f === "object" ? f.path : undefined))
|
|
406
|
+
.filter((p) => typeof p === "string")
|
|
407
|
+
: [];
|
|
408
|
+
if (typeof first.url !== "string")
|
|
409
|
+
return null;
|
|
410
|
+
return {
|
|
411
|
+
url: first.url,
|
|
412
|
+
headRefName: typeof first.headRefName === "string" ? first.headRefName : "",
|
|
413
|
+
baseRefName: typeof first.baseRefName === "string" ? first.baseRefName : "",
|
|
414
|
+
files,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Confirm a detected pull request really is this installation's review surface.
|
|
423
|
+
*
|
|
424
|
+
* Reporting an unverified pull request as "already open" would tell an operator
|
|
425
|
+
* the review exists when it might touch different files, target a different
|
|
426
|
+
* base, or have been opened for something else entirely.
|
|
427
|
+
*/
|
|
428
|
+
function existingPrIsExpected(pr, branch, defaultBranch) {
|
|
429
|
+
if (pr.headRefName !== branch)
|
|
430
|
+
return false;
|
|
431
|
+
if (pr.baseRefName !== defaultBranch)
|
|
432
|
+
return false;
|
|
433
|
+
if (pr.files.length === 0)
|
|
434
|
+
return false;
|
|
435
|
+
const managed = new Set(MERGE_WORKFLOW_MANAGED_RELPATHS);
|
|
436
|
+
return pr.files.every((f) => managed.has(f));
|
|
437
|
+
}
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Pull request body
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
/** Render the pull request body. Deterministic, and free of any secret value. */
|
|
442
|
+
export function renderMergeWorkflowPrBody(codeOwner, upgrade) {
|
|
443
|
+
const owner = validateMergeWorkflowCodeOwner(codeOwner);
|
|
444
|
+
return [
|
|
445
|
+
upgrade
|
|
446
|
+
? "This pull request upgrades the Bridge Epic Implementer merge workflow to contract version " +
|
|
447
|
+
String(MERGE_WORKFLOW_EXPECTED_VERSION) + "."
|
|
448
|
+
: "This pull request installs the Bridge Epic Implementer merge workflow, contract version " +
|
|
449
|
+
String(MERGE_WORKFLOW_EXPECTED_VERSION) + ".",
|
|
450
|
+
"",
|
|
451
|
+
"The workflow is the write actor for Epic Implementer child merges. Bridge's own",
|
|
452
|
+
"GitHub App installation is `contents: read` and cannot merge; the ephemeral",
|
|
453
|
+
"`GITHUB_TOKEN` issued to this workflow can. Reviewing it before it lands is the",
|
|
454
|
+
"security control, which is why this pull request was opened rather than applied.",
|
|
455
|
+
"",
|
|
456
|
+
"## Files",
|
|
457
|
+
"",
|
|
458
|
+
...MERGE_WORKFLOW_MANAGED_RELPATHS.map((p) => `- \`${p}\``),
|
|
459
|
+
"",
|
|
460
|
+
"## What you must do after merging",
|
|
461
|
+
"",
|
|
462
|
+
"1. Create a repository Actions secret named `" +
|
|
463
|
+
MERGE_WORKFLOW_API_KEY_SECRET_NAME +
|
|
464
|
+
"` holding a Bridge API key for this repository. Bridge never sees the value.",
|
|
465
|
+
"2. Copy the rule from `" +
|
|
466
|
+
MERGE_WORKFLOW_CODEOWNERS_RELPATH +
|
|
467
|
+
"` into your active CODEOWNERS file.",
|
|
468
|
+
"3. Add a default-branch repository rule requiring a pull request and code-owner",
|
|
469
|
+
" review, so `" + MERGE_WORKFLOW_PATH + "` cannot be rewritten without review.",
|
|
470
|
+
"",
|
|
471
|
+
"Code owner proposed for that path: " + owner,
|
|
472
|
+
"",
|
|
473
|
+
"## What this pull request does not do",
|
|
474
|
+
"",
|
|
475
|
+
"It does not merge itself, enable anything, or change repository settings. Bridge",
|
|
476
|
+
"performed no write to this repository: the branch, the commit, and this pull",
|
|
477
|
+
"request were all created locally with your own git and `gh`.",
|
|
478
|
+
"",
|
|
479
|
+
"See `" + MERGE_WORKFLOW_GUIDANCE_RELPATH + "` for the full setup steps.",
|
|
480
|
+
"",
|
|
481
|
+
].join("\n");
|
|
482
|
+
}
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// Orchestration
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
async function consent(deps, promptText) {
|
|
487
|
+
if (!deps.confirmWrite)
|
|
488
|
+
return false;
|
|
489
|
+
return await deps.confirmWrite(promptText);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Generate the managed artifacts and open the customer's review pull request.
|
|
493
|
+
*
|
|
494
|
+
* Returns a process exit code: `0` for every success shape (new pull request,
|
|
495
|
+
* upgrade pull request, verified existing pull request, byte-identical no-op,
|
|
496
|
+
* dry run) and `1` for every failure. A failure prints the stable reason token
|
|
497
|
+
* first and one remediation sentence second, both on stderr.
|
|
498
|
+
*
|
|
499
|
+
* Nothing already created is undone on a later failure. A branch that exists, a
|
|
500
|
+
* file that was written, or a ref that was pushed is left in place with a
|
|
501
|
+
* bounded recovery instruction: deleting a customer's branch to tidy up after
|
|
502
|
+
* ourselves is a destructive act taken on incomplete information.
|
|
503
|
+
*/
|
|
504
|
+
export async function runMergeWorkflowInstall(cwd, options, deps) {
|
|
505
|
+
const report = (result) => {
|
|
506
|
+
if (result.outcome === "failed") {
|
|
507
|
+
deps.errorLog(`${result.reason}`);
|
|
508
|
+
deps.errorLog(result.remediation);
|
|
509
|
+
return 1;
|
|
510
|
+
}
|
|
511
|
+
return 0;
|
|
512
|
+
};
|
|
513
|
+
deps.log("Bridge merge-workflow installer");
|
|
514
|
+
deps.log(" preflight:");
|
|
515
|
+
const pre = await runMergeWorkflowPreflight({ ...deps, cwd }, options);
|
|
516
|
+
if (!pre.ok)
|
|
517
|
+
return report(pre.result);
|
|
518
|
+
const preflight = pre.preflight;
|
|
519
|
+
if (preflight.classification === "identical") {
|
|
520
|
+
deps.log("");
|
|
521
|
+
deps.log("Clean no-op: every managed artifact already matches this build.");
|
|
522
|
+
deps.log(` ${MERGE_WORKFLOW_PATH} (contract version ${MERGE_WORKFLOW_EXPECTED_VERSION})`);
|
|
523
|
+
deps.log("Nothing was written and no pull request was opened.");
|
|
524
|
+
return 0;
|
|
525
|
+
}
|
|
526
|
+
const upgrade = preflight.classification === "managed_version_older";
|
|
527
|
+
const branch = upgrade ? MERGE_WORKFLOW_UPGRADE_BRANCH : MERGE_WORKFLOW_INSTALL_BRANCH;
|
|
528
|
+
// Asked BEFORE a local branch exists, so a verified open pull request is
|
|
529
|
+
// reported instead of duplicated rather than after a duplicate branch is made.
|
|
530
|
+
const existing = await findExistingManagedPr(deps, branch);
|
|
531
|
+
if (existing) {
|
|
532
|
+
if (!existingPrIsExpected(existing, branch, preflight.defaultBranch)) {
|
|
533
|
+
return report(fail("existing_pr_unverifiable"));
|
|
534
|
+
}
|
|
535
|
+
deps.log("");
|
|
536
|
+
deps.log(`${upgrade ? "Upgrade" : "Installation"} pull request is already open.`);
|
|
537
|
+
deps.log(` branch: ${branch}`);
|
|
538
|
+
deps.log(` pull request: ${existing.url}`);
|
|
539
|
+
deps.log("Review and merge it; re-running this command opens nothing new.");
|
|
540
|
+
return 0;
|
|
541
|
+
}
|
|
542
|
+
deps.log("");
|
|
543
|
+
deps.log(upgrade ? "Planned upgrade:" : "Planned installation:");
|
|
544
|
+
deps.log(` branch: ${branch}`);
|
|
545
|
+
for (const relPath of MERGE_WORKFLOW_MANAGED_RELPATHS)
|
|
546
|
+
deps.log(` file: ${relPath}`);
|
|
547
|
+
deps.log(` commit: ${upgrade ? MERGE_WORKFLOW_UPGRADE_COMMIT_SUBJECT : MERGE_WORKFLOW_INSTALL_COMMIT_SUBJECT}`);
|
|
548
|
+
deps.log(` push: ${preflight.remoteName}/${branch} (your git identity)`);
|
|
549
|
+
deps.log(` pull request: into ${preflight.defaultBranch} (your gh identity)`);
|
|
550
|
+
if (options.dryRun) {
|
|
551
|
+
deps.log("");
|
|
552
|
+
deps.log("--dry-run: nothing was written, committed, pushed, or opened.");
|
|
553
|
+
return 0;
|
|
554
|
+
}
|
|
555
|
+
if (!(await consent(deps, `Create branch ${branch}, write ${MERGE_WORKFLOW_MANAGED_RELPATHS.length} files, commit, push, and open a pull request? [y/N] `))) {
|
|
556
|
+
return report(fail("consent_declined"));
|
|
557
|
+
}
|
|
558
|
+
// --- first mutation: the branch ----------------------------------------
|
|
559
|
+
if (!(await commandSucceeds(deps.runGit, ["checkout", "-b", branch]))) {
|
|
560
|
+
return report(fail("git_failed"));
|
|
561
|
+
}
|
|
562
|
+
const artifacts = [
|
|
563
|
+
[MERGE_WORKFLOW_PATH, renderEpicImplementerMergeWorkflow()],
|
|
564
|
+
[MERGE_WORKFLOW_CODEOWNERS_RELPATH, renderMergeWorkflowCodeownersFragment(preflight.codeOwner)],
|
|
565
|
+
[MERGE_WORKFLOW_GUIDANCE_RELPATH, renderMergeWorkflowGuidance(preflight.codeOwner)],
|
|
566
|
+
];
|
|
567
|
+
const written = [];
|
|
568
|
+
for (const [relPath, content] of artifacts) {
|
|
569
|
+
const absPath = joinCwd(preflight.repositoryRoot, relPath);
|
|
570
|
+
const parent = absPath.slice(0, absPath.lastIndexOf("/"));
|
|
571
|
+
try {
|
|
572
|
+
// Read-compare-write: an identical target is never rewritten, so a partial
|
|
573
|
+
// re-run does not churn file mtimes or produce an empty diff entry.
|
|
574
|
+
const existingContent = await readOrNull(deps, absPath);
|
|
575
|
+
if (existingContent !== content) {
|
|
576
|
+
await deps.mkdir(parent, { recursive: true });
|
|
577
|
+
await deps.writeFile(absPath, content, { encoding: "utf-8" });
|
|
578
|
+
}
|
|
579
|
+
written.push(relPath);
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
return report(fail("write_failed"));
|
|
583
|
+
}
|
|
584
|
+
// Recorded per artifact, with the POSIX repository-relative path only.
|
|
585
|
+
await deps.recordArtifact(preflight.repositoryRoot, relPath);
|
|
586
|
+
}
|
|
587
|
+
// Only the explicit managed paths are staged, passed after `--` so a path can
|
|
588
|
+
// never be read as a git option.
|
|
589
|
+
if (!(await commandSucceeds(deps.runGit, ["add", "--", ...MERGE_WORKFLOW_MANAGED_RELPATHS]))) {
|
|
590
|
+
return report(fail("git_failed"));
|
|
591
|
+
}
|
|
592
|
+
const commitSubject = upgrade
|
|
593
|
+
? MERGE_WORKFLOW_UPGRADE_COMMIT_SUBJECT
|
|
594
|
+
: MERGE_WORKFLOW_INSTALL_COMMIT_SUBJECT;
|
|
595
|
+
if (!(await commandSucceeds(deps.runGit, ["commit", "-m", commitSubject]))) {
|
|
596
|
+
return report(fail("git_failed"));
|
|
597
|
+
}
|
|
598
|
+
if (!(await commandSucceeds(deps.runGit, [
|
|
599
|
+
"push",
|
|
600
|
+
"--set-upstream",
|
|
601
|
+
preflight.remoteName,
|
|
602
|
+
branch,
|
|
603
|
+
]))) {
|
|
604
|
+
return report(fail("git_failed"));
|
|
605
|
+
}
|
|
606
|
+
const prTitle = upgrade ? MERGE_WORKFLOW_UPGRADE_PR_TITLE : MERGE_WORKFLOW_INSTALL_PR_TITLE;
|
|
607
|
+
const prUrl = await captureStdout(deps.runGh, [
|
|
608
|
+
"pr",
|
|
609
|
+
"create",
|
|
610
|
+
"--base",
|
|
611
|
+
preflight.defaultBranch,
|
|
612
|
+
"--head",
|
|
613
|
+
branch,
|
|
614
|
+
"--title",
|
|
615
|
+
prTitle,
|
|
616
|
+
"--body",
|
|
617
|
+
renderMergeWorkflowPrBody(preflight.codeOwner, upgrade),
|
|
618
|
+
]);
|
|
619
|
+
if (!prUrl) {
|
|
620
|
+
deps.errorLog("pr_open_failed");
|
|
621
|
+
deps.errorLog(`${REMEDIATION.pr_open_failed} The branch ${branch} is pushed to ${preflight.remoteName} and was left in place.`);
|
|
622
|
+
return 1;
|
|
623
|
+
}
|
|
624
|
+
deps.log("");
|
|
625
|
+
deps.log(upgrade ? "Upgrade pull request opened." : "Installation pull request opened.");
|
|
626
|
+
deps.log(` branch: ${branch}`);
|
|
627
|
+
for (const relPath of written)
|
|
628
|
+
deps.log(` file: ${relPath}`);
|
|
629
|
+
deps.log(` pull request: ${prUrl}`);
|
|
630
|
+
deps.log("");
|
|
631
|
+
deps.log("Next, in this order:");
|
|
632
|
+
deps.log(" 1. Review the pull request — the workflow is what performs the privileged merge.");
|
|
633
|
+
deps.log(` 2. Create the repository Actions secret ${MERGE_WORKFLOW_API_KEY_SECRET_NAME} through GitHub. Never pass its value to this command.`);
|
|
634
|
+
deps.log(` 3. Merge the rule in ${MERGE_WORKFLOW_CODEOWNERS_RELPATH} into your active CODEOWNERS file.`);
|
|
635
|
+
deps.log(" 4. Add a default-branch rule requiring a pull request and code-owner review.");
|
|
636
|
+
deps.log(" 5. Merge the pull request yourself; Bridge never merges it.");
|
|
637
|
+
return 0;
|
|
638
|
+
}
|