@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
|
@@ -78,7 +78,7 @@ import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, REVIEW_STATE, normalizeChe
|
|
|
78
78
|
import { MERGE_REVIEW_WAIVED_BY_DEGRADATION_REASON, VERDICTLESS_DISPOSITION_FAIL_OPEN, VERDICTLESS_DISPOSITION_PARK, } from "./conductor/git-ci-types.js";
|
|
79
79
|
import { evaluateReviewCondition, normalizeReviewSnapshot, parseDoneGateConfig, } from "./conductor/done-gate.js";
|
|
80
80
|
import { buildGateIdentity, makeMergeActionKey } from "./conductor/merge-identity.js";
|
|
81
|
-
import { resolveLocalMergeMethod, runApprovedLocalMerge, } from "./conductor/local-merge.js";
|
|
81
|
+
import { resolveLocalMergeMethod, runApprovedLocalMerge, resolveLocalMergeCredential, LOCAL_MERGE_CREDENTIAL_ENV_KEYS, LOCAL_MERGE_CREDENTIAL_MISSING_REASONS, } from "./conductor/local-merge.js";
|
|
82
82
|
/**
|
|
83
83
|
* The exact remediation for a `dry_run`. Fixed text, because a caller that has to
|
|
84
84
|
* infer WHICH setting to flip from a reason code guesses wrong.
|
|
@@ -629,22 +629,86 @@ function interpretMergeResponse(body, expectedHeadSha, prNumber) {
|
|
|
629
629
|
// ---------------------------------------------------------------------------
|
|
630
630
|
/** The non-terminal status the server returns when it approves a local merge. */
|
|
631
631
|
const LOCAL_APPROVAL_STATUS = "approved_for_local_execution";
|
|
632
|
-
const DEFAULT_MERGE_EXECUTION = "local";
|
|
633
|
-
/**
|
|
632
|
+
export const DEFAULT_MERGE_EXECUTION = "local";
|
|
633
|
+
/**
|
|
634
|
+
* The ONE place the legacy repository vocabulary is reconciled onto the typed
|
|
635
|
+
* policy vocabulary (BAPI-1133, A2). Exhaustive and pure.
|
|
636
|
+
*
|
|
637
|
+
* `local -> local` is the identity case. `server -> machine_token`, NOT
|
|
638
|
+
* `github_actions`: legacy `server` means the existing direct credentialed
|
|
639
|
+
* provider-API write through the App-mediated merge path
|
|
640
|
+
* (`executeApprovedLocalMerge`'s sibling branch on the server), so its typed
|
|
641
|
+
* equivalent is the direct-credential declaration. `github_actions` represents
|
|
642
|
+
* workflow-DISPATCH intent, which the legacy `server` behavior never performed
|
|
643
|
+
* and so can never be inferred from it. `server` is therefore structurally
|
|
644
|
+
* impossible as a {@link TypedMergeExecutionMode} — this function is the only
|
|
645
|
+
* place that mapping exists, so no other consumer can reimplement it
|
|
646
|
+
* differently.
|
|
647
|
+
*/
|
|
648
|
+
export function mapLegacyMergeExecutionModeToPolicyMode(mode) {
|
|
649
|
+
switch (mode) {
|
|
650
|
+
case "local":
|
|
651
|
+
return "local";
|
|
652
|
+
case "server":
|
|
653
|
+
return "machine_token";
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Which configured credential a resolved legacy mode reads (BAPI-1136).
|
|
658
|
+
*
|
|
659
|
+
* Routed through {@link mapLegacyMergeExecutionModeToPolicyMode} rather than
|
|
660
|
+
* switching on the legacy value again, so there remains exactly ONE place where
|
|
661
|
+
* legacy `server` becomes typed `machine_token`. Typed `github_actions` never
|
|
662
|
+
* reaches this local path — that mode dispatches the customer's workflow and
|
|
663
|
+
* performs no local subprocess — so it falls back to the local key rather than
|
|
664
|
+
* inventing a third credential.
|
|
665
|
+
*/
|
|
666
|
+
export function resolveLocalCredentialMode(mode) {
|
|
667
|
+
const typed = mapLegacyMergeExecutionModeToPolicyMode(mode);
|
|
668
|
+
return typed === "machine_token" ? "machine_token" : "local";
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Operator-fixable hints for every preflight and credential refusal.
|
|
672
|
+
*
|
|
673
|
+
* BAPI-1136 removed the `gh auth login` remediation outright. A merge no longer
|
|
674
|
+
* authenticates as whoever is logged into `gh`, so telling an operator to log in
|
|
675
|
+
* would send them to fix a thing that is not consulted — the most expensive kind
|
|
676
|
+
* of wrong hint, because following it appears reasonable and changes nothing.
|
|
677
|
+
*
|
|
678
|
+
* Each hint names the CONFIGURATION KEY to set, never where or how a secret
|
|
679
|
+
* manager should materialize the value: that is the operator's own infrastructure
|
|
680
|
+
* decision, and prescribing one would be guidance this tool cannot verify.
|
|
681
|
+
*/
|
|
634
682
|
const LOCAL_GH_HINTS = {
|
|
635
683
|
local_gh_unavailable: "Install the GitHub CLI (`gh`) on the machine running this MCP server — local merges execute there.",
|
|
636
|
-
|
|
684
|
+
local_merge_credential_missing: `Set ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.local} in the environment this MCP server is launched with. ` +
|
|
685
|
+
"It is a configured local scoped credential; Bridge never stores it and there is no ambient-login fallback.",
|
|
686
|
+
machine_merge_credential_missing: `Set ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.machine_token} in the environment this MCP server is launched with. ` +
|
|
687
|
+
"It is your machine user's credential; Bridge never stores it and there is no ambient-login fallback.",
|
|
688
|
+
local_merge_credential_invalid: `The configured ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.local} was rejected by GitHub. Rotate it and relaunch this MCP server.`,
|
|
689
|
+
machine_merge_credential_invalid: `The configured ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.machine_token} was rejected by GitHub. Rotate it and relaunch this MCP server.`,
|
|
690
|
+
local_merge_credential_unpermitted: `The configured ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.local} authenticates but cannot write to this repository. Grant it merge permission.`,
|
|
691
|
+
machine_merge_credential_unpermitted: `The configured ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.machine_token} authenticates but cannot write to this repository. Grant the machine user merge permission.`,
|
|
637
692
|
};
|
|
638
693
|
/**
|
|
639
|
-
* Read the repository's merge execution mode.
|
|
694
|
+
* Read the repository's LEGACY merge execution mode.
|
|
640
695
|
*
|
|
641
696
|
* Mode is server-side configuration, never a tool argument: an agent must not be
|
|
642
697
|
* able to choose who performs a privileged write. An unreadable or unrecognized
|
|
643
698
|
* value falls back to `local`, which is the mode that works on an installation
|
|
644
699
|
* without `contents: write` — defaulting to the broken mode on a transient
|
|
645
|
-
* config read failure would park a perfectly mergeable pull request.
|
|
700
|
+
* config read failure would park a perfectly mergeable pull request. Total: every
|
|
701
|
+
* failure mode (network error, non-2xx, malformed body, missing/unknown
|
|
702
|
+
* `merge_execution`) resolves to {@link DEFAULT_MERGE_EXECUTION} rather than
|
|
703
|
+
* throwing.
|
|
704
|
+
*
|
|
705
|
+
* Returns only the legacy vocabulary — `local | server` — never a typed mode.
|
|
706
|
+
* `setup-epic.ts` maps this result through
|
|
707
|
+
* {@link mapLegacyMergeExecutionModeToPolicyMode} before composing it into a
|
|
708
|
+
* `RunPolicy` declaration; `mergePullRequestHandler` sends it straight to the
|
|
709
|
+
* existing merge route unchanged.
|
|
646
710
|
*/
|
|
647
|
-
async function resolveMergeExecutionMode(deps) {
|
|
711
|
+
export async function resolveMergeExecutionMode(deps) {
|
|
648
712
|
try {
|
|
649
713
|
const resp = await (deps.fetchImpl ?? fetch)(deps.buildGetUrl("/epic-runs/supervisor-config/defaults/", { repo_name: deps.repoName }), { headers: await deps.getHeaders() });
|
|
650
714
|
if (!resp.ok)
|
|
@@ -718,7 +782,7 @@ async function reportLocalCompletion(deps, prNumber, body) {
|
|
|
718
782
|
* left to time out; and a refusal here is always `needs_human`, because every
|
|
719
783
|
* local failure this can produce is an operator fix, not a wait.
|
|
720
784
|
*/
|
|
721
|
-
async function executeApprovedLocalMerge(deps, approval, prNumber, expectedHeadSha, actionKey) {
|
|
785
|
+
async function executeApprovedLocalMerge(deps, approval, prNumber, expectedHeadSha, actionKey, credentialMode) {
|
|
722
786
|
if (localApprovalMismatch(approval, prNumber, expectedHeadSha, actionKey)) {
|
|
723
787
|
return envelope(false, "refused", "local_approval_mismatch", "needs_human", expectedHeadSha, prNumber);
|
|
724
788
|
}
|
|
@@ -730,10 +794,34 @@ async function executeApprovedLocalMerge(deps, approval, prNumber, expectedHeadS
|
|
|
730
794
|
gate: { name: DEFAULT_GATE_NAME },
|
|
731
795
|
action_key: actionKey,
|
|
732
796
|
};
|
|
797
|
+
// BAPI-1136. The credential is resolved HERE, before the executor is called,
|
|
798
|
+
// so an unconfigured mode refuses without spawning a privileged subprocess —
|
|
799
|
+
// and so the refusal names which key to set rather than surfacing whatever
|
|
800
|
+
// `gh` says when it has no identity.
|
|
801
|
+
const credential = resolveLocalMergeCredential(deps.runtimeEnv ?? process.env, credentialMode);
|
|
802
|
+
if (credential === null) {
|
|
803
|
+
const reason = LOCAL_MERGE_CREDENTIAL_MISSING_REASONS[credentialMode];
|
|
804
|
+
// The lease is still closed: an attempt that cannot run is an attempt that
|
|
805
|
+
// is over, and leaving the ledger row open would make the reconciler wait
|
|
806
|
+
// out a lease for a merge nobody is going to perform.
|
|
807
|
+
await reportLocalCompletion(deps, prNumber, {
|
|
808
|
+
repo_name: deps.repoName,
|
|
809
|
+
action_key: actionKey,
|
|
810
|
+
expected_head_sha: expectedHeadSha,
|
|
811
|
+
result: "failed",
|
|
812
|
+
reason,
|
|
813
|
+
});
|
|
814
|
+
const missingHint = LOCAL_GH_HINTS[reason];
|
|
815
|
+
return envelope(false, "refused", reason, "needs_human", expectedHeadSha, prNumber, {
|
|
816
|
+
...(missingHint ? { hint: missingHint } : {}),
|
|
817
|
+
});
|
|
818
|
+
}
|
|
733
819
|
let local;
|
|
734
820
|
try {
|
|
735
821
|
const runLocal = deps.runLocalMerge ?? runApprovedLocalMerge;
|
|
736
|
-
|
|
822
|
+
// Exactly the resolved credential — never `{ env: process.env }`, which is
|
|
823
|
+
// how the ambient identity used to reach the subprocess.
|
|
824
|
+
local = await runLocal(request, { method }, { credential });
|
|
737
825
|
}
|
|
738
826
|
catch {
|
|
739
827
|
// The executor itself threw. Treat it exactly like a reported failure: close
|
|
@@ -891,7 +979,12 @@ export async function mergePullRequestHandler(deps, args) {
|
|
|
891
979
|
if (executionMode === "local" &&
|
|
892
980
|
isPlainObject(mergeJson) &&
|
|
893
981
|
mergeJson.status === LOCAL_APPROVAL_STATUS) {
|
|
894
|
-
return text(withReviewWaiver(await executeApprovedLocalMerge(deps, mergeJson, prNumber, expectedHeadSha, actionKey
|
|
982
|
+
return text(withReviewWaiver(await executeApprovedLocalMerge(deps, mergeJson, prNumber, expectedHeadSha, actionKey,
|
|
983
|
+
// The AUTHORITATIVE typed axis decides which credential is read.
|
|
984
|
+
// Legacy `server` maps to typed `machine_token` through the one
|
|
985
|
+
// existing mapping function; nothing here re-derives that, and no
|
|
986
|
+
// tool argument can influence it.
|
|
987
|
+
resolveLocalCredentialMode(executionMode)), reviewWaiver));
|
|
895
988
|
}
|
|
896
989
|
return text(withReviewWaiver(interpretMergeResponse(mergeJson, expectedHeadSha, prNumber), reviewWaiver));
|
|
897
990
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer-owned review artifacts for the merge workflow (BAPI-1136).
|
|
3
|
+
*
|
|
4
|
+
* The merge workflow is only as safe as the review gate on its path. This module
|
|
5
|
+
* renders the two artifacts that make that gate possible, and it renders NOTHING
|
|
6
|
+
* that enforces it — enforcement is a repository setting only the customer can
|
|
7
|
+
* apply.
|
|
8
|
+
*
|
|
9
|
+
* ## Why a fragment and not an edit to `.github/CODEOWNERS`
|
|
10
|
+
*
|
|
11
|
+
* A repository's CODEOWNERS file is live policy. An installer that rewrote it
|
|
12
|
+
* could reassign ownership of unrelated paths, drop a rule whose syntax it
|
|
13
|
+
* misparsed, or resolve a conflict nobody asked it to resolve — and it would do
|
|
14
|
+
* so inside a pull request whose reviewer is reading a workflow file, not a
|
|
15
|
+
* policy diff. So this emits a self-contained FRAGMENT at a dedicated path and
|
|
16
|
+
* tells the customer to merge it themselves. The fragment is real, reviewable
|
|
17
|
+
* CODEOWNERS syntax; it simply is not yet in force.
|
|
18
|
+
*
|
|
19
|
+
* ## Why the guidance is plain text
|
|
20
|
+
*
|
|
21
|
+
* It is read in a pull-request diff, in a terminal, and by a screen reader. So:
|
|
22
|
+
* descriptive headings, explicit "Required:" labels, short paragraphs, stable
|
|
23
|
+
* ordering, and no code block that needs horizontal scrolling.
|
|
24
|
+
*
|
|
25
|
+
* ## Secret discipline
|
|
26
|
+
*
|
|
27
|
+
* Neither renderer accepts, embeds, or invites a secret VALUE. The Bridge API
|
|
28
|
+
* key is named only — `BAPI_API_KEY` — and every command shown is one that
|
|
29
|
+
* cannot take a credential on argv.
|
|
30
|
+
*/
|
|
31
|
+
import { MERGE_WORKFLOW_EXPECTED_VERSION, MERGE_WORKFLOW_PATH, } from "./conductor/merge-execution-contract.js";
|
|
32
|
+
import { MERGE_WORKFLOW_API_KEY_SECRET_NAME, MERGE_WORKFLOW_BASE_URL_VARIABLE_NAME, } from "./merge-workflow-config.js";
|
|
33
|
+
/** Repo-relative path of the generated CODEOWNERS fragment. */
|
|
34
|
+
export const MERGE_WORKFLOW_CODEOWNERS_RELPATH = ".bridge/merge-workflow/CODEOWNERS.fragment";
|
|
35
|
+
/** Repo-relative path of the generated repository-rule guidance. */
|
|
36
|
+
export const MERGE_WORKFLOW_GUIDANCE_RELPATH = ".bridge/merge-workflow/REPOSITORY-RULES.md";
|
|
37
|
+
/**
|
|
38
|
+
* Every repo-relative path this installer may write, in the order a reader
|
|
39
|
+
* should see them. Exported as the single manifest so the preflight, the
|
|
40
|
+
* consent summary, the commit, and the install-state record cannot disagree
|
|
41
|
+
* about what "the managed artifacts" are.
|
|
42
|
+
*/
|
|
43
|
+
export const MERGE_WORKFLOW_MANAGED_RELPATHS = [
|
|
44
|
+
MERGE_WORKFLOW_PATH,
|
|
45
|
+
MERGE_WORKFLOW_CODEOWNERS_RELPATH,
|
|
46
|
+
MERGE_WORKFLOW_GUIDANCE_RELPATH,
|
|
47
|
+
];
|
|
48
|
+
/** Thrown when a code-owner value fails validation. Carries no rendered output. */
|
|
49
|
+
export class MergeWorkflowCodeOwnerError extends Error {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "MergeWorkflowCodeOwnerError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A GitHub code owner: `@user` or `@org/team`.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately rejects whitespace, quotes, `#` (which would comment out the rest
|
|
59
|
+
* of the CODEOWNERS line), `$`, `{`, `}`, and any second `/`. The pattern is an
|
|
60
|
+
* allowlist rather than a denylist so a character nobody thought about is
|
|
61
|
+
* rejected rather than rendered.
|
|
62
|
+
*/
|
|
63
|
+
const CODE_OWNER_PATTERN = /^@[A-Za-z0-9][A-Za-z0-9-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
|
64
|
+
const MAX_CODE_OWNER_LENGTH = 100;
|
|
65
|
+
/**
|
|
66
|
+
* Values that parse as a code owner but are obviously a placeholder someone
|
|
67
|
+
* pasted from documentation. Accepting one would open a pull request assigning
|
|
68
|
+
* review of a write-enabled workflow to an account that does not exist, which
|
|
69
|
+
* reads as protected while protecting nothing.
|
|
70
|
+
*/
|
|
71
|
+
const PLACEHOLDER_CODE_OWNERS = new Set([
|
|
72
|
+
"@org/team",
|
|
73
|
+
"@owner/team",
|
|
74
|
+
"@your-org/your-team",
|
|
75
|
+
"@user",
|
|
76
|
+
"@username",
|
|
77
|
+
"@your-username",
|
|
78
|
+
"@example",
|
|
79
|
+
"@example/team",
|
|
80
|
+
]);
|
|
81
|
+
/**
|
|
82
|
+
* Validate a single code owner.
|
|
83
|
+
*
|
|
84
|
+
* Runs BEFORE any filesystem or git mutation — an invalid owner must cost
|
|
85
|
+
* nothing, and must never leave a half-written branch behind.
|
|
86
|
+
*
|
|
87
|
+
* @throws MergeWorkflowCodeOwnerError naming the problem, never echoing a value
|
|
88
|
+
* longer than the bound above.
|
|
89
|
+
*/
|
|
90
|
+
export function validateMergeWorkflowCodeOwner(value) {
|
|
91
|
+
if (typeof value !== "string") {
|
|
92
|
+
throw new MergeWorkflowCodeOwnerError("--codeowner requires a value");
|
|
93
|
+
}
|
|
94
|
+
const trimmed = value.trim();
|
|
95
|
+
if (trimmed.length === 0) {
|
|
96
|
+
throw new MergeWorkflowCodeOwnerError("--codeowner requires a non-empty value");
|
|
97
|
+
}
|
|
98
|
+
if (trimmed.length > MAX_CODE_OWNER_LENGTH) {
|
|
99
|
+
throw new MergeWorkflowCodeOwnerError("--codeowner value is too long");
|
|
100
|
+
}
|
|
101
|
+
if (/\s/.test(trimmed)) {
|
|
102
|
+
throw new MergeWorkflowCodeOwnerError("--codeowner takes exactly one owner; supply a single @user or @org/team");
|
|
103
|
+
}
|
|
104
|
+
if (!trimmed.startsWith("@")) {
|
|
105
|
+
throw new MergeWorkflowCodeOwnerError("--codeowner must start with @ (e.g. @org/team or @user)");
|
|
106
|
+
}
|
|
107
|
+
if (!CODE_OWNER_PATTERN.test(trimmed)) {
|
|
108
|
+
throw new MergeWorkflowCodeOwnerError("--codeowner must be a GitHub @user or @org/team handle");
|
|
109
|
+
}
|
|
110
|
+
if (PLACEHOLDER_CODE_OWNERS.has(trimmed.toLowerCase())) {
|
|
111
|
+
throw new MergeWorkflowCodeOwnerError("--codeowner looks like a documentation placeholder; supply the real reviewing team or user");
|
|
112
|
+
}
|
|
113
|
+
return trimmed;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Render the CODEOWNERS fragment for the merge-workflow path.
|
|
117
|
+
*
|
|
118
|
+
* Deterministic for a given owner, newline-terminated, and covering exactly one
|
|
119
|
+
* path. It claims no other file.
|
|
120
|
+
*/
|
|
121
|
+
export function renderMergeWorkflowCodeownersFragment(codeOwner) {
|
|
122
|
+
const owner = validateMergeWorkflowCodeOwner(codeOwner);
|
|
123
|
+
return [
|
|
124
|
+
"# Bridge-managed CODEOWNERS fragment for the Epic Implementer merge workflow.",
|
|
125
|
+
"#",
|
|
126
|
+
"# This file is NOT a CODEOWNERS file. GitHub reads CODEOWNERS only from",
|
|
127
|
+
"# .github/CODEOWNERS, CODEOWNERS, or docs/CODEOWNERS, so nothing here is in",
|
|
128
|
+
"# force until you copy the rule below into the file your repository actually",
|
|
129
|
+
"# uses. It is generated separately so an installer never rewrites live review",
|
|
130
|
+
"# policy on your behalf.",
|
|
131
|
+
"#",
|
|
132
|
+
"# The rule exists because " + MERGE_WORKFLOW_PATH + " is the one file in this",
|
|
133
|
+
"# repository that can perform a privileged merge. Whoever can change it can",
|
|
134
|
+
"# change what merges.",
|
|
135
|
+
"",
|
|
136
|
+
MERGE_WORKFLOW_PATH + " " + owner,
|
|
137
|
+
"",
|
|
138
|
+
].join("\n");
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Render the repository-rule guidance document.
|
|
142
|
+
*
|
|
143
|
+
* Deterministic for a given owner. States, in order: what was generated, what
|
|
144
|
+
* the customer must do, and what is NOT protected until they do it.
|
|
145
|
+
*/
|
|
146
|
+
export function renderMergeWorkflowGuidance(codeOwner) {
|
|
147
|
+
const owner = validateMergeWorkflowCodeOwner(codeOwner);
|
|
148
|
+
return [
|
|
149
|
+
"# Epic Implementer merge workflow — required repository setup",
|
|
150
|
+
"",
|
|
151
|
+
"Bridge generated three files in this pull request. Merging the pull request",
|
|
152
|
+
"installs them. It does not protect them: two of the three steps below are",
|
|
153
|
+
"repository settings that only you can apply.",
|
|
154
|
+
"",
|
|
155
|
+
"## What was generated",
|
|
156
|
+
"",
|
|
157
|
+
"- `" + MERGE_WORKFLOW_PATH + "` — the merge workflow itself, contract version " +
|
|
158
|
+
String(MERGE_WORKFLOW_EXPECTED_VERSION) + ".",
|
|
159
|
+
"- `" + MERGE_WORKFLOW_CODEOWNERS_RELPATH + "` — a CODEOWNERS rule for that path.",
|
|
160
|
+
"- `" + MERGE_WORKFLOW_GUIDANCE_RELPATH + "` — this document.",
|
|
161
|
+
"",
|
|
162
|
+
"## Required: create the repository secret",
|
|
163
|
+
"",
|
|
164
|
+
"Required. The workflow authenticates to Bridge with a repository-level Actions",
|
|
165
|
+
"secret named `" + MERGE_WORKFLOW_API_KEY_SECRET_NAME + "`, holding a Bridge API",
|
|
166
|
+
"key for this repository.",
|
|
167
|
+
"",
|
|
168
|
+
"Create it through GitHub's own secret interface — repository Settings, then",
|
|
169
|
+
"Secrets and variables, then Actions. Bridge never sees, transmits, stores, or",
|
|
170
|
+
"writes the value: the installer that opened this pull request has no flag, no",
|
|
171
|
+
"prompt, and no file field for a credential, and the value must never be",
|
|
172
|
+
"committed, printed, or pasted into a project file.",
|
|
173
|
+
"",
|
|
174
|
+
"Optional: set a repository Actions VARIABLE named",
|
|
175
|
+
"`" + MERGE_WORKFLOW_BASE_URL_VARIABLE_NAME + "` if this repository talks to a",
|
|
176
|
+
"Bridge deployment other than the default. A variable, not a secret, and not a",
|
|
177
|
+
"workflow input — the base URL decides which server authorizes a merge, so it",
|
|
178
|
+
"is repository configuration rather than something a caller supplies per run.",
|
|
179
|
+
"",
|
|
180
|
+
"## Required: merge the CODEOWNERS rule into your active CODEOWNERS file",
|
|
181
|
+
"",
|
|
182
|
+
"Required. GitHub reads code owners only from `.github/CODEOWNERS`,",
|
|
183
|
+
"`CODEOWNERS`, or `docs/CODEOWNERS`. The generated fragment sits at",
|
|
184
|
+
"`" + MERGE_WORKFLOW_CODEOWNERS_RELPATH + "` and enforces nothing where it is.",
|
|
185
|
+
"",
|
|
186
|
+
"Copy its single rule into the CODEOWNERS file your repository actually uses,",
|
|
187
|
+
"keeping your existing rules intact. The rule is:",
|
|
188
|
+
"",
|
|
189
|
+
"```",
|
|
190
|
+
MERGE_WORKFLOW_PATH + " " + owner,
|
|
191
|
+
"```",
|
|
192
|
+
"",
|
|
193
|
+
"CODEOWNERS is last-match-wins, so place it after any broader rule that also",
|
|
194
|
+
"matches `.github/workflows/`.",
|
|
195
|
+
"",
|
|
196
|
+
"## Required: add the default-branch repository rule",
|
|
197
|
+
"",
|
|
198
|
+
"Required. A CODEOWNERS entry requests a reviewer. It does not require one.",
|
|
199
|
+
"Until you add a rule, anyone who can push to the default branch can rewrite",
|
|
200
|
+
"the merge workflow.",
|
|
201
|
+
"",
|
|
202
|
+
"In repository Settings, under Rules, add a ruleset targeting the default",
|
|
203
|
+
"branch that requires a pull request before merging and requires review from",
|
|
204
|
+
"code owners. Restricting who can bypass the ruleset matters as much as the",
|
|
205
|
+
"rule itself.",
|
|
206
|
+
"",
|
|
207
|
+
"## What is not protected until you finish",
|
|
208
|
+
"",
|
|
209
|
+
"Enforcement is absent until the repository rule above is in place. Between",
|
|
210
|
+
"merging this pull request and applying that rule, the workflow is installed",
|
|
211
|
+
"and reviewable but its path is not review-gated.",
|
|
212
|
+
"",
|
|
213
|
+
"## If something fails later",
|
|
214
|
+
"",
|
|
215
|
+
"Bridge refuses to dispatch a merge when it cannot detect the workflow, or when",
|
|
216
|
+
"the workflow's contract version is not the one Bridge understands. Escalate",
|
|
217
|
+
"both to a repository administrator — they are the only person who can check",
|
|
218
|
+
"that the workflow is present on the default branch, that Actions is enabled",
|
|
219
|
+
"for this repository, and that the file has not been hand-edited. Re-running",
|
|
220
|
+
"the installer reports which of the four states the file is in and never",
|
|
221
|
+
"overwrites a file it did not write.",
|
|
222
|
+
"",
|
|
223
|
+
].join("\n");
|
|
224
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration names and the command-fetch contract the RENDERED merge workflow
|
|
3
|
+
* consumes (BAPI-1136).
|
|
4
|
+
*
|
|
5
|
+
* ## Why these live here and not in `conductor/merge-execution-contract.ts`
|
|
6
|
+
*
|
|
7
|
+
* That module documents itself as the byte-for-byte TypeScript MIRROR of
|
|
8
|
+
* `api/library/epic_implementer/merge_execution_contract.py`: every constant in
|
|
9
|
+
* it has a Python twin whose spelling must match exactly. None of the values
|
|
10
|
+
* below has one. They are the workflow's OWN runtime configuration — a GitHub
|
|
11
|
+
* Actions secret name, a GitHub Actions variable name, a fallback base URL, and
|
|
12
|
+
* the shape of an endpoint the rendered YAML calls — so keeping them in the
|
|
13
|
+
* mirror would have made that module two things at once.
|
|
14
|
+
*
|
|
15
|
+
* There is also a mechanical reason, and it is a good one: a static invariant
|
|
16
|
+
* forbids any `conductor/*.ts` except `bridge-api-client.ts` from containing the
|
|
17
|
+
* literal `BAPI_BASE_URL`, because conductor code must reach the Bridge API only
|
|
18
|
+
* through its dedicated sanitized client. This module reaches the Bridge API not
|
|
19
|
+
* at all — it names a string that the CUSTOMER's workflow reads from its own
|
|
20
|
+
* repository variables — but the guard is textual, and the right response to it
|
|
21
|
+
* was to put these values where they belong rather than to work around it.
|
|
22
|
+
*/
|
|
23
|
+
import { MERGE_WORKFLOW_EXPECTED_VERSION } from "./conductor/merge-execution-contract.js";
|
|
24
|
+
/** Re-exported so a consumer needs one import for the workflow's whole contract. */
|
|
25
|
+
export { MERGE_WORKFLOW_EXPECTED_VERSION };
|
|
26
|
+
/**
|
|
27
|
+
* The ONE repository-level GitHub Actions secret the rendered workflow reads.
|
|
28
|
+
*
|
|
29
|
+
* Only the NAME lives here, and only the name ever appears in a rendered
|
|
30
|
+
* template, an installer message, or the README. The customer provisions the
|
|
31
|
+
* value themselves through GitHub's own secret UI or API; the installer never
|
|
32
|
+
* accepts, transmits, prints, or writes it (ratified review decision E-18).
|
|
33
|
+
*
|
|
34
|
+
* Repository-scoped rather than organization-scoped on purpose: a repository
|
|
35
|
+
* reviewer can create a repository secret, while an organization secret needs a
|
|
36
|
+
* second actor the install command cannot name in advance.
|
|
37
|
+
*/
|
|
38
|
+
export const MERGE_WORKFLOW_API_KEY_SECRET_NAME = "BAPI_API_KEY";
|
|
39
|
+
/**
|
|
40
|
+
* The repository-level Actions VARIABLE (not a secret) that overrides the Bridge
|
|
41
|
+
* base URL the workflow calls back into.
|
|
42
|
+
*
|
|
43
|
+
* A variable, not a dispatch input: the two dispatch inputs are fixed at
|
|
44
|
+
* `action_key` and `lease_owner` (BAPI-1134), and a base URL supplied by whoever
|
|
45
|
+
* triggered the dispatch would let the caller choose which server authorizes the
|
|
46
|
+
* merge. Absent, the workflow uses {@link MERGE_WORKFLOW_DEFAULT_BASE_URL}.
|
|
47
|
+
*/
|
|
48
|
+
export const MERGE_WORKFLOW_BASE_URL_VARIABLE_NAME = "BAPI_BASE_URL";
|
|
49
|
+
/** The hosted Bridge API the rendered workflow falls back to. */
|
|
50
|
+
export const MERGE_WORKFLOW_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
|
|
51
|
+
/**
|
|
52
|
+
* The durable merge-command fetch contract the rendered workflow consumes.
|
|
53
|
+
*
|
|
54
|
+
* ## Why a named contract rather than a live route
|
|
55
|
+
*
|
|
56
|
+
* The SERVER half of this contract is not in the tree. BAPI-1134 shipped the
|
|
57
|
+
* dispatch emitter with its durable-command persistence resolved lazily
|
|
58
|
+
* (`api/library/epic_implementer/reconciler.py`'s
|
|
59
|
+
* `_resolve_durable_merge_command_store`, which imports
|
|
60
|
+
* `api.library.vcs.epic_implementer_merge_command_store.persist_merge_command`
|
|
61
|
+
* and returns `None` today), and recorded that store as "a slice of its own"
|
|
62
|
+
* blocked on an unnamed sibling. The BAPI-1136 review ratified carving that
|
|
63
|
+
* store and its fetch endpoint out as a separate A2 child that lands FIRST
|
|
64
|
+
* (decision E-14), with this child writing the workflow's fetch step against the
|
|
65
|
+
* named contract below.
|
|
66
|
+
*
|
|
67
|
+
* ## What that means operationally
|
|
68
|
+
*
|
|
69
|
+
* Until the sibling child lands, the endpoint answers 404 and the rendered
|
|
70
|
+
* workflow refuses with `merge_command_unavailable` before touching the pull
|
|
71
|
+
* request — no retry, no merge, no completion report. The merge lane is
|
|
72
|
+
* therefore INERT by construction rather than by a flag someone could flip: no
|
|
73
|
+
* dispatch is emitted at all while the command store is absent, and a dispatch
|
|
74
|
+
* that somehow arrived would be refused at the first step. That is the block on
|
|
75
|
+
* operational enablement; {@link MERGE_WORKFLOW_EXPECTED_VERSION} exact equality
|
|
76
|
+
* remains the SEPARATE deploy-ordering refusal it already was, and neither
|
|
77
|
+
* substitutes for the other.
|
|
78
|
+
*
|
|
79
|
+
* ## Shape
|
|
80
|
+
*
|
|
81
|
+
* Keyed by `action_key`, NOT by pull request number: the workflow learns the
|
|
82
|
+
* pull request FROM the command, so a PR-scoped route could not be called. That
|
|
83
|
+
* is also why a fetch failure cannot be reported through
|
|
84
|
+
* `POST /vcs/pull-requests/{pr}/merge/complete` — the path parameter is not yet
|
|
85
|
+
* resolved — and the lease's own expiry is the reconciler's backstop for that
|
|
86
|
+
* one window.
|
|
87
|
+
*/
|
|
88
|
+
export const MERGE_COMMAND_FETCH_METHOD = "POST";
|
|
89
|
+
/** Root-mounted, repo-authorized path of the durable command fetch. */
|
|
90
|
+
export const MERGE_COMMAND_FETCH_PATH = "/vcs/merge-commands/fetch";
|
|
91
|
+
/**
|
|
92
|
+
* The request fields the fetch accepts, in the contract's own spelling.
|
|
93
|
+
*
|
|
94
|
+
* `lease_owner` is the per-attempt secret BAPI-1134 mints; it is presented here
|
|
95
|
+
* as PROOF that this dispatch is the live attempt, never echoed, and never a
|
|
96
|
+
* response field.
|
|
97
|
+
*/
|
|
98
|
+
export const MERGE_COMMAND_FETCH_REQUEST_FIELDS = [
|
|
99
|
+
"repo_name",
|
|
100
|
+
"action_key",
|
|
101
|
+
"lease_owner",
|
|
102
|
+
];
|
|
103
|
+
/**
|
|
104
|
+
* The separator and field position the Bridge repository name occupies inside a
|
|
105
|
+
* merge action key (`merge:{repo}:{pr}:{head_sha}:{gate_identity}`, built by
|
|
106
|
+
* `makeMergeActionKey`).
|
|
107
|
+
*
|
|
108
|
+
* The workflow derives `repo_name` from the key it was dispatched with rather
|
|
109
|
+
* than accepting a third dispatch input, so the Bridge-side identity and the
|
|
110
|
+
* attempt identity cannot disagree.
|
|
111
|
+
*/
|
|
112
|
+
export const MERGE_ACTION_KEY_REPO_FIELD_INDEX = 1;
|
|
113
|
+
/**
|
|
114
|
+
* The response projection the workflow consumes, in the contract's own spelling.
|
|
115
|
+
*
|
|
116
|
+
* `lease_owner` is deliberately ABSENT: a response that echoed the live
|
|
117
|
+
* per-attempt secret would hand it to every log that captured the body.
|
|
118
|
+
*
|
|
119
|
+
* `github_repository` is the provider-side `owner/name` the command was issued
|
|
120
|
+
* against. The workflow compares it with `github.repository` before reading the
|
|
121
|
+
* pull request, so a command minted for a different repository cannot actuate
|
|
122
|
+
* here even if its key and lease were presented correctly.
|
|
123
|
+
*/
|
|
124
|
+
export const MERGE_COMMAND_FETCH_RESPONSE_FIELDS = [
|
|
125
|
+
"action_key",
|
|
126
|
+
"repo_name",
|
|
127
|
+
"github_repository",
|
|
128
|
+
"pr_number",
|
|
129
|
+
"expected_head_sha",
|
|
130
|
+
"merge_method",
|
|
131
|
+
"execution",
|
|
132
|
+
"lease_expires_at",
|
|
133
|
+
];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical byte identity for the Epic Implementer merge workflow (BAPI-1136).
|
|
3
|
+
*
|
|
4
|
+
* The merge workflow is the one file in a customer repository that can perform a
|
|
5
|
+
* privileged write on Bridge's behalf, so "is the installed copy the copy Bridge
|
|
6
|
+
* ships?" has to be answerable cheaply, in more than one place, and with one
|
|
7
|
+
* answer. This module is that one place.
|
|
8
|
+
*
|
|
9
|
+
* It follows `claude-review-workflow-drift.ts`'s shape deliberately: a leaf
|
|
10
|
+
* module with NO filesystem, network, or subprocess access, importing only the
|
|
11
|
+
* shared Git blob hash so two workflow-identity implementations cannot drift
|
|
12
|
+
* apart about what "the same file" means. Callers acquire bytes however their
|
|
13
|
+
* own layer already does and hand them in.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here logs file content: a classification carries a path, a pair of
|
|
16
|
+
* SHAs, and a closed-vocabulary state — never bytes, never provider text.
|
|
17
|
+
*/
|
|
18
|
+
import { computeGitBlobSha } from "./claude-review-workflow-drift.js";
|
|
19
|
+
import { MERGE_WORKFLOW_PATH } from "./conductor/merge-execution-contract.js";
|
|
20
|
+
import { renderEpicImplementerMergeWorkflow } from "./merge-workflow.js";
|
|
21
|
+
export { MERGE_WORKFLOW_PATH };
|
|
22
|
+
/**
|
|
23
|
+
* The Git blob SHA-1 of the workflow this build renders.
|
|
24
|
+
*
|
|
25
|
+
* Computed, never hardcoded. A checked-in literal would be a second byte source
|
|
26
|
+
* the renderer could silently diverge from — exactly the drift this module
|
|
27
|
+
* exists to make impossible.
|
|
28
|
+
*/
|
|
29
|
+
export function canonicalMergeWorkflowSha() {
|
|
30
|
+
return computeGitBlobSha(Buffer.from(renderEpicImplementerMergeWorkflow(), "utf8"));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Every Bridge-published historical rendering of the merge workflow, by Git blob
|
|
34
|
+
* SHA, with the contract version it declared.
|
|
35
|
+
*
|
|
36
|
+
* Empty at contract version 1: there is no earlier published version to upgrade
|
|
37
|
+
* FROM. A future contract version adds its predecessor's SHA here, and only
|
|
38
|
+
* then can {@link classifyMergeWorkflowContent} return `managed_version_older`.
|
|
39
|
+
*/
|
|
40
|
+
export const MANAGED_MERGE_WORKFLOW_HISTORY = new Map();
|
|
41
|
+
/**
|
|
42
|
+
* Classify an installed copy against the canonical bytes.
|
|
43
|
+
*
|
|
44
|
+
* `existing` is `null` for an absent file. The comparison is on RAW BYTES, not
|
|
45
|
+
* normalized text: a copy that differs only in line endings is still a copy this
|
|
46
|
+
* installer did not write, and treating it as identical would let a rewrite hide
|
|
47
|
+
* behind a whitespace-insensitive compare.
|
|
48
|
+
*/
|
|
49
|
+
export function classifyMergeWorkflowContent(existing) {
|
|
50
|
+
if (existing === null)
|
|
51
|
+
return "not_installed";
|
|
52
|
+
const canonical = renderEpicImplementerMergeWorkflow();
|
|
53
|
+
if (existing === canonical)
|
|
54
|
+
return "identical";
|
|
55
|
+
const sha = computeGitBlobSha(Buffer.from(existing, "utf8"));
|
|
56
|
+
if (MANAGED_MERGE_WORKFLOW_HISTORY.has(sha))
|
|
57
|
+
return "managed_version_older";
|
|
58
|
+
return "modified_or_unrecognized";
|
|
59
|
+
}
|
|
60
|
+
/** Operator-facing gloss for each classification. Secret-free and path-only. */
|
|
61
|
+
export const MERGE_WORKFLOW_CLASSIFICATION_TEXT = {
|
|
62
|
+
not_installed: `${MERGE_WORKFLOW_PATH} is not installed`,
|
|
63
|
+
identical: `${MERGE_WORKFLOW_PATH} already matches the version this build ships`,
|
|
64
|
+
managed_version_older: `${MERGE_WORKFLOW_PATH} is an older Bridge-managed version and can be upgraded`,
|
|
65
|
+
modified_or_unrecognized: `${MERGE_WORKFLOW_PATH} has been modified or is not a version Bridge published`,
|
|
66
|
+
};
|