@bridge_gpt/mcp-server 0.2.69 → 0.2.70
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 +140 -292
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +4 -4
- package/build/artifact-diff.js +110 -0
- package/build/bin-entrypoint.js +36 -0
- package/build/bridge-permissions.js +23 -4
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/codex-bridge-profile.js +1 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +598 -19
- package/build/conductor/bring-up-facts.js +9 -9
- package/build/conductor/doctor.js +466 -18
- package/build/conductor/epic-child-continuation.js +54 -3
- package/build/conductor/epic-implementer-cli.js +132 -34
- package/build/conductor/epic-runtime.js +2 -2
- package/build/conductor/epic-state.js +1 -1
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +4 -2
- package/build/conductor/launch-need-probes.js +337 -0
- package/build/conductor/local-merge.js +241 -56
- package/build/conductor/merge-credential.js +172 -0
- package/build/conductor/merge-execution-eligibility.js +1 -1
- package/build/conductor/merged-child-check.js +95 -0
- package/build/conductor/plan-trim.js +30 -0
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +11 -6
- package/build/conductor/readiness-prerequisites.js +41 -0
- package/build/conductor/readiness-sections.js +345 -55
- package/build/conductor/readiness.js +99 -87
- package/build/conductor/recovery-cli.js +606 -21
- package/build/conductor/recovery-operations.js +306 -7
- package/build/conductor/repair-contract.js +47 -2
- package/build/conductor/repository-workflow-remedy.js +8 -6
- package/build/conductor/stored-merge-credential.js +76 -0
- package/build/conductor/stored-merge-token-probe.js +28 -0
- package/build/conductor/temporary-plan.js +50 -0
- package/build/conductor/wait-run-cli.js +759 -0
- package/build/conductor/worker-database-doctor-inspector.js +49 -0
- package/build/conductor-bin.js +2 -2
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
- package/build/conductor-removed-stub-bin.js +2 -5
- package/build/credential-store.js +356 -13
- package/build/docs.generated.js +26 -13
- package/build/doctor.js +94 -32
- package/build/drive-epic-help.js +212 -0
- package/build/drive-epic.js +1840 -373
- package/build/epic-ci-workflow-drift.js +77 -0
- package/build/epic-ci-workflow-install.js +183 -0
- package/build/epic-ci-workflow.js +157 -0
- package/build/epic-contract-context.js +326 -0
- package/build/epic-implementer-bin.js +57 -41
- package/build/epic-implementer-claude-hook-bin.js +2 -2
- package/build/escalation-reachability.js +134 -0
- package/build/executor/cleanup-report.js +99 -0
- package/build/executor/cli.js +12 -1
- package/build/executor/completion-contract.js +66 -11
- package/build/executor/completion-supervision.js +99 -0
- package/build/executor/delivery-outcome.js +179 -0
- package/build/executor/deps.js +3 -0
- package/build/executor/env.js +71 -0
- package/build/executor/failure-reason-codes.js +126 -0
- package/build/executor/heartbeat.js +38 -0
- package/build/executor/http-client.js +72 -0
- package/build/executor/integration-job.js +298 -5
- package/build/executor/job-errors.js +36 -6
- package/build/executor/job-runner.js +720 -36
- package/build/executor/likely-tests.js +36 -2
- package/build/executor/merge-job.js +145 -2
- package/build/executor/observation.js +73 -4
- package/build/executor/permissions.js +193 -97
- package/build/executor/preflight.js +14 -3
- package/build/executor/process-containment.js +28 -2
- package/build/executor/prompt-spec.js +50 -5
- package/build/executor/rebase-finalization.js +38 -3
- package/build/executor/reconcile-overlap-job.js +35 -7
- package/build/executor/required-work-ledger.js +236 -81
- package/build/executor/resume-pre-spawn.js +38 -1
- package/build/executor/runner.js +91 -3
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +37 -2
- package/build/executor/worker-cli-launcher.js +259 -0
- package/build/executor/worker-command.js +41 -4
- package/build/executor/worker-database-target.js +91 -0
- package/build/executor/worker-database.js +908 -0
- package/build/executor/worker-finalization.js +106 -0
- package/build/executor/worker-guard-hook.js +203 -0
- package/build/executor/worker-resources.js +47 -0
- package/build/executor/worker-verification-guidance.js +15 -2
- package/build/executor/worktree.js +303 -15
- package/build/executor-launch.js +11 -1
- package/build/generated-assets-doctor.js +9 -1
- package/build/generated-assets-health.js +13 -4
- package/build/generated-assets-legacy.js +39 -0
- package/build/generated-assets-paths.js +12 -1
- package/build/generated-assets-repair.js +4 -1
- package/build/generated-assets-test-support.js +12 -0
- package/build/github-remote.js +33 -0
- package/build/github-sign-in.js +62 -0
- package/build/index.js +1067 -536
- package/build/init.js +39 -1
- package/build/install-bridge-conductor-gate.js +78 -0
- package/build/install-bridge-conductor-setup.js +413 -0
- package/build/install-bridge-conductor.js +1226 -267
- package/build/install-bridge.js +376 -60
- package/build/mcp-install-state.js +8 -2
- package/build/merge-pull-request.js +6 -0
- package/build/merge-workflow-install.js +763 -345
- package/build/phase-result-artifacts.js +75 -20
- package/build/pipeline-orchestrator.js +247 -4
- package/build/pipelines.generated.js +35 -35
- package/build/plan-discovery.js +15 -2
- package/build/plan-execution-ledger.js +306 -17
- package/build/plan-phase-routing.js +48 -13
- package/build/plane/cli.js +105 -51
- package/build/plane/defaults.js +3 -0
- package/build/plane/plane-freshness.js +294 -0
- package/build/plane/schema-compatibility.js +137 -0
- package/build/plane/shutdown-deps.js +38 -0
- package/build/plane/shutdown.js +83 -8
- package/build/plane/terminal-run-cleanup.js +303 -0
- package/build/printed-remedy-commands.js +26 -4
- package/build/readiness-check.js +6 -2
- package/build/readiness-prerequisites.generated.js +532 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +28 -15
- package/build/test-command-detection.js +247 -0
- package/build/test-command-validation.js +73 -0
- package/build/testing-config-client.js +150 -0
- package/build/ticket-delivery-artifacts.js +258 -0
- package/build/ticket-delivery-enrollment.js +306 -0
- package/build/ticket-delivery.js +205 -0
- package/build/ticket-review-summary.js +14 -0
- package/build/upgrade-cli.js +13 -0
- package/build/v2-readiness-requirements.js +552 -0
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/build/worktree-core.js +22 -7
- package/docs/BRIDGE-DIRECTORY-README.md +48 -0
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
- package/docs/EPIC-IMPLEMENTER.md +104 -63
- package/docs/epic-implementer/getting-started.md +131 -0
- package/docs/epic-implementer/self-hosting.md +133 -0
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +1 -1
- package/pipelines/implement-ticket.json +8 -8
- package/pipelines/review-ticket.json +2 -2
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
|
@@ -1,49 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (BAPI-1136).
|
|
2
|
+
* The generate-and-PR artifact installer (BAPI-1136, generalized by BAPI-1460).
|
|
4
3
|
*
|
|
5
|
-
* One
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* One engine installs a named ARTIFACT SET: it generates the set's managed
|
|
5
|
+
* files, commits them on a managed branch, pushes with the CUSTOMER's own git
|
|
6
|
+
* identity, and opens a pull request with the customer's own `gh`. It never
|
|
8
7
|
* merges that pull request, and Bridge never writes to the repository at all.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Two sets exist today: the Epic Implementer merge workflow with its two review
|
|
9
|
+
* artifacts (`install-bridge conductor --merge-workflow`, below), and the
|
|
10
|
+
* Epic Implementer CI workflow (`epic-ci-workflow-install.ts`).
|
|
12
11
|
*
|
|
13
12
|
* ## Domain logic lives here, not in the CLI
|
|
14
13
|
*
|
|
15
14
|
* `install-bridge.ts` parses arguments and builds seams. Everything below — the
|
|
16
|
-
* preflight, the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* preflight, the classification, the consent summary, the worktree / commit /
|
|
16
|
+
* push / PR sequence — is here so it is unit-testable with in-memory fakes and
|
|
17
|
+
* so the CLI layer stays thin.
|
|
19
18
|
*
|
|
20
19
|
* ## Read-only until it is not
|
|
21
20
|
*
|
|
22
|
-
* The ENTIRE preflight
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* clean up. Nothing here can partially install.
|
|
21
|
+
* The ENTIRE preflight — local checks, the remote default-branch head, every
|
|
22
|
+
* managed file's content on that head, the open managed pull request, and the
|
|
23
|
+
* managed remote branch — completes before the first fetch, file write, commit,
|
|
24
|
+
* push, or pull request. Nothing here can partially install.
|
|
27
25
|
*
|
|
28
|
-
* ##
|
|
26
|
+
* ## The person's checkout is never touched
|
|
29
27
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
28
|
+
* All branch and file work happens in a temporary, DETACHED `git worktree`
|
|
29
|
+
* created from the verified default-branch head, outside the person's checkout,
|
|
30
|
+
* and removed on every exit path. The person's HEAD, index, and working files
|
|
31
|
+
* are unchanged at the end: no checkout, switch, reset, or stash ever runs
|
|
32
|
+
* against them. Content is classified from the default branch, not from
|
|
33
|
+
* whatever the person has checked out.
|
|
34
|
+
*
|
|
35
|
+
* ## Content states, and why `identical` is not the only no-op
|
|
36
|
+
*
|
|
37
|
+
* A file at a managed path is absent, byte-identical to the proposal, a complete
|
|
38
|
+
* older version Bridge itself published, or something else. Only the first and
|
|
39
|
+
* third are installable. "Something else" — a hand edit, a truncation, a newer
|
|
40
|
+
* version, a file that merely claims a version — is never overwritten: the
|
|
41
|
+
* installer prints a unified diff of what it would have written against what is
|
|
42
|
+
* there and reports a named refusal.
|
|
43
|
+
*
|
|
44
|
+
* ## Re-runs find their own work
|
|
45
|
+
*
|
|
46
|
+
* A re-run that finds an open managed pull request whose contents match the
|
|
47
|
+
* proposal reports it and writes nothing. One whose contents differ is reported
|
|
48
|
+
* for manual reconciliation. A pushed managed branch with no open pull request
|
|
49
|
+
* (left by an earlier `pr_open_failed`) is reported with a fixed next step; it
|
|
50
|
+
* is never reused, overwritten, or deleted automatically.
|
|
37
51
|
*
|
|
38
52
|
* ## Secret discipline
|
|
39
53
|
*
|
|
40
|
-
* No path here accepts, prompts for, reads, writes, or prints a credential.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* remote URL with an embedded token.
|
|
54
|
+
* No path here accepts, prompts for, reads, writes, or prints a credential. Raw
|
|
55
|
+
* `git` and `gh` output never reaches the operator: every failure is a stable
|
|
56
|
+
* named reason plus one remediation sentence, because subprocess text is
|
|
57
|
+
* unbounded and can carry a remote URL with an embedded token.
|
|
45
58
|
*/
|
|
46
|
-
import {
|
|
59
|
+
import { lstat as fsLstat, mkdtemp, rm } from "node:fs/promises";
|
|
60
|
+
import { tmpdir } from "node:os";
|
|
61
|
+
import { join, resolve } from "node:path";
|
|
62
|
+
import { renderUnifiedDiff } from "./artifact-diff.js";
|
|
47
63
|
import { MERGE_WORKFLOW_EXPECTED_VERSION, MERGE_WORKFLOW_PATH, } from "./conductor/merge-execution-contract.js";
|
|
48
64
|
import { MERGE_WORKFLOW_API_KEY_SECRET_NAME, } from "./merge-workflow-config.js";
|
|
49
65
|
import { MERGE_WORKFLOW_CLASSIFICATION_TEXT, classifyMergeWorkflowContent, } from "./merge-workflow-drift.js";
|
|
@@ -51,7 +67,7 @@ import { MERGE_WORKFLOW_CODEOWNERS_RELPATH, MERGE_WORKFLOW_GUIDANCE_RELPATH, MER
|
|
|
51
67
|
import { renderEpicImplementerMergeWorkflow } from "./merge-workflow.js";
|
|
52
68
|
import { MCP_INSTALL_STATE_RELPATH } from "./mcp-install-state.js";
|
|
53
69
|
// ---------------------------------------------------------------------------
|
|
54
|
-
// Managed identity
|
|
70
|
+
// Managed identity (merge-workflow set)
|
|
55
71
|
// ---------------------------------------------------------------------------
|
|
56
72
|
/** The managed branch this installer creates, for a fresh installation. */
|
|
57
73
|
export const MERGE_WORKFLOW_INSTALL_BRANCH = "bridge/merge-workflow-v" + String(MERGE_WORKFLOW_EXPECTED_VERSION);
|
|
@@ -73,7 +89,8 @@ export const MERGE_WORKFLOW_UPGRADE_PR_TITLE = MERGE_WORKFLOW_UPGRADE_COMMIT_SUB
|
|
|
73
89
|
*
|
|
74
90
|
* Closed on purpose: each value is printed verbatim as the first token of a
|
|
75
91
|
* stderr line, so the vocabulary is an operator-facing contract, and the set has
|
|
76
|
-
* to be greppable rather than assembled from subprocess text.
|
|
92
|
+
* to be greppable rather than assembled from subprocess text. Tokens may be
|
|
93
|
+
* added; none may be removed or renamed.
|
|
77
94
|
*/
|
|
78
95
|
export const MERGE_WORKFLOW_INSTALL_FAILURE_REASONS = [
|
|
79
96
|
/** `git` is not on PATH. */
|
|
@@ -106,6 +123,21 @@ export const MERGE_WORKFLOW_INSTALL_FAILURE_REASONS = [
|
|
|
106
123
|
"pr_open_failed",
|
|
107
124
|
/** A required write surface was declined, or no prompt was available. */
|
|
108
125
|
"consent_declined",
|
|
126
|
+
// --- BAPI-1460 -----------------------------------------------------------
|
|
127
|
+
/** A remote read (default-branch head, managed file, PR, or branch) gave no reliable answer. */
|
|
128
|
+
"remote_state_unverifiable",
|
|
129
|
+
/** An open managed pull request carries contents that differ from the current proposal. */
|
|
130
|
+
"existing_pr_differs",
|
|
131
|
+
/** A managed branch is pushed but has no open pull request. */
|
|
132
|
+
"managed_branch_without_pr",
|
|
133
|
+
/** The default branch moved after consent and no plan could be re-approved at the new head. */
|
|
134
|
+
"default_branch_moved",
|
|
135
|
+
/** Another actor created the managed branch or pull request during this run. */
|
|
136
|
+
"managed_state_changed",
|
|
137
|
+
/** The temporary worktree could not be created. */
|
|
138
|
+
"worktree_failed",
|
|
139
|
+
/** The test command for the CI workflow is missing or invalid. */
|
|
140
|
+
"test_command_invalid",
|
|
109
141
|
];
|
|
110
142
|
// ---------------------------------------------------------------------------
|
|
111
143
|
// Path safety
|
|
@@ -113,10 +145,10 @@ export const MERGE_WORKFLOW_INSTALL_FAILURE_REASONS = [
|
|
|
113
145
|
/**
|
|
114
146
|
* Reject any managed path that is not a plain, repository-relative POSIX path.
|
|
115
147
|
*
|
|
116
|
-
* The
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
148
|
+
* The managed paths are constants, so this can never fire in production — which
|
|
149
|
+
* is exactly why it is here. It keeps a future edit from introducing an
|
|
150
|
+
* absolute path, a `..` escape, or a Windows separator into the one code path
|
|
151
|
+
* that writes files and stages them for commit.
|
|
120
152
|
*/
|
|
121
153
|
export function isSafeManagedRelPath(relPath) {
|
|
122
154
|
if (typeof relPath !== "string" || relPath.length === 0)
|
|
@@ -137,49 +169,19 @@ function joinCwd(cwd, rel) {
|
|
|
137
169
|
return `${base}/${rel}`;
|
|
138
170
|
}
|
|
139
171
|
function isEnoent(err) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
/** Read a file, mapping "absent" to `null` and any other error to a throw. */
|
|
143
|
-
async function readOrNull(deps, absPath) {
|
|
144
|
-
try {
|
|
145
|
-
return await deps.readFile(absPath);
|
|
146
|
-
}
|
|
147
|
-
catch (err) {
|
|
148
|
-
if (isEnoent(err))
|
|
149
|
-
return null;
|
|
150
|
-
throw err;
|
|
151
|
-
}
|
|
172
|
+
const code = typeof err === "object" && err !== null ? err.code : undefined;
|
|
173
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
152
174
|
}
|
|
153
175
|
// ---------------------------------------------------------------------------
|
|
154
176
|
// Remote identity
|
|
155
177
|
// ---------------------------------------------------------------------------
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
* cannot work would open a pull request that can never actuate anything.
|
|
164
|
-
*/
|
|
165
|
-
export function parseGithubRemote(remoteUrl) {
|
|
166
|
-
if (typeof remoteUrl !== "string")
|
|
167
|
-
return null;
|
|
168
|
-
const trimmed = remoteUrl.trim();
|
|
169
|
-
if (trimmed.length === 0)
|
|
170
|
-
return null;
|
|
171
|
-
const patterns = [
|
|
172
|
-
/^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
173
|
-
/^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
174
|
-
/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
175
|
-
];
|
|
176
|
-
for (const pattern of patterns) {
|
|
177
|
-
const m = pattern.exec(trimmed);
|
|
178
|
-
if (m)
|
|
179
|
-
return `${m[1]}/${m[2]}`;
|
|
180
|
-
}
|
|
181
|
-
return null;
|
|
182
|
-
}
|
|
178
|
+
// BAPI-1461: moved to a dependency-free module so credential resolution can use
|
|
179
|
+
// it without loading this installer's graph; re-exported so this surface is unchanged.
|
|
180
|
+
import { parseGithubRemote } from "./github-remote.js";
|
|
181
|
+
export { parseGithubRemote };
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Remediation
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
183
185
|
/** One remediation sentence per named failure. Printed after the reason token. */
|
|
184
186
|
const REMEDIATION = {
|
|
185
187
|
git_unavailable: "Install git and re-run; this command creates the branch with your own git.",
|
|
@@ -192,15 +194,29 @@ const REMEDIATION = {
|
|
|
192
194
|
codeowner_invalid: "Pass exactly one real reviewer as `--codeowner @org/team` or `--codeowner @user`.",
|
|
193
195
|
workflow_modified_or_unrecognized: "Review the existing file by hand; this command never overwrites a workflow Bridge did not write.",
|
|
194
196
|
companion_artifact_conflict: "Review the differing file by hand and remove or reconcile it, then re-run.",
|
|
195
|
-
existing_pr_unverifiable: "Inspect the open Bridge
|
|
197
|
+
existing_pr_unverifiable: "Inspect the open Bridge pull request by hand and close it if it is not the one you want.",
|
|
196
198
|
write_failed: "Check directory permissions for the repository and re-run.",
|
|
197
|
-
git_failed: "Check the reported branch with git by hand; nothing already
|
|
198
|
-
pr_open_failed: "The branch is pushed; open
|
|
199
|
+
git_failed: "Check the reported branch with git by hand; nothing already pushed has been removed.",
|
|
200
|
+
pr_open_failed: "The branch is pushed; open a pull request from it by hand, or delete that remote branch, then re-run this command.",
|
|
199
201
|
consent_declined: "Re-run in an interactive terminal and accept the write to continue.",
|
|
202
|
+
remote_state_unverifiable: "Check that `gh` is authenticated and can read this repository, then re-run; nothing was written.",
|
|
203
|
+
existing_pr_differs: "An open Bridge pull request differs from what this build proposes; review it and close it, then re-run.",
|
|
204
|
+
managed_branch_without_pr: "Open a pull request from the managed branch, or delete that remote branch, then re-run this command.",
|
|
205
|
+
default_branch_moved: "The default branch changed while this command ran; re-run it to review the new proposal.",
|
|
206
|
+
managed_state_changed: "Another managed branch or pull request appeared while this command ran; review it, then re-run.",
|
|
207
|
+
worktree_failed: "A temporary git worktree could not be created; check free disk space and `git worktree list`, then re-run.",
|
|
208
|
+
test_command_invalid: "Save a valid test command through Bridge setup, then re-run.",
|
|
200
209
|
};
|
|
201
|
-
|
|
202
|
-
|
|
210
|
+
/** The remediation sentence for a reason. */
|
|
211
|
+
export function artifactSetRemediation(reason) {
|
|
212
|
+
return REMEDIATION[reason];
|
|
203
213
|
}
|
|
214
|
+
function fail(reason, extra = {}) {
|
|
215
|
+
return { outcome: "failed", reason, remediation: REMEDIATION[reason], warnings: [], ...extra };
|
|
216
|
+
}
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Subprocess helpers
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
204
220
|
async function commandSucceeds(run, args) {
|
|
205
221
|
try {
|
|
206
222
|
const result = await run(args);
|
|
@@ -211,31 +227,242 @@ async function commandSucceeds(run, args) {
|
|
|
211
227
|
}
|
|
212
228
|
}
|
|
213
229
|
async function captureStdout(run, args) {
|
|
230
|
+
const raw = await captureRawStdout(run, args);
|
|
231
|
+
return raw === null ? null : raw.trim();
|
|
232
|
+
}
|
|
233
|
+
/** Successful stdout, UNtrimmed — a leading status column is significant. */
|
|
234
|
+
async function captureRawStdout(run, args) {
|
|
214
235
|
try {
|
|
215
236
|
const result = await run(args);
|
|
216
237
|
if (result.status !== 0)
|
|
217
238
|
return null;
|
|
218
|
-
return result.stdout
|
|
239
|
+
return result.stdout;
|
|
219
240
|
}
|
|
220
241
|
catch {
|
|
221
242
|
return null;
|
|
222
243
|
}
|
|
223
244
|
}
|
|
245
|
+
/** Whether a porcelain-v1 status line names exactly the install manifest. */
|
|
246
|
+
function isInstallStateLine(line) {
|
|
247
|
+
// `XY <path>`: two status columns, a space, then the path.
|
|
248
|
+
return line.length > 3 && line.slice(3) === MCP_INSTALL_STATE_RELPATH;
|
|
249
|
+
}
|
|
250
|
+
/** Split `gh api --include` output into its status code and RAW body. */
|
|
251
|
+
function parseIncludedResponse(stdout) {
|
|
252
|
+
const status = /^HTTP\/[0-9.]+ (\d{3})\b/.exec(stdout);
|
|
253
|
+
if (!status)
|
|
254
|
+
return null;
|
|
255
|
+
const crlf = stdout.indexOf("\r\n\r\n");
|
|
256
|
+
const lf = stdout.indexOf("\n\n");
|
|
257
|
+
let cut;
|
|
258
|
+
if (crlf >= 0 && (lf < 0 || crlf < lf))
|
|
259
|
+
cut = crlf + 4;
|
|
260
|
+
else if (lf >= 0)
|
|
261
|
+
cut = lf + 2;
|
|
262
|
+
else
|
|
263
|
+
return null;
|
|
264
|
+
return { status: Number(status[1]), body: stdout.slice(cut) };
|
|
265
|
+
}
|
|
224
266
|
/**
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
267
|
+
* Read one file's RAW bytes from the remote at an immutable commit, through
|
|
268
|
+
* `gh api`. A confirmed 404 is `absent`; anything else that is not a clean 200
|
|
269
|
+
* is `unverifiable` — an uncertain read must never authorize a replacement.
|
|
270
|
+
*/
|
|
271
|
+
async function readRemoteFile(deps, repository, sha, relPath) {
|
|
272
|
+
const encodedPath = relPath.split("/").map(encodeURIComponent).join("/");
|
|
273
|
+
let result;
|
|
274
|
+
try {
|
|
275
|
+
result = await deps.runGh([
|
|
276
|
+
"api",
|
|
277
|
+
"--include",
|
|
278
|
+
"-H",
|
|
279
|
+
"Accept: application/vnd.github.raw",
|
|
280
|
+
`repos/${repository}/contents/${encodedPath}?ref=${encodeURIComponent(sha)}`,
|
|
281
|
+
]);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return { kind: "unverifiable" };
|
|
285
|
+
}
|
|
286
|
+
const parsed = parseIncludedResponse(result.stdout ?? "");
|
|
287
|
+
if (!parsed)
|
|
288
|
+
return { kind: "unverifiable" };
|
|
289
|
+
if (parsed.status === 200 && result.status === 0)
|
|
290
|
+
return { kind: "present", content: parsed.body };
|
|
291
|
+
if (parsed.status === 404)
|
|
292
|
+
return { kind: "absent" };
|
|
293
|
+
return { kind: "unverifiable" };
|
|
294
|
+
}
|
|
295
|
+
/** The SHA a remote branch points at; `null` when absent; `undefined` when unknown. */
|
|
296
|
+
async function readRemoteBranchSha(deps, remoteName, branch) {
|
|
297
|
+
const ref = `refs/heads/${branch}`;
|
|
298
|
+
const raw = await captureRawStdout(deps.runGit, ["ls-remote", remoteName, ref]);
|
|
299
|
+
if (raw === null)
|
|
300
|
+
return undefined;
|
|
301
|
+
const lines = raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
302
|
+
const matches = lines
|
|
303
|
+
.map((line) => line.split(/\s+/))
|
|
304
|
+
.filter((parts) => parts.length === 2 && parts[1] === ref);
|
|
305
|
+
if (matches.length === 0)
|
|
306
|
+
return lines.length === 0 ? null : undefined;
|
|
307
|
+
if (matches.length > 1 || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(matches[0][0]))
|
|
308
|
+
return undefined;
|
|
309
|
+
return matches[0][0];
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Find an open pull request already carrying this installation, by the
|
|
313
|
+
* deterministic managed BRANCH identity (a title is prose anyone can change).
|
|
314
|
+
* A lookup failure is distinct from a reliable "no open PR".
|
|
315
|
+
*/
|
|
316
|
+
async function findExistingManagedPr(deps, repository, branch) {
|
|
317
|
+
const raw = await captureStdout(deps.runGh, [
|
|
318
|
+
"pr",
|
|
319
|
+
"list",
|
|
320
|
+
"--repo",
|
|
321
|
+
repository,
|
|
322
|
+
"--state",
|
|
323
|
+
"open",
|
|
324
|
+
"--head",
|
|
325
|
+
branch,
|
|
326
|
+
"--json",
|
|
327
|
+
"url,headRefName,headRefOid,baseRefName,isCrossRepository,files",
|
|
328
|
+
"--limit",
|
|
329
|
+
"2",
|
|
330
|
+
]);
|
|
331
|
+
if (raw === null)
|
|
332
|
+
return { kind: "lookup_failed" };
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(raw);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return { kind: "lookup_failed" };
|
|
339
|
+
}
|
|
340
|
+
if (!Array.isArray(parsed))
|
|
341
|
+
return { kind: "lookup_failed" };
|
|
342
|
+
if (parsed.length === 0)
|
|
343
|
+
return { kind: "none" };
|
|
344
|
+
if (parsed.length > 1) {
|
|
345
|
+
// Two open PRs from one managed branch name cannot both be ours.
|
|
346
|
+
return { kind: "found", pr: { url: "", headRefName: "", headRefOid: "", baseRefName: "", isCrossRepository: null, files: [] } };
|
|
347
|
+
}
|
|
348
|
+
const first = parsed[0];
|
|
349
|
+
if (typeof first.url !== "string")
|
|
350
|
+
return { kind: "lookup_failed" };
|
|
351
|
+
const files = Array.isArray(first.files)
|
|
352
|
+
? first.files
|
|
353
|
+
.map((f) => (f && typeof f === "object" ? f.path : undefined))
|
|
354
|
+
.filter((p) => typeof p === "string")
|
|
355
|
+
: [];
|
|
356
|
+
return {
|
|
357
|
+
kind: "found",
|
|
358
|
+
pr: {
|
|
359
|
+
url: first.url,
|
|
360
|
+
headRefName: typeof first.headRefName === "string" ? first.headRefName : "",
|
|
361
|
+
headRefOid: typeof first.headRefOid === "string" ? first.headRefOid : "",
|
|
362
|
+
baseRefName: typeof first.baseRefName === "string" ? first.baseRefName : "",
|
|
363
|
+
isCrossRepository: typeof first.isCrossRepository === "boolean" ? first.isCrossRepository : null,
|
|
364
|
+
files,
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Confirm a detected pull request has the shape of this installation's review
|
|
370
|
+
* surface: the managed head branch in THIS repository, the default branch as
|
|
371
|
+
* base, and only managed paths changed.
|
|
372
|
+
*/
|
|
373
|
+
function existingPrIsExpected(pr, branch, defaultBranch, managedPaths) {
|
|
374
|
+
if (pr.headRefName !== branch)
|
|
375
|
+
return false;
|
|
376
|
+
if (pr.baseRefName !== defaultBranch)
|
|
377
|
+
return false;
|
|
378
|
+
if (pr.isCrossRepository !== false)
|
|
379
|
+
return false;
|
|
380
|
+
if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(pr.headRefOid))
|
|
381
|
+
return false;
|
|
382
|
+
if (pr.files.length === 0)
|
|
383
|
+
return false;
|
|
384
|
+
const managed = new Set(managedPaths);
|
|
385
|
+
return pr.files.every((f) => managed.has(f));
|
|
386
|
+
}
|
|
387
|
+
/** Whether every managed file at the PR head equals the proposal. `null` if unreadable. */
|
|
388
|
+
async function prContentMatchesProposal(deps, repository, pr, proposals) {
|
|
389
|
+
for (const [relPath, proposal] of proposals) {
|
|
390
|
+
const file = await readRemoteFile(deps, repository, pr.headRefOid, relPath);
|
|
391
|
+
if (file.kind === "unverifiable")
|
|
392
|
+
return null;
|
|
393
|
+
if (file.kind === "absent" || file.content !== proposal)
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
function compareUrl(repository, defaultBranch, branch) {
|
|
399
|
+
return `https://github.com/${repository}/compare/${encodeURI(defaultBranch)}...${encodeURI(branch)}`;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Classify every managed artifact against the default branch at `sha`.
|
|
403
|
+
* Read-only. Prints each refused artifact's diff.
|
|
404
|
+
*/
|
|
405
|
+
async function classifyAtRemote(deps, descriptor, repository, sha, quiet) {
|
|
406
|
+
const proposals = new Map();
|
|
407
|
+
let allIdentical = true;
|
|
408
|
+
let upgrade = false;
|
|
409
|
+
const refusals = [];
|
|
410
|
+
for (const artifact of descriptor.artifacts) {
|
|
411
|
+
const proposal = artifact.render();
|
|
412
|
+
proposals.set(artifact.relPath, proposal);
|
|
413
|
+
const remote = await readRemoteFile(deps, repository, sha, artifact.relPath);
|
|
414
|
+
if (remote.kind === "unverifiable")
|
|
415
|
+
return { ok: false, result: fail("remote_state_unverifiable") };
|
|
416
|
+
const existing = remote.kind === "present" ? remote.content : null;
|
|
417
|
+
const classification = artifact.classify(existing);
|
|
418
|
+
if (artifact.progressLabel && classification.text && !quiet) {
|
|
419
|
+
deps.log(` ${artifact.progressLabel}: ${classification.text}`);
|
|
420
|
+
}
|
|
421
|
+
if (classification.state === "conflict") {
|
|
422
|
+
refusals.push({
|
|
423
|
+
reason: classification.reason,
|
|
424
|
+
diff: { relPath: artifact.relPath, diff: renderUnifiedDiff(artifact.relPath, existing ?? "", proposal) },
|
|
425
|
+
});
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (classification.state !== "identical")
|
|
429
|
+
allIdentical = false;
|
|
430
|
+
if (classification.state === "managed_version_older")
|
|
431
|
+
upgrade = true;
|
|
432
|
+
}
|
|
433
|
+
if (refusals.length > 0) {
|
|
434
|
+
if (!quiet) {
|
|
435
|
+
for (const { diff } of refusals) {
|
|
436
|
+
deps.log("");
|
|
437
|
+
deps.log(`Not overwriting ${diff.relPath}. What this command would have written, against what is there:`);
|
|
438
|
+
for (const line of diff.diff.replace(/\n$/, "").split("\n"))
|
|
439
|
+
deps.log(line);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
ok: false,
|
|
444
|
+
result: fail(refusals[0].reason, { diffs: refusals.map((r) => r.diff) }),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
ok: true,
|
|
449
|
+
classification: allIdentical ? "identical" : upgrade ? "managed_version_older" : "not_installed",
|
|
450
|
+
proposals,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Run the complete read-only preflight for an artifact set.
|
|
230
455
|
*
|
|
231
|
-
*
|
|
232
|
-
* branch, remote
|
|
233
|
-
*
|
|
456
|
+
* Every check here is a read: executables, repository root, clean worktree,
|
|
457
|
+
* default branch, remote and `gh` identity, set-specific options, the remote
|
|
458
|
+
* default-branch head (via `ls-remote`, no fetch), and each managed file's raw
|
|
459
|
+
* bytes at that head (via `gh api`). Nothing it calls creates a branch, writes a
|
|
460
|
+
* file, touches install state, fetches, commits, pushes, or opens a pull request.
|
|
234
461
|
*/
|
|
235
|
-
export async function
|
|
462
|
+
export async function runArtifactSetPreflight(descriptor, deps) {
|
|
236
463
|
// Managed paths are constants; this guards a future edit, not today's input.
|
|
237
|
-
for (const
|
|
238
|
-
if (!isSafeManagedRelPath(relPath))
|
|
464
|
+
for (const artifact of descriptor.artifacts) {
|
|
465
|
+
if (!isSafeManagedRelPath(artifact.relPath))
|
|
239
466
|
return { ok: false, result: fail("write_failed") };
|
|
240
467
|
}
|
|
241
468
|
if (!(await commandSucceeds(deps.runGit, ["--version"]))) {
|
|
@@ -253,26 +480,21 @@ export async function runMergeWorkflowPreflight(deps, options) {
|
|
|
253
480
|
return { ok: false, result: fail("not_repository_root") };
|
|
254
481
|
}
|
|
255
482
|
deps.log(` repository root: ${normalizedRoot}`);
|
|
256
|
-
// A clean worktree is required
|
|
257
|
-
//
|
|
483
|
+
// A clean worktree is still required, so the person is not surprised by a
|
|
484
|
+
// proposal built while their own changes are in flight.
|
|
258
485
|
//
|
|
259
486
|
// `--untracked-files=all` rather than the default: git otherwise COLLAPSES an
|
|
260
|
-
// untracked directory to a single `?? .bridge/` entry
|
|
261
|
-
//
|
|
262
|
-
|
|
487
|
+
// untracked directory to a single `?? .bridge/` entry. The output is read RAW
|
|
488
|
+
// (untrimmed) because the first status column is significant, and exactly one
|
|
489
|
+
// path — the install manifest, by full name — is tolerated.
|
|
490
|
+
const status = await captureRawStdout(deps.runGit, ["status", "--porcelain", "--untracked-files=all"]);
|
|
263
491
|
if (status === null)
|
|
264
492
|
return { ok: false, result: fail("git_failed") };
|
|
265
493
|
const dirty = status
|
|
266
494
|
.split("\n")
|
|
267
|
-
.map((line) => line.
|
|
268
|
-
.filter((line) => line.length > 0)
|
|
269
|
-
|
|
270
|
-
// deliberately never staged. Counting it as a customer change made a
|
|
271
|
-
// successful install poison the very re-run the ticket requires to be a
|
|
272
|
-
// clean no-op: the first run left the file behind, and the second refused
|
|
273
|
-
// with `worktree_not_clean`. Exactly one path is tolerated, by full name, so
|
|
274
|
-
// any other file under `.bridge/` still makes the worktree dirty.
|
|
275
|
-
.filter((line) => !line.endsWith(MCP_INSTALL_STATE_RELPATH));
|
|
495
|
+
.map((line) => line.replace(/\r$/, ""))
|
|
496
|
+
.filter((line) => line.trim().length > 0)
|
|
497
|
+
.filter((line) => !isInstallStateLine(line));
|
|
276
498
|
if (dirty.length > 0)
|
|
277
499
|
return { ok: false, result: fail("worktree_not_clean") };
|
|
278
500
|
deps.log(" worktree: clean");
|
|
@@ -309,135 +531,75 @@ export async function runMergeWorkflowPreflight(deps, options) {
|
|
|
309
531
|
return { ok: false, result: fail("repository_identity_mismatch") };
|
|
310
532
|
}
|
|
311
533
|
deps.log(` gh: authenticated for ${ghRepository}`);
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return { ok: false, result: fail("codeowner_invalid") };
|
|
319
|
-
}
|
|
320
|
-
throw err;
|
|
534
|
+
if (descriptor.validateOptions) {
|
|
535
|
+
const checked = descriptor.validateOptions();
|
|
536
|
+
if (!checked.ok)
|
|
537
|
+
return { ok: false, result: fail(checked.reason) };
|
|
538
|
+
for (const line of checked.progress)
|
|
539
|
+
deps.log(line);
|
|
321
540
|
}
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
existingWorkflow = await readOrNull(deps, joinCwd(normalizedRoot, MERGE_WORKFLOW_PATH));
|
|
327
|
-
}
|
|
328
|
-
catch {
|
|
329
|
-
return { ok: false, result: fail("write_failed") };
|
|
330
|
-
}
|
|
331
|
-
const classification = classifyMergeWorkflowContent(existingWorkflow);
|
|
332
|
-
deps.log(` workflow: ${MERGE_WORKFLOW_CLASSIFICATION_TEXT[classification]}`);
|
|
333
|
-
if (classification === "modified_or_unrecognized") {
|
|
334
|
-
return { ok: false, result: fail("workflow_modified_or_unrecognized") };
|
|
335
|
-
}
|
|
336
|
-
// Companion artifacts are inspected BEFORE any write. An existing file whose
|
|
337
|
-
// bytes differ from what this owner would produce is a conflict, not a target:
|
|
338
|
-
// it may be a previous install for a DIFFERENT code owner, or a customer edit.
|
|
339
|
-
const companions = [
|
|
340
|
-
[MERGE_WORKFLOW_CODEOWNERS_RELPATH, renderMergeWorkflowCodeownersFragment(codeOwner)],
|
|
341
|
-
[MERGE_WORKFLOW_GUIDANCE_RELPATH, renderMergeWorkflowGuidance(codeOwner)],
|
|
342
|
-
];
|
|
343
|
-
let allCompanionsIdentical = true;
|
|
344
|
-
for (const [relPath, expected] of companions) {
|
|
345
|
-
let existing;
|
|
346
|
-
try {
|
|
347
|
-
existing = await readOrNull(deps, joinCwd(normalizedRoot, relPath));
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
return { ok: false, result: fail("write_failed") };
|
|
351
|
-
}
|
|
352
|
-
if (existing === null) {
|
|
353
|
-
allCompanionsIdentical = false;
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
if (existing !== expected) {
|
|
357
|
-
return { ok: false, result: fail("companion_artifact_conflict") };
|
|
358
|
-
}
|
|
541
|
+
// The default branch head, read from the remote. A managed branch is never
|
|
542
|
+
// the default branch; that is enforced again at push time.
|
|
543
|
+
if (descriptor.installBranch === ghDefaultBranch || descriptor.upgradeBranch === ghDefaultBranch) {
|
|
544
|
+
return { ok: false, result: fail("default_branch_unresolved") };
|
|
359
545
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
546
|
+
const defaultBranchSha = await readRemoteBranchSha(deps, remoteName, ghDefaultBranch);
|
|
547
|
+
if (defaultBranchSha === undefined)
|
|
548
|
+
return { ok: false, result: fail("remote_state_unverifiable") };
|
|
549
|
+
if (defaultBranchSha === null)
|
|
550
|
+
return { ok: false, result: fail("default_branch_unresolved") };
|
|
551
|
+
const classified = await classifyAtRemote(deps, descriptor, ghRepository, defaultBranchSha, false);
|
|
552
|
+
if (!classified.ok)
|
|
553
|
+
return { ok: false, result: classified.result };
|
|
363
554
|
return {
|
|
364
555
|
ok: true,
|
|
365
556
|
preflight: {
|
|
366
557
|
repositoryRoot: normalizedRoot,
|
|
367
558
|
defaultBranch: ghDefaultBranch,
|
|
559
|
+
defaultBranchSha,
|
|
368
560
|
remoteName,
|
|
369
561
|
repository: ghRepository,
|
|
370
|
-
|
|
371
|
-
|
|
562
|
+
classification: classified.classification,
|
|
563
|
+
proposals: classified.proposals,
|
|
372
564
|
},
|
|
373
565
|
};
|
|
374
566
|
}
|
|
375
567
|
/**
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* Looked up by the deterministic managed BRANCH identity rather than by title:
|
|
379
|
-
* a title is prose anyone can change, while the branch is the thing this command
|
|
380
|
-
* would otherwise try to create.
|
|
568
|
+
* Inspect the managed pull request and branch for `branch`. Read-only.
|
|
569
|
+
* Returns `null` when the way is clear, or the outcome to report.
|
|
381
570
|
*/
|
|
382
|
-
async function
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
"
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
return
|
|
397
|
-
try {
|
|
398
|
-
const parsed = JSON.parse(raw);
|
|
399
|
-
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
400
|
-
return null;
|
|
401
|
-
const first = parsed[0];
|
|
402
|
-
const files = Array.isArray(first.files)
|
|
403
|
-
? first.files
|
|
404
|
-
.map((f) => (f && typeof f === "object" ? f.path : undefined))
|
|
405
|
-
.filter((p) => typeof p === "string")
|
|
406
|
-
: [];
|
|
407
|
-
if (typeof first.url !== "string")
|
|
408
|
-
return null;
|
|
409
|
-
return {
|
|
410
|
-
url: first.url,
|
|
411
|
-
headRefName: typeof first.headRefName === "string" ? first.headRefName : "",
|
|
412
|
-
baseRefName: typeof first.baseRefName === "string" ? first.baseRefName : "",
|
|
413
|
-
files,
|
|
414
|
-
};
|
|
571
|
+
async function inspectManagedState(deps, descriptor, preflight, branch, upgrade) {
|
|
572
|
+
const managedPaths = descriptor.artifacts.map((a) => a.relPath);
|
|
573
|
+
const lookup = await findExistingManagedPr(deps, preflight.repository, branch);
|
|
574
|
+
if (lookup.kind === "lookup_failed")
|
|
575
|
+
return fail("remote_state_unverifiable", { branch });
|
|
576
|
+
if (lookup.kind === "found") {
|
|
577
|
+
if (!existingPrIsExpected(lookup.pr, branch, preflight.defaultBranch, managedPaths)) {
|
|
578
|
+
return fail("existing_pr_unverifiable", { branch });
|
|
579
|
+
}
|
|
580
|
+
const matches = await prContentMatchesProposal(deps, preflight.repository, lookup.pr, preflight.proposals);
|
|
581
|
+
if (matches === null)
|
|
582
|
+
return fail("existing_pr_unverifiable", { branch, prUrl: lookup.pr.url });
|
|
583
|
+
if (!matches)
|
|
584
|
+
return fail("existing_pr_differs", { branch, prUrl: lookup.pr.url });
|
|
585
|
+
return { outcome: "existing-pr", branch, prUrl: lookup.pr.url, upgrade, warnings: [] };
|
|
415
586
|
}
|
|
416
|
-
|
|
417
|
-
|
|
587
|
+
// Independently of PR discovery: a pushed managed branch with no open PR.
|
|
588
|
+
const branchSha = await readRemoteBranchSha(deps, preflight.remoteName, branch);
|
|
589
|
+
if (branchSha === undefined)
|
|
590
|
+
return fail("remote_state_unverifiable", { branch });
|
|
591
|
+
if (branchSha !== null) {
|
|
592
|
+
return fail("managed_branch_without_pr", {
|
|
593
|
+
branch,
|
|
594
|
+
compareUrl: compareUrl(preflight.repository, preflight.defaultBranch, branch),
|
|
595
|
+
});
|
|
418
596
|
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Confirm a detected pull request really is this installation's review surface.
|
|
422
|
-
*
|
|
423
|
-
* Reporting an unverified pull request as "already open" would tell an operator
|
|
424
|
-
* the review exists when it might touch different files, target a different
|
|
425
|
-
* base, or have been opened for something else entirely.
|
|
426
|
-
*/
|
|
427
|
-
function existingPrIsExpected(pr, branch, defaultBranch) {
|
|
428
|
-
if (pr.headRefName !== branch)
|
|
429
|
-
return false;
|
|
430
|
-
if (pr.baseRefName !== defaultBranch)
|
|
431
|
-
return false;
|
|
432
|
-
if (pr.files.length === 0)
|
|
433
|
-
return false;
|
|
434
|
-
const managed = new Set(MERGE_WORKFLOW_MANAGED_RELPATHS);
|
|
435
|
-
return pr.files.every((f) => managed.has(f));
|
|
597
|
+
return null;
|
|
436
598
|
}
|
|
437
599
|
// ---------------------------------------------------------------------------
|
|
438
|
-
//
|
|
600
|
+
// Merge-workflow descriptor
|
|
439
601
|
// ---------------------------------------------------------------------------
|
|
440
|
-
/** Render the pull request body. Deterministic, and free of any secret value. */
|
|
602
|
+
/** Render the merge-workflow pull request body. Deterministic, and free of any secret value. */
|
|
441
603
|
export function renderMergeWorkflowPrBody(codeOwner, upgrade) {
|
|
442
604
|
const owner = validateMergeWorkflowCodeOwner(codeOwner);
|
|
443
605
|
return [
|
|
@@ -479,159 +641,415 @@ export function renderMergeWorkflowPrBody(codeOwner, upgrade) {
|
|
|
479
641
|
"",
|
|
480
642
|
].join("\n");
|
|
481
643
|
}
|
|
644
|
+
/** A companion: absent or byte-identical is fine; anything else is a conflict. */
|
|
645
|
+
function companionArtifact(relPath, render) {
|
|
646
|
+
return {
|
|
647
|
+
relPath,
|
|
648
|
+
render,
|
|
649
|
+
classify: (existing) => {
|
|
650
|
+
if (existing === null)
|
|
651
|
+
return { state: "not_installed" };
|
|
652
|
+
if (existing === render())
|
|
653
|
+
return { state: "identical" };
|
|
654
|
+
return { state: "conflict", reason: "companion_artifact_conflict" };
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
/** The merge-workflow artifact set for one code owner. */
|
|
659
|
+
export function buildMergeWorkflowArtifactSet(codeOwner) {
|
|
660
|
+
let owner = codeOwner;
|
|
661
|
+
return {
|
|
662
|
+
setName: "merge-workflow",
|
|
663
|
+
installBranch: MERGE_WORKFLOW_INSTALL_BRANCH,
|
|
664
|
+
upgradeBranch: MERGE_WORKFLOW_UPGRADE_BRANCH,
|
|
665
|
+
installCommitSubject: MERGE_WORKFLOW_INSTALL_COMMIT_SUBJECT,
|
|
666
|
+
upgradeCommitSubject: MERGE_WORKFLOW_UPGRADE_COMMIT_SUBJECT,
|
|
667
|
+
installPrTitle: MERGE_WORKFLOW_INSTALL_PR_TITLE,
|
|
668
|
+
upgradePrTitle: MERGE_WORKFLOW_UPGRADE_PR_TITLE,
|
|
669
|
+
renderPrBody: (upgrade) => renderMergeWorkflowPrBody(owner, upgrade),
|
|
670
|
+
validateOptions: () => {
|
|
671
|
+
try {
|
|
672
|
+
owner = validateMergeWorkflowCodeOwner(codeOwner);
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
if (err instanceof MergeWorkflowCodeOwnerError)
|
|
676
|
+
return { ok: false, reason: "codeowner_invalid" };
|
|
677
|
+
throw err;
|
|
678
|
+
}
|
|
679
|
+
return { ok: true, progress: [` code owner: ${owner}`] };
|
|
680
|
+
},
|
|
681
|
+
consentPrompt: (plan) => `Create branch ${plan.branch}, write ${plan.artifacts.length} files, commit, push, and open a pull request? [y/N] `,
|
|
682
|
+
artifacts: [
|
|
683
|
+
{
|
|
684
|
+
relPath: MERGE_WORKFLOW_PATH,
|
|
685
|
+
render: () => renderEpicImplementerMergeWorkflow(),
|
|
686
|
+
progressLabel: "workflow",
|
|
687
|
+
classify: (existing) => {
|
|
688
|
+
const classification = classifyMergeWorkflowContent(existing);
|
|
689
|
+
const text = MERGE_WORKFLOW_CLASSIFICATION_TEXT[classification];
|
|
690
|
+
if (classification === "modified_or_unrecognized") {
|
|
691
|
+
return { state: "conflict", reason: "workflow_modified_or_unrecognized", text };
|
|
692
|
+
}
|
|
693
|
+
return { state: classification, text };
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
companionArtifact(MERGE_WORKFLOW_CODEOWNERS_RELPATH, () => renderMergeWorkflowCodeownersFragment(owner)),
|
|
697
|
+
companionArtifact(MERGE_WORKFLOW_GUIDANCE_RELPATH, () => renderMergeWorkflowGuidance(owner)),
|
|
698
|
+
],
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Run the complete read-only merge-workflow preflight. Compatibility entry point
|
|
703
|
+
* around {@link runArtifactSetPreflight}.
|
|
704
|
+
*/
|
|
705
|
+
export async function runMergeWorkflowPreflight(deps, options) {
|
|
706
|
+
const pre = await runArtifactSetPreflight(buildMergeWorkflowArtifactSet(options.codeOwner), deps);
|
|
707
|
+
if (!pre.ok)
|
|
708
|
+
return pre;
|
|
709
|
+
return {
|
|
710
|
+
ok: true,
|
|
711
|
+
preflight: { ...pre.preflight, codeOwner: validateMergeWorkflowCodeOwner(options.codeOwner) },
|
|
712
|
+
};
|
|
713
|
+
}
|
|
482
714
|
// ---------------------------------------------------------------------------
|
|
483
715
|
// Orchestration
|
|
484
716
|
// ---------------------------------------------------------------------------
|
|
717
|
+
/** How many times consent is asked before a still-moving default branch stops the run. */
|
|
718
|
+
const MAX_CONSENT_ROUNDS = 3;
|
|
719
|
+
function defaultConsentPrompt(plan) {
|
|
720
|
+
return (`Create branch ${plan.branch} from ${plan.defaultBranch} in a temporary worktree, write ` +
|
|
721
|
+
`${plan.artifacts.join(", ")}, commit "${plan.commitSubject}", push to ` +
|
|
722
|
+
`${plan.remoteName}/${plan.branch}, and open a pull request into ${plan.defaultBranch}? [y/N] `);
|
|
723
|
+
}
|
|
485
724
|
async function consent(deps, promptText) {
|
|
486
725
|
if (!deps.confirmWrite)
|
|
487
726
|
return false;
|
|
488
727
|
return await deps.confirmWrite(promptText);
|
|
489
728
|
}
|
|
729
|
+
function samePlan(a, b) {
|
|
730
|
+
if (a.classification !== b.classification)
|
|
731
|
+
return false;
|
|
732
|
+
if (a.proposals.size !== b.proposals.size)
|
|
733
|
+
return false;
|
|
734
|
+
for (const [path, content] of a.proposals)
|
|
735
|
+
if (b.proposals.get(path) !== content)
|
|
736
|
+
return false;
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
/** Refuse to write through a symlink anywhere on the path inside the worktree. */
|
|
740
|
+
async function pathIsSafeInWorktree(deps, worktree, relPath) {
|
|
741
|
+
const target = resolve(worktree, relPath);
|
|
742
|
+
if (!target.startsWith(resolve(worktree) + "/"))
|
|
743
|
+
return false;
|
|
744
|
+
const lstat = deps.lstat ?? fsLstat;
|
|
745
|
+
const segments = relPath.split("/");
|
|
746
|
+
let current = resolve(worktree);
|
|
747
|
+
for (const segment of segments) {
|
|
748
|
+
current = join(current, segment);
|
|
749
|
+
try {
|
|
750
|
+
if ((await lstat(current)).isSymbolicLink())
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
catch (err) {
|
|
754
|
+
if (isEnoent(err))
|
|
755
|
+
return true;
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
490
761
|
/**
|
|
491
|
-
*
|
|
762
|
+
* Install one artifact set: read-only preflight, consent, then all writes in a
|
|
763
|
+
* temporary worktree, push to the managed branch, and open the pull request.
|
|
492
764
|
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
* Nothing already created is undone on a later failure. A branch that exists, a
|
|
499
|
-
* file that was written, or a ref that was pushed is left in place with a
|
|
500
|
-
* bounded recovery instruction: deleting a customer's branch to tidy up after
|
|
501
|
-
* ourselves is a destructive act taken on incomplete information.
|
|
765
|
+
* Progress lines go to `deps.log`; the caller reports the returned outcome and
|
|
766
|
+
* prints any `warnings` (cleanup problems) after it, on stderr.
|
|
767
|
+
* Nothing already pushed is undone on a later failure: deleting a customer's
|
|
768
|
+
* branch to tidy up is a destructive act taken on incomplete information.
|
|
502
769
|
*/
|
|
503
|
-
export async function
|
|
504
|
-
const
|
|
505
|
-
if (result.outcome === "failed") {
|
|
506
|
-
deps.errorLog(`${result.reason}`);
|
|
507
|
-
deps.errorLog(result.remediation);
|
|
508
|
-
return 1;
|
|
509
|
-
}
|
|
510
|
-
return 0;
|
|
511
|
-
};
|
|
512
|
-
deps.log("Bridge merge-workflow installer");
|
|
513
|
-
deps.log(" preflight:");
|
|
514
|
-
const pre = await runMergeWorkflowPreflight({ ...deps, cwd }, options);
|
|
770
|
+
export async function installArtifactSet(descriptor, options, deps) {
|
|
771
|
+
const pre = await runArtifactSetPreflight(descriptor, deps);
|
|
515
772
|
if (!pre.ok)
|
|
516
|
-
return
|
|
517
|
-
|
|
773
|
+
return pre.result;
|
|
774
|
+
let preflight = pre.preflight;
|
|
518
775
|
if (preflight.classification === "identical") {
|
|
519
|
-
|
|
520
|
-
deps.log("Clean no-op: every managed artifact already matches this build.");
|
|
521
|
-
deps.log(` ${MERGE_WORKFLOW_PATH} (contract version ${MERGE_WORKFLOW_EXPECTED_VERSION})`);
|
|
522
|
-
deps.log("Nothing was written and no pull request was opened.");
|
|
523
|
-
return 0;
|
|
776
|
+
return { outcome: "identical", artifacts: [...preflight.proposals.keys()], warnings: [] };
|
|
524
777
|
}
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
778
|
+
const managedPaths = descriptor.artifacts.map((a) => a.relPath);
|
|
779
|
+
let upgrade = false;
|
|
780
|
+
let branch = "";
|
|
781
|
+
let commitSubject = "";
|
|
782
|
+
// Consent covers one plan at one default-branch head. When the default branch
|
|
783
|
+
// moves after consent and the plan changes, the new plan is shown and consent
|
|
784
|
+
// is asked again; a branch that keeps moving stops with default_branch_moved.
|
|
785
|
+
for (let round = 0;; round += 1) {
|
|
786
|
+
upgrade = preflight.classification === "managed_version_older";
|
|
787
|
+
branch = upgrade ? descriptor.upgradeBranch : descriptor.installBranch;
|
|
788
|
+
commitSubject = upgrade ? descriptor.upgradeCommitSubject : descriptor.installCommitSubject;
|
|
789
|
+
const managed = await inspectManagedState(deps, descriptor, preflight, branch, upgrade);
|
|
790
|
+
if (managed)
|
|
791
|
+
return managed;
|
|
792
|
+
const plan = {
|
|
793
|
+
branch,
|
|
794
|
+
upgrade,
|
|
795
|
+
artifacts: managedPaths,
|
|
796
|
+
commitSubject,
|
|
797
|
+
remoteName: preflight.remoteName,
|
|
798
|
+
defaultBranch: preflight.defaultBranch,
|
|
799
|
+
};
|
|
534
800
|
deps.log("");
|
|
535
|
-
|
|
801
|
+
if (round > 0)
|
|
802
|
+
deps.log(`${preflight.defaultBranch} moved since you approved, and the plan changed.`);
|
|
803
|
+
deps.log(upgrade ? "Planned upgrade:" : "Planned installation:");
|
|
536
804
|
deps.log(` branch: ${branch}`);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
805
|
+
for (const relPath of managedPaths)
|
|
806
|
+
deps.log(` file: ${relPath}`);
|
|
807
|
+
deps.log(` commit: ${commitSubject}`);
|
|
808
|
+
deps.log(` push: ${preflight.remoteName}/${branch} (your git identity)`);
|
|
809
|
+
deps.log(` pull request: into ${preflight.defaultBranch} (your gh identity)`);
|
|
810
|
+
if (options.dryRun) {
|
|
811
|
+
deps.log("");
|
|
812
|
+
deps.log("--dry-run: nothing was written, committed, pushed, or opened.");
|
|
813
|
+
return { outcome: "dry-run", branch, artifacts: managedPaths, upgrade, warnings: [] };
|
|
814
|
+
}
|
|
815
|
+
const prompt = (descriptor.consentPrompt ?? defaultConsentPrompt)(plan);
|
|
816
|
+
if (!(await consent(deps, prompt)))
|
|
817
|
+
return fail("consent_declined", { branch });
|
|
818
|
+
// --- first mutation: fetch the default branch into the remote-tracking ref --
|
|
819
|
+
const trackingRef = `refs/remotes/${preflight.remoteName}/${preflight.defaultBranch}`;
|
|
820
|
+
if (!(await commandSucceeds(deps.runGit, [
|
|
821
|
+
"fetch",
|
|
822
|
+
"--no-tags",
|
|
823
|
+
preflight.remoteName,
|
|
824
|
+
`+refs/heads/${preflight.defaultBranch}:${trackingRef}`,
|
|
825
|
+
]))) {
|
|
826
|
+
return fail("git_failed", { branch });
|
|
827
|
+
}
|
|
828
|
+
const fetchedSha = await captureStdout(deps.runGit, ["rev-parse", "--verify", "--quiet", `${trackingRef}^{commit}`]);
|
|
829
|
+
if (!fetchedSha)
|
|
830
|
+
return fail("git_failed", { branch });
|
|
831
|
+
if (fetchedSha === preflight.defaultBranchSha)
|
|
832
|
+
break;
|
|
833
|
+
// The default branch moved: re-inspect at the fetched head. An unchanged
|
|
834
|
+
// plan proceeds from the new head under the consent already given.
|
|
835
|
+
const again = await classifyAtRemote(deps, descriptor, preflight.repository, fetchedSha, true);
|
|
836
|
+
if (!again.ok && again.result.reason === "remote_state_unverifiable")
|
|
837
|
+
return fail("remote_state_unverifiable", { branch });
|
|
838
|
+
if (!again.ok)
|
|
839
|
+
return fail("default_branch_moved", { branch });
|
|
840
|
+
const unchanged = samePlan(again, preflight);
|
|
841
|
+
preflight = { ...preflight, defaultBranchSha: fetchedSha, classification: again.classification, proposals: again.proposals };
|
|
842
|
+
if (unchanged)
|
|
843
|
+
break;
|
|
844
|
+
if (again.classification === "identical") {
|
|
845
|
+
return { outcome: "identical", artifacts: [...again.proposals.keys()], warnings: [] };
|
|
846
|
+
}
|
|
847
|
+
if (round + 1 >= MAX_CONSENT_ROUNDS)
|
|
848
|
+
return fail("default_branch_moved", { branch });
|
|
540
849
|
}
|
|
541
|
-
deps.
|
|
542
|
-
deps.
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
deps.log(` commit: ${upgrade ? MERGE_WORKFLOW_UPGRADE_COMMIT_SUBJECT : MERGE_WORKFLOW_INSTALL_COMMIT_SUBJECT}`);
|
|
547
|
-
deps.log(` push: ${preflight.remoteName}/${branch} (your git identity)`);
|
|
548
|
-
deps.log(` pull request: into ${preflight.defaultBranch} (your gh identity)`);
|
|
549
|
-
if (options.dryRun) {
|
|
550
|
-
deps.log("");
|
|
551
|
-
deps.log("--dry-run: nothing was written, committed, pushed, or opened.");
|
|
552
|
-
return 0;
|
|
850
|
+
const makeTempDir = deps.makeTempDir ?? (() => mkdtemp(join(tmpdir(), `bridge-${descriptor.setName}-`)));
|
|
851
|
+
const removeDir = deps.removeDir ?? ((dir) => rm(dir, { recursive: true, force: true }));
|
|
852
|
+
let tempRoot;
|
|
853
|
+
try {
|
|
854
|
+
tempRoot = await makeTempDir();
|
|
553
855
|
}
|
|
554
|
-
|
|
555
|
-
return
|
|
856
|
+
catch {
|
|
857
|
+
return fail("worktree_failed", { branch });
|
|
556
858
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
859
|
+
const worktree = join(tempRoot, "worktree");
|
|
860
|
+
const warnings = [];
|
|
861
|
+
let worktreeAdded = false;
|
|
862
|
+
const run = async () => {
|
|
863
|
+
if (!(await commandSucceeds(deps.runGit, [
|
|
864
|
+
"worktree",
|
|
865
|
+
"add",
|
|
866
|
+
"--detach",
|
|
867
|
+
worktree,
|
|
868
|
+
preflight.defaultBranchSha,
|
|
869
|
+
]))) {
|
|
870
|
+
return fail("worktree_failed", { branch });
|
|
871
|
+
}
|
|
872
|
+
worktreeAdded = true;
|
|
873
|
+
for (const artifact of descriptor.artifacts) {
|
|
874
|
+
const content = preflight.proposals.get(artifact.relPath);
|
|
875
|
+
if (!(await pathIsSafeInWorktree(deps, worktree, artifact.relPath)))
|
|
876
|
+
return fail("write_failed", { branch });
|
|
877
|
+
const absPath = joinCwd(worktree, artifact.relPath);
|
|
878
|
+
const parent = absPath.slice(0, absPath.lastIndexOf("/"));
|
|
879
|
+
try {
|
|
880
|
+
// Read-compare-write: an identical target is never rewritten.
|
|
881
|
+
let existing;
|
|
882
|
+
try {
|
|
883
|
+
existing = await deps.readFile(absPath);
|
|
884
|
+
}
|
|
885
|
+
catch (err) {
|
|
886
|
+
if (!isEnoent(err))
|
|
887
|
+
throw err;
|
|
888
|
+
existing = null;
|
|
889
|
+
}
|
|
890
|
+
if (existing !== content) {
|
|
891
|
+
await deps.mkdir(parent, { recursive: true });
|
|
892
|
+
await deps.writeFile(absPath, content, { encoding: "utf-8" });
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
return fail("write_failed", { branch });
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
// The manifest lands in the temporary worktree and is never staged.
|
|
900
|
+
await deps.recordArtifact(worktree, artifact.relPath);
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
// Recording is best effort and never blocks the install.
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
// Only the explicit managed paths are staged, after `--`.
|
|
907
|
+
if (!(await commandSucceeds(deps.runGit, ["-C", worktree, "add", "--", ...managedPaths]))) {
|
|
908
|
+
return fail("git_failed", { branch });
|
|
909
|
+
}
|
|
910
|
+
if (!(await commandSucceeds(deps.runGit, ["-C", worktree, "commit", "-m", commitSubject]))) {
|
|
911
|
+
return fail("git_failed", { branch });
|
|
912
|
+
}
|
|
913
|
+
// Immediately before push: nobody else created the managed branch or PR.
|
|
914
|
+
const raced = await inspectManagedState(deps, descriptor, preflight, branch, upgrade);
|
|
915
|
+
if (raced) {
|
|
916
|
+
if (raced.outcome === "failed" && raced.reason === "remote_state_unverifiable")
|
|
917
|
+
return raced;
|
|
918
|
+
return fail("managed_state_changed", { branch });
|
|
919
|
+
}
|
|
920
|
+
if (branch === preflight.defaultBranch)
|
|
921
|
+
return fail("git_failed", { branch });
|
|
922
|
+
if (!(await commandSucceeds(deps.runGit, [
|
|
923
|
+
"-C",
|
|
924
|
+
worktree,
|
|
925
|
+
"push",
|
|
926
|
+
preflight.remoteName,
|
|
927
|
+
`HEAD:refs/heads/${branch}`,
|
|
928
|
+
]))) {
|
|
929
|
+
return fail("git_failed", { branch });
|
|
930
|
+
}
|
|
931
|
+
const prUrl = await captureStdout(deps.runGh, [
|
|
932
|
+
"pr",
|
|
933
|
+
"create",
|
|
934
|
+
"--repo",
|
|
935
|
+
preflight.repository,
|
|
936
|
+
"--base",
|
|
937
|
+
preflight.defaultBranch,
|
|
938
|
+
"--head",
|
|
939
|
+
branch,
|
|
940
|
+
"--title",
|
|
941
|
+
upgrade ? descriptor.upgradePrTitle : descriptor.installPrTitle,
|
|
942
|
+
"--body",
|
|
943
|
+
descriptor.renderPrBody(upgrade),
|
|
944
|
+
]);
|
|
945
|
+
if (!prUrl) {
|
|
946
|
+
return fail("pr_open_failed", {
|
|
947
|
+
branch,
|
|
948
|
+
compareUrl: compareUrl(preflight.repository, preflight.defaultBranch, branch),
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
return {
|
|
952
|
+
outcome: upgrade ? "upgrade-pr-opened" : "installed-pr-opened",
|
|
953
|
+
branch,
|
|
954
|
+
prUrl,
|
|
955
|
+
artifacts: managedPaths,
|
|
956
|
+
warnings: [],
|
|
957
|
+
};
|
|
958
|
+
};
|
|
959
|
+
let result;
|
|
960
|
+
try {
|
|
961
|
+
result = await run();
|
|
560
962
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
[
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const written = [];
|
|
567
|
-
for (const [relPath, content] of artifacts) {
|
|
568
|
-
const absPath = joinCwd(preflight.repositoryRoot, relPath);
|
|
569
|
-
const parent = absPath.slice(0, absPath.lastIndexOf("/"));
|
|
963
|
+
finally {
|
|
964
|
+
let cleaned = true;
|
|
965
|
+
if (worktreeAdded && !(await commandSucceeds(deps.runGit, ["worktree", "remove", "--force", worktree]))) {
|
|
966
|
+
cleaned = false;
|
|
967
|
+
}
|
|
570
968
|
try {
|
|
571
|
-
|
|
572
|
-
// re-run does not churn file mtimes or produce an empty diff entry.
|
|
573
|
-
const existingContent = await readOrNull(deps, absPath);
|
|
574
|
-
if (existingContent !== content) {
|
|
575
|
-
await deps.mkdir(parent, { recursive: true });
|
|
576
|
-
await deps.writeFile(absPath, content, { encoding: "utf-8" });
|
|
577
|
-
}
|
|
578
|
-
written.push(relPath);
|
|
969
|
+
await removeDir(tempRoot);
|
|
579
970
|
}
|
|
580
971
|
catch {
|
|
581
|
-
|
|
972
|
+
cleaned = false;
|
|
973
|
+
}
|
|
974
|
+
if (!cleaned) {
|
|
975
|
+
const warning = `cleanup_incomplete: the temporary worktree at ${worktree} was not fully removed. ` +
|
|
976
|
+
`Remove it with \`git worktree remove --force ${worktree}\` and delete ${tempRoot}.`;
|
|
977
|
+
// Returned, not printed: the caller prints it AFTER its own outcome, so a
|
|
978
|
+
// failure's stable reason token stays the first stderr line.
|
|
979
|
+
warnings.push(warning);
|
|
582
980
|
}
|
|
583
|
-
// Recorded per artifact, with the POSIX repository-relative path only.
|
|
584
|
-
await deps.recordArtifact(preflight.repositoryRoot, relPath);
|
|
585
|
-
}
|
|
586
|
-
// Only the explicit managed paths are staged, passed after `--` so a path can
|
|
587
|
-
// never be read as a git option.
|
|
588
|
-
if (!(await commandSucceeds(deps.runGit, ["add", "--", ...MERGE_WORKFLOW_MANAGED_RELPATHS]))) {
|
|
589
|
-
return report(fail("git_failed"));
|
|
590
|
-
}
|
|
591
|
-
const commitSubject = upgrade
|
|
592
|
-
? MERGE_WORKFLOW_UPGRADE_COMMIT_SUBJECT
|
|
593
|
-
: MERGE_WORKFLOW_INSTALL_COMMIT_SUBJECT;
|
|
594
|
-
if (!(await commandSucceeds(deps.runGit, ["commit", "-m", commitSubject]))) {
|
|
595
|
-
return report(fail("git_failed"));
|
|
596
|
-
}
|
|
597
|
-
if (!(await commandSucceeds(deps.runGit, [
|
|
598
|
-
"push",
|
|
599
|
-
"--set-upstream",
|
|
600
|
-
preflight.remoteName,
|
|
601
|
-
branch,
|
|
602
|
-
]))) {
|
|
603
|
-
return report(fail("git_failed"));
|
|
604
981
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
982
|
+
return { ...result, warnings: [...result.warnings, ...warnings] };
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Generate the merge-workflow artifacts and open the customer's review PR.
|
|
986
|
+
*
|
|
987
|
+
* Compatibility wrapper for `install-bridge conductor --merge-workflow` around
|
|
988
|
+
* {@link installArtifactSet}. Returns a process exit code: `0` for every
|
|
989
|
+
* success shape (new pull request, upgrade pull request, verified existing pull
|
|
990
|
+
* request, byte-identical no-op, dry run) and `1` for every failure. A failure
|
|
991
|
+
* prints the stable reason token first and one remediation sentence second,
|
|
992
|
+
* both on stderr.
|
|
993
|
+
*/
|
|
994
|
+
export async function runMergeWorkflowInstall(cwd, options, deps) {
|
|
995
|
+
deps.log("Bridge merge-workflow installer");
|
|
996
|
+
deps.log(" preflight:");
|
|
997
|
+
const descriptor = buildMergeWorkflowArtifactSet(options.codeOwner);
|
|
998
|
+
const result = await installArtifactSet(descriptor, { dryRun: options.dryRun }, { ...deps, cwd });
|
|
999
|
+
const exitCode = reportMergeWorkflowResult(result, deps);
|
|
1000
|
+
for (const warning of result.warnings)
|
|
1001
|
+
deps.errorLog(warning);
|
|
1002
|
+
return exitCode;
|
|
1003
|
+
}
|
|
1004
|
+
function reportMergeWorkflowResult(result, deps) {
|
|
1005
|
+
switch (result.outcome) {
|
|
1006
|
+
case "failed":
|
|
1007
|
+
deps.errorLog(`${result.reason}`);
|
|
1008
|
+
if (result.reason === "pr_open_failed" && result.branch) {
|
|
1009
|
+
deps.errorLog(`${result.remediation} The branch ${result.branch} is pushed to origin and was left in place.`);
|
|
1010
|
+
}
|
|
1011
|
+
else if (result.reason === "managed_branch_without_pr" && result.branch) {
|
|
1012
|
+
deps.errorLog(`${result.remediation} Managed branch: ${result.branch}.`);
|
|
1013
|
+
if (result.compareUrl)
|
|
1014
|
+
deps.errorLog(`Open a pull request from it: ${result.compareUrl}`);
|
|
1015
|
+
}
|
|
1016
|
+
else if (result.reason === "existing_pr_differs" && result.prUrl) {
|
|
1017
|
+
deps.errorLog(`${result.remediation} Pull request: ${result.prUrl}`);
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
deps.errorLog(result.remediation);
|
|
1021
|
+
}
|
|
1022
|
+
return 1;
|
|
1023
|
+
case "identical":
|
|
1024
|
+
deps.log("");
|
|
1025
|
+
deps.log("Clean no-op: every managed artifact already matches this build.");
|
|
1026
|
+
deps.log(` ${MERGE_WORKFLOW_PATH} (contract version ${MERGE_WORKFLOW_EXPECTED_VERSION})`);
|
|
1027
|
+
deps.log("Nothing was written and no pull request was opened.");
|
|
1028
|
+
return 0;
|
|
1029
|
+
case "existing-pr":
|
|
1030
|
+
deps.log("");
|
|
1031
|
+
deps.log(`${result.upgrade ? "Upgrade" : "Installation"} pull request is already open.`);
|
|
1032
|
+
deps.log(` branch: ${result.branch}`);
|
|
1033
|
+
deps.log(` pull request: ${result.prUrl}`);
|
|
1034
|
+
deps.log("Review and merge it; re-running this command opens nothing new.");
|
|
1035
|
+
return 0;
|
|
1036
|
+
case "dry-run":
|
|
1037
|
+
return 0;
|
|
1038
|
+
case "installed-pr-opened":
|
|
1039
|
+
case "upgrade-pr-opened":
|
|
1040
|
+
deps.log("");
|
|
1041
|
+
deps.log(result.outcome === "upgrade-pr-opened" ? "Upgrade pull request opened." : "Installation pull request opened.");
|
|
1042
|
+
deps.log(` branch: ${result.branch}`);
|
|
1043
|
+
for (const relPath of result.artifacts)
|
|
1044
|
+
deps.log(` file: ${relPath}`);
|
|
1045
|
+
deps.log(` pull request: ${result.prUrl}`);
|
|
1046
|
+
deps.log("");
|
|
1047
|
+
deps.log("Next, in this order:");
|
|
1048
|
+
deps.log(" 1. Review the pull request — the workflow is what performs the privileged merge.");
|
|
1049
|
+
deps.log(` 2. Create the repository Actions secret ${MERGE_WORKFLOW_API_KEY_SECRET_NAME} through GitHub. Never pass its value to this command.`);
|
|
1050
|
+
deps.log(` 3. Merge the rule in ${MERGE_WORKFLOW_CODEOWNERS_RELPATH} into your active CODEOWNERS file.`);
|
|
1051
|
+
deps.log(" 4. Add a default-branch rule requiring a pull request and code-owner review.");
|
|
1052
|
+
deps.log(" 5. Merge the pull request yourself; Bridge never merges it.");
|
|
1053
|
+
return 0;
|
|
622
1054
|
}
|
|
623
|
-
deps.log("");
|
|
624
|
-
deps.log(upgrade ? "Upgrade pull request opened." : "Installation pull request opened.");
|
|
625
|
-
deps.log(` branch: ${branch}`);
|
|
626
|
-
for (const relPath of written)
|
|
627
|
-
deps.log(` file: ${relPath}`);
|
|
628
|
-
deps.log(` pull request: ${prUrl}`);
|
|
629
|
-
deps.log("");
|
|
630
|
-
deps.log("Next, in this order:");
|
|
631
|
-
deps.log(" 1. Review the pull request — the workflow is what performs the privileged merge.");
|
|
632
|
-
deps.log(` 2. Create the repository Actions secret ${MERGE_WORKFLOW_API_KEY_SECRET_NAME} through GitHub. Never pass its value to this command.`);
|
|
633
|
-
deps.log(` 3. Merge the rule in ${MERGE_WORKFLOW_CODEOWNERS_RELPATH} into your active CODEOWNERS file.`);
|
|
634
|
-
deps.log(" 4. Add a default-branch rule requiring a pull request and code-owner review.");
|
|
635
|
-
deps.log(" 5. Merge the pull request yourself; Bridge never merges it.");
|
|
636
|
-
return 0;
|
|
637
1055
|
}
|