diff --git a/.changes/autonomy/work-resolve-issue-issue-7469-c2f85a17.md b/.changes/autonomy/work-resolve-issue-issue-7469-c2f85a17.md new file mode 100644 index 00000000000..342396a5de4 --- /dev/null +++ b/.changes/autonomy/work-resolve-issue-issue-7469-c2f85a17.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- `work-classes.md` names the babysit merge gate's rungs `merge` (`C2`) and `merge-reviewed` (`C3`) where it maps each rung to its promotion cells ([#7469](https://github.com/melodic-software/claude-code-plugins/issues/7469)). diff --git a/.changes/pr-pipeline/work-resolve-issue-issue-7469-9b1d3f60.md b/.changes/pr-pipeline/work-resolve-issue-issue-7469-9b1d3f60.md new file mode 100644 index 00000000000..d957ea03f8f --- /dev/null +++ b/.changes/pr-pipeline/work-resolve-issue-issue-7469-9b1d3f60.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Changed + +- `merge.rung` takes names for what each rung needs before the merge authority merges: `off`, `merge` and `merge-reviewed`. `C2` and `C3` stay accepted as aliases for `merge` and `merge-reviewed`, and each conditional rung requirement applies to both spellings. The example and this repository's config use `merge`, and the README names the rung first with its code in parentheses ([#7469](https://github.com/melodic-software/claude-code-plugins/issues/7469)). diff --git a/.changes/source-control/work-resolve-issue-issue-7469-4e7a2c91.md b/.changes/source-control/work-resolve-issue-issue-7469-4e7a2c91.md new file mode 100644 index 00000000000..6602a1db8f5 --- /dev/null +++ b/.changes/source-control/work-resolve-issue-issue-7469-4e7a2c91.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- The babysit merge gate reads the `merge.rung` names `merge` and `merge-reviewed` as the rungs `C2` and `C3`, at the same rank and with the same promotion cells, in the config, `CLAUDE_LANES_MERGE_RUNG_CAP` and `--lane-rung-cap`. `C2` and `C3` stay accepted as aliases, and the gate still reports the rung by its code ([#7469](https://github.com/melodic-software/claude-code-plugins/issues/7469)). diff --git a/.github/actions/resolve-config/merge-authority.test.mjs b/.github/actions/resolve-config/merge-authority.test.mjs index 2d4d5e6bd15..659a22239b9 100644 --- a/.github/actions/resolve-config/merge-authority.test.mjs +++ b/.github/actions/resolve-config/merge-authority.test.mjs @@ -197,6 +197,24 @@ test("rung C2 with authority local-babysit fails on both the missing diff-check ); }); +// Each rung above off has a name and a code kept as its alias; both spellings +// carry the same conditional requirements. +for (const rung of ["merge", "merge-reviewed", "C2", "C3"]) { + test(`rung ${rung} validates and carries the requirements of a rung above off`, () => { + assert.doesNotThrow(() => run(withMerge({ rung }))); + assert.doesNotThrow(() => run(withMerge({ ...LANE, rung, "require-verifier-verdict": true }))); + rejectedFor(withMerge({ ...LANE, rung }), /require-verifier-verdict/); + rejectedFor( + withMerge({ rung, authority: "local-babysit", "agent-identity": IDENTITY }), + /required property 'diff-check'/, + ); + }); +} + +test("a rung outside off, its names and its codes fails the schema", () => { + rejectedFor(withMerge({ rung: "C4" }), /\/merge\/rung must be equal to one of the allowed values/); +}); + test("an agent-identity without the [bot] suffix fails the schema: it must be a GitHub App", () => { rejectedFor( withMerge({ authority: "local-babysit", "agent-identity": "some-user" }), diff --git a/docs/contracts/loop-lane/CHANGELOG.md b/docs/contracts/loop-lane/CHANGELOG.md index e7ad18b33b9..193058fd0b6 100644 --- a/docs/contracts/loop-lane/CHANGELOG.md +++ b/docs/contracts/loop-lane/CHANGELOG.md @@ -5,6 +5,15 @@ topology, the escalation contract, the capability-tier vocabulary, or any loop-l major bump, and additive guidance is a minor bump. A new model release re-audits the capability-tier table (§3); drift found by that audit is recorded here. +## [13.1.1] - 2026-11-01 + +Patch. Wording only; no topology, escalation-contract mechanism, capability-tier or §4 invariant +changed. + +- **Rung names (§1).** The unattended merge gate's rungs are named `merge` (`C2`) and + `merge-reviewed` (`C3`) where the contract maps each rung to its promotion cells, matching the + `merge.rung` names in the PR pipeline convention (#7469). + ## [13.1.0] - 2026-10-31 Minor. Guidance only: confirms an interim §8 mechanism and adds how to read it; no topology, diff --git a/docs/contracts/loop-lane/README.md b/docs/contracts/loop-lane/README.md index 00c15f709d8..697022599fd 100644 --- a/docs/contracts/loop-lane/README.md +++ b/docs/contracts/loop-lane/README.md @@ -62,8 +62,8 @@ too ([the pr-merge lane](../../conventions/pr-pipeline/README.md#the-pr-merge-la [`work-classes.md`](../../../plugins/autonomy/reference/guardrails/work-classes.md#merge-authorities-that-read-no-class)). The unattended merge gate (`/source-control:babysit-prs`, `--unattended`) reads no class either, by [owner decision](https://github.com/melodic-software/claude-code-plugins/issues/9668#issuecomment-6386299330): -no class caps its rung, its promotion evidence is read by rung, not class (C2 needs -`C2-auto-merge`, C3 every C3 cell), and its one class-named +no class caps its rung, its promotion evidence is read by rung, not class (`merge` (`C2`) needs +`C2-auto-merge`, `merge-reviewed` (`C3`) every C3 cell), and its one class-named check is the trust hold, which refuses a pull request whose linked issue carries `work-class: untrusted-provenance` or had it removed by a non-human. Its rung and rung cap remain pacing caps. Higher rungs are opt-in per repository, up to full autonomy, where frontier-tier subagents resolve diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index 43de7df9826..56c23373a71 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -1029,10 +1029,12 @@ branch-cleanup: max-deletes-per-run: 25 merge: - # The lane treats every PR the lanes App opened for one closing issue as - # class C2 (plugins/autonomy/reference/guardrails/work-classes.md) and reads - # no `work-class:` label. Its merge criterion is the verifier verdict below. - rung: C2 + # The rung is a pacing cap: off merges nothing without a human; merge lets + # the merge authority merge; merge-reviewed also needs AI review to be + # blocking on the local babysit lane. C2 and C3 are accepted aliases for + # merge and merge-reviewed. The lane reads no `work-class:` label: its merge + # criterion is the verifier verdict below. + rung: merge authority: pr-merge agent-identity: melodic-automation-lanes[bot] # The Apps whose touch holds a trust-gate path change for a person in diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index f5f5dd26c9a..3df8c071754 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -991,10 +991,13 @@ run by the local babysit lane (loop-lane), or `pr-merge`. When it is absent the reads the authority the wrapper runs as: `local-babysit` by default, `pr-merge` under `--authority pr-merge` (below). `merge.report-only: true` makes that authority report each decision and write nothing. `merge.rung` is a pacing cap: `off` merges nothing without a human, -and `C2` or `C3` lets the authority merge. Neither authority reads a work class +and `merge` or `merge-reviewed` lets the authority merge. On the pr-merge lane both merge on the +verifier verdict; on the local babysit lane `merge-reviewed` also needs AI review to be blocking +(its promotion cells, below). `C2` and `C3` are accepted aliases for `merge` and `merge-reviewed`. +Neither authority reads a work class ([`work-classes.md`](../../../plugins/autonomy/reference/guardrails/work-classes.md#merge-authorities-that-read-no-class)). -- `off` is the default. The first rung a repository adopts is `C2`, together with +- `off` is the default. The first rung a repository adopts is `merge` (`C2`), together with `merge.diff-check`: the diff touches no denied path and stays under the size cap. - No work class is read, and no class, missing class or diff shape caps the rung. The only class-named check is the untrusted-provenance hold: a pull request whose linked issue carries @@ -1065,7 +1068,8 @@ the rules that only tighten in key, or either file the wrapper cannot read or trust, means `off`; a YAML without it leaves the legacy value, and with no legacy file the rung is `off`, so the YAML alone never raises it), the cap variable, and `off` while any demotion issue is open. -- **Cap variable.** `CLAUDE_LANES_MERGE_RUNG_CAP` (`off`, `C2` or `C3`) is a repository variable +- **Cap variable.** `CLAUDE_LANES_MERGE_RUNG_CAP` (`off`, `merge` or `merge-reviewed`, or the + aliases `C2` and `C3`) is a repository variable only a human sets, and it can only lower the rung. The wrapper never writes it, and the identity can read variables but not write them. A 404 after the variables listing proved readable means no cap; any other failure means `off`. @@ -1616,7 +1620,8 @@ except six: stops the lane. The value is read once per run, and a stopped run cannot dequeue: to stop at once, cancel the running pr-merge run and dequeue the lane's pull request by hand. - **Cap variable.** For the same reason the workflow passes `CLAUDE_LANES_MERGE_RUNG_CAP` from its - `vars` context as `--lane-rung-cap`. Empty (unset) is no cap; `off`, `C2` or `C3` caps the rung; + `vars` context as `--lane-rung-cap`. Empty (unset) is no cap; `off`, `merge` or `merge-reviewed` + (or the aliases `C2` and `C3`) caps the rung; a missing flag or any other value holds the rung at `off`. - **Post-merge check and merges in flight.** Only merges the agent identity enqueued count. A merge by a person or by another App, such as a release pull request, is not the lane's. @@ -2121,7 +2126,7 @@ the same rules (`babysit_dependabot.py` here, `dependency-classify.mjs` in pr-un always refuses, verdict or not. - **Gate.** Either tier merges only once `ci-status` succeeded on its head, through the same gate as the lane's own: mergeable, no `do-not-merge` label, no body hold, no blocking comment from a - human with write access, the rung (C2) and its cap, the kill switches, the post-merge check and + human with write access, the rung (`merge` (`C2`) or above) and its cap, the kill switches, the post-merge check and the merges in flight. No closing issue, denied-path rule or diff risk score applies: `ci-status` (and, for tier `review`, the review verdict) is the gate, and a workflow pin bump touches `.github/**` by definition. diff --git a/docs/conventions/pr-pipeline/examples/claude-code-plugins.yaml b/docs/conventions/pr-pipeline/examples/claude-code-plugins.yaml index f1b61c3bf0c..3820c79a190 100644 --- a/docs/conventions/pr-pipeline/examples/claude-code-plugins.yaml +++ b/docs/conventions/pr-pipeline/examples/claude-code-plugins.yaml @@ -197,7 +197,8 @@ models: claude: review merge: - rung: C2 + # off, merge or merge-reviewed (README.md, Merge authority). + rung: merge # The merge authority and, for local-babysit, the login the lane acts as # (README.md, Merge authority). local-babysit requires agent-identity. # authority: local-babysit diff --git a/docs/conventions/pr-pipeline/pr-pipeline.schema.json b/docs/conventions/pr-pipeline/pr-pipeline.schema.json index 51f49c31f4d..2e548cb6272 100644 --- a/docs/conventions/pr-pipeline/pr-pipeline.schema.json +++ b/docs/conventions/pr-pipeline/pr-pipeline.schema.json @@ -993,7 +993,7 @@ "if": { "required": ["rung", "authority"], "properties": { - "rung": { "enum": ["C2", "C3"] }, + "rung": { "enum": ["merge", "merge-reviewed", "C2", "C3"] }, "authority": { "const": "local-babysit" } } }, @@ -1018,7 +1018,7 @@ "if": { "required": ["rung", "agent-identity"], "properties": { - "rung": { "enum": ["C2", "C3"] }, + "rung": { "enum": ["merge", "merge-reviewed", "C2", "C3"] }, "authority": { "const": "pr-merge" } } }, @@ -1030,9 +1030,9 @@ ], "properties": { "rung": { - "enum": ["off", "C2", "C3"], + "enum": ["off", "merge", "merge-reviewed", "C2", "C3"], "default": "off", - "description": "Pacing cap for the merge authority: off merges nothing without a human; C2 or C3 lets it merge. No work class is read; the only class-named check is the local babysit lane's untrusted-provenance hold (README.md, Merge authority)." + "description": "Pacing cap for the merge authority: off merges nothing without a human; merge or merge-reviewed lets it merge, and on the local babysit lane merge-reviewed also needs AI review to be blocking. C2 and C3 are accepted aliases for merge and merge-reviewed. No work class is read; the only class-named check is the local babysit lane's untrusted-provenance hold (README.md, Merge authority)." }, "authority": { "enum": ["local-babysit", "pr-merge"], diff --git a/lib/parse-concern-value.test.sh b/lib/parse-concern-value.test.sh index e439b3d7106..85a06187fca 100755 --- a/lib/parse-concern-value.test.sh +++ b/lib/parse-concern-value.test.sh @@ -134,8 +134,8 @@ assert_exit "no args exits 2" 2 "$rc" # --- Dotted keys and lists, read from the pipeline's canonical example --- # Expected values are the literals on the cited lines of that example file. EX="$SCRIPT_DIR/../docs/conventions/pr-pipeline/examples/claude-code-plugins.yaml" -assert_eq 'nested key merge.rung (example :173)' "C2" "$(bash "$SCRIPT" "$EX" merge.rung)" -assert_eq '--list prints a flow list one item per line, quotes removed (example :179)' \ +assert_eq 'nested key merge.rung (example :201)' "merge" "$(bash "$SCRIPT" "$EX" merge.rung)" +assert_eq '--list prints a flow list one item per line, quotes removed (example :207)' \ $'.github/**\n.claude/**\ndocs/conventions/pr-pipeline.yaml' \ "$(bash "$SCRIPT" "$EX" merge.diff-check.denied-paths --list)" assert_eq 'a block sequence of mappings is indexed (example :143)' "merge" \ @@ -215,7 +215,7 @@ assert_eq 'duplicate key: --strict names the second line' "parse-concern-value: rc=0 out=$(bash "$SCRIPT" --strict "$EX" merge.rung) || rc=$? assert_exit '--strict on a clean file exits 0' 0 "$rc" -assert_eq '--strict on a clean file prints the value' "C2" "$out" +assert_eq '--strict on a clean file prints the value' "merge" "$out" assert_eq '--strict on a missing file prints the fallback' ".x" \ "$(bash "$SCRIPT" --strict "$TEST_TMPDIR/nope.yaml" memory_dir .x)" diff --git a/plugins/autonomy/reference/guardrails/work-classes.md b/plugins/autonomy/reference/guardrails/work-classes.md index a4e36ebbde6..801f8e2a7b4 100644 --- a/plugins/autonomy/reference/guardrails/work-classes.md +++ b/plugins/autonomy/reference/guardrails/work-classes.md @@ -99,8 +99,8 @@ authority refuses every fork change request and every change request its automat the dependency manager did not open. The babysit unattended merge gate (`/source-control:babysit-prs`, its `--unattended` path) is -another. It reads no class; it needs its effective rung's promotion cells (rung C2 the `C2-auto-merge` -cell, rung C3 every C3 cell), read by rung, never by class. The one label it +another. It reads no class; it needs its effective rung's promotion cells (rung `merge` (`C2`) the +`C2-auto-merge` cell, rung `merge-reviewed` (`C3`) every C3 cell), read by rung, never by class. The one label it reads by a class name is a trust hold: it refuses a change request whose linked item carries `work-class: untrusted-provenance`, or had that label removed by a non-human, and it needs the linked item vouched for by a human (written by one with write access, or given the admission diff --git a/plugins/source-control/skills/babysit-prs/reference/safety.md b/plugins/source-control/skills/babysit-prs/reference/safety.md index cea107829e5..18592320fa6 100644 --- a/plugins/source-control/skills/babysit-prs/reference/safety.md +++ b/plugins/source-control/skills/babysit-prs/reference/safety.md @@ -1081,8 +1081,8 @@ any refusal is the blocker `unattended merge refused: `: promotion evidence (`unattended.promoted`, from the babysit-loop resolver, which reads its four options from the operator's managed and user settings, never from arguments or the environment), and eligibility. The cells are the effective rung's, never the pull request's - work class: rung C2 needs `C2-auto-merge`, rung C3 every C3 cell - (`promotion-unqualified:` otherwise); an unreadable or untrusted resolver refuses as + work class: rung `merge` (`C2`) needs `C2-auto-merge`, rung `merge-reviewed` (`C3`) every C3 + cell (`promotion-unqualified:` otherwise, naming the rung by its code); an unreadable or untrusted resolver refuses as `promotion-unqualified:`. A settings file, or a binding, probe root, evidence source or checker it names, that the lane user can write, or replace through any directory above it, refuses; every worktree of the checkout is passed to the resolver as a `--worktree-root`, so a diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py index f15054e8cb3..9ab1073b773 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py @@ -100,6 +100,9 @@ PARSER = Path(__file__).resolve().parents[3] / "lib" / "parse-concern-value.sh" RUNG_RANK = {"off": 0, "C2": 2, "C3": 3} +# Each `merge.rung` name and the code it stands for. The codes stay accepted +# as aliases and are the form the gate compares and reports. +RUNG_NAMES = {"merge": "C2", "merge-reviewed": "C3"} # `babysit_loop_merge` values as a rung. LOOP_MERGE_RUNG = { "human-only": "off", @@ -1013,9 +1016,11 @@ def scalar(key: str) -> str | None: found = values[key] return found[0] if found else None - rung = scalar("merge.rung") or "off" + rung = rung_code(scalar("merge.rung")) or "off" if rung not in RUNG_RANK: - notes.append(f"merge.rung {rung!r} is not off, C2 or C3") + notes.append( + f"merge.rung {rung!r} is not off, merge (C2) or merge-reviewed (C3)" + ) rung = "off" lines = scalar("merge.diff-check.max-changed-lines") if lines is not None and not re.fullmatch(r"[0-9]+", lines): @@ -1432,6 +1437,12 @@ def lane_identity(repo: str, base_sha: str) -> tuple[str | None, str]: # --- Effective rung --------------------------------------------------------------- +def rung_code(value: str | None) -> str | None: + """`value` with a rung name (`RUNG_NAMES`) read as its code; any other + value, a code or an unknown one, unchanged.""" + return RUNG_NAMES.get(value or "", value) + + @dataclass(frozen=True) class EffectiveRung: rung: str @@ -1444,6 +1455,7 @@ def effective_rung( ) -> EffectiveRung: """min(merge.rung, babysit_loop_merge, rung cap, demotion); an unknown input is off. `cap` None means no cap (a proven 404); `demoted` holds it off.""" + config_rung, cap = rung_code(config_rung), rung_code(cap) inputs = { "merge.rung": config_rung if config_rung in RUNG_RANK else "off", LOOP_MERGE_KEY: LOOP_MERGE_RUNG.get(loop_merge or "", "off"), @@ -1544,6 +1556,7 @@ def read_rung_cap(repo: str) -> RungCap: status, value = _variable(owner, name, RUNG_CAP_VARIABLE) if status == 404: return RungCap(None, "no-cap") + value = rung_code(value) if status == 200 and value in RUNG_RANK: return RungCap(value, "cap-set") return RungCap("off", "cap-unreadable" if status != 200 else "cap-invalid") @@ -1561,6 +1574,7 @@ def rung_cap(repo: str, *, authority: str, lane_value: str | None = None) -> Run return RungCap("off", "lane-value-missing") if lane_value == "": return RungCap(None, "no-cap") + lane_value = rung_code(lane_value) if lane_value in RUNG_RANK: return RungCap(lane_value, "cap-set") return RungCap("off", "cap-invalid") @@ -5757,7 +5771,7 @@ class Promotion: reason: str | None def promoted(self, rung: str | None) -> bool: - needed = RUNG_CELLS.get(rung or "") + needed = RUNG_CELLS.get(rung_code(rung) or "") return bool( self.cells and needed diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py index d473dddc462..09b626615b7 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py @@ -4605,7 +4605,9 @@ def _evidence_report(args: argparse.Namespace, reader: Reader) -> dict[str, Any] rung = merge_policy.effective_rung( config_rung=config.rung, loop_merge=config.loop_merge, - cap=args.rung if args.rung in merge_policy.RUNG_RANK else "off", + cap=args.rung + if merge_policy.rung_code(args.rung) in merge_policy.RUNG_RANK + else "off", demoted=False, ).rung held = ( diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_lane_reviews.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_lane_reviews.py index dd1ee9b10d4..84bac689853 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_lane_reviews.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_lane_reviews.py @@ -783,6 +783,14 @@ def test_the_line_says_whether_a_whole_review_clears_the_risk_hold(self) -> None self.assertIs(report["wholeReviewWanted"], whole) self.assertIs(self.cli(passed, None, head="d" * 40)["wholeReviewWanted"], False) + def test_a_rung_name_from_the_room_scores_as_its_code(self) -> None: + for rung in ("merge", "merge-reviewed"): + with self.subTest(rung=rung): + report = self.cli( + ve.Evidence(None, HEAD, (), 0), "unsafe-path: docs/link", rung=rung + ) + self.assertEqual(report["reviews"], "unsafe-path: docs/link") + def test_no_rung_from_the_room_holds(self) -> None: for rung in (None, "off", "C9"): with self.subTest(rung=rung): diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge_policy.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge_policy.py index fea4561dd5c..43f38cc5355 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge_policy.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge_policy.py @@ -492,6 +492,19 @@ def test_an_unknown_rung_is_off(self) -> None: config_fake(self, "merge:\n rung: C4\n") self.assertEqual(policy.read_config_at(REPO, SHA).rung, "off") + def test_a_rung_name_and_its_code_read_as_the_same_rung(self) -> None: + for value, expected in ( + ("off", "off"), + ("merge", "C2"), + ("C2", "C2"), + ("merge-reviewed", "C3"), + ("C3", "C3"), + ): + with self.subTest(value=value): + config_fake(self, f"merge:\n rung: {value}\n") + config = policy.read_config_at(REPO, SHA) + self.assertEqual((config.rung, config.notes), (expected, ())) + def test_a_value_with_a_line_separator_is_a_parse_error(self) -> None: for char in ("\u0085", "\u2028", "\x0b", "\x1c"): with self.subTest(char=repr(char)): @@ -626,6 +639,12 @@ def test_unknown_inputs_are_off(self) -> None: def test_names_the_input_that_holds_it_off(self) -> None: self.assertEqual(self.rung(cap="off").limited_by, "rungCap") + def test_a_rung_name_ranks_as_its_code(self) -> None: + for name, code in (("merge", "C2"), ("merge-reviewed", "C3")): + with self.subTest(name=name): + self.assertEqual(self.rung(config_rung=name).rung, code) + self.assertEqual(self.rung(cap=name).rung, code) + class KillSwitch(unittest.TestCase): VARS = f"repos/{REPO}/actions/variables" @@ -702,6 +721,22 @@ def test_a_set_value_caps_and_an_unknown_value_is_off(self) -> None: self.assertEqual(self.cap({"variables": []}, "C4"), "off") self.assertEqual(self.cap({"variables": []}, 500), "off") + def test_a_rung_name_caps_as_its_code(self) -> None: + self.assertEqual(self.cap({"variables": []}, "merge"), "C2") + self.assertEqual(self.cap({"variables": []}, "merge-reviewed"), "C3") + + def test_the_lane_value_takes_a_rung_name_or_its_code(self) -> None: + for value, expected in ( + ("merge", "C2"), + ("C2", "C2"), + ("merge-reviewed", "C3"), + ("C3", "C3"), + ("merge-verified", "off"), + ): + with self.subTest(value=value): + cap = policy.rung_cap(REPO, authority=policy.PR_MERGE, lane_value=value) + self.assertEqual(cap.value, expected) + class IsHuman(unittest.TestCase): def human(self, actor: Any, identity: str | None = IDENTITY, **kw: Any) -> bool: @@ -2647,6 +2682,8 @@ def test_only_an_on_rung_is_promoted(self) -> None: result, _ = self.run_resolver() self.assertTrue(result.promoted("C2")) self.assertTrue(result.promoted("C3")) + self.assertTrue(result.promoted("merge")) + self.assertTrue(result.promoted("merge-reviewed")) for other in ("off", None, "C1", "C4", "C5"): self.assertFalse(result.promoted(other)) @@ -2663,6 +2700,9 @@ def test_each_rung_needs_its_own_cells(self) -> None: result, _ = self.run_resolver(payload) self.assertTrue(result.promoted("C2")) self.assertFalse(result.promoted("C3")) + # A rung name needs the cells of the code it stands for. + self.assertTrue(result.promoted("merge")) + self.assertFalse(result.promoted("merge-reviewed")) payload["cells"] = self.CELLS | {"C2-auto-merge": {"effective": "unpromoted"}} result, _ = self.run_resolver(payload) self.assertFalse(result.promoted("C2"))