From 21ff12cef730f01529ff8c022f4edd2fd00a6991 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 09:39:52 -0400 Subject: [PATCH] feat: add prgs-reconciler profile model and role detection (Closes #304) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/gitea-dual-namespace-deployment.md | 1 + docs/gitea-execution-profiles.md | 21 ++++++ gitea_mcp_server.py | 9 +++ migrate_profiles.py | 2 + reconciler_profile.py | 94 +++++++++++++++++++++++++ tests/test_reconciler_profile.py | 87 +++++++++++++++++++++++ 6 files changed, 214 insertions(+) create mode 100644 reconciler_profile.py create mode 100644 tests/test_reconciler_profile.py diff --git a/docs/gitea-dual-namespace-deployment.md b/docs/gitea-dual-namespace-deployment.md index aa4c03d..e54b0ee 100644 --- a/docs/gitea-dual-namespace-deployment.md +++ b/docs/gitea-dual-namespace-deployment.md @@ -23,6 +23,7 @@ launched with exactly one static execution profile: |-----------------------------|----------------|-------------| | `gitea-author` | an author profile | implement issues, push branches, open PRs, comment | | `gitea-reviewer` | a reviewer profile | review, approve/request changes, merge | +| `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304) | Properties: diff --git a/docs/gitea-execution-profiles.md b/docs/gitea-execution-profiles.md index 061ae70..50a6dcd 100644 --- a/docs/gitea-execution-profiles.md +++ b/docs/gitea-execution-profiles.md @@ -226,6 +226,27 @@ explicit operator-directed closure of a contaminated PR). If `close_pr` ever resolves as unknown, agents must fail closed rather than fall back to the edit path. +## Reconciler profile for already-landed open PRs (#304) + +Normal author and reviewer profiles must not gain broad `gitea.pr.close` +authority. Already-landed open PRs need a dedicated reconciler profile such +as `prgs-reconciler` with a narrow operation set: + +- `gitea.read` +- `gitea.pr.comment` +- `gitea.issue.comment` +- `gitea.issue.close` +- `gitea.pr.close` + +Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`, +`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and +`gitea.repo.commit`. + +Launch a static `gitea-reconciler` MCP namespace with +`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by +`reconciler_profile.assess_reconciler_profile`; the gated close MCP tool and +ancestor proofs ship with the #310 reconciler close stack. + ## Identity and fail-closed rules Before **any** mutating action, a workflow must know both: diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index dbe1c2e..f1aef55 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -478,6 +478,8 @@ import agent_temp_artifacts import issue_lock_worktree # noqa: E402 import issue_claim_heartbeat # noqa: E402 import merged_cleanup_reconcile # noqa: E402 +import reconciler_profile # noqa: E402 +import reconciler_profile # noqa: E402 # Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue, @@ -3935,10 +3937,13 @@ def gitea_create_issue_comment( def _role_kind(allowed, forbidden) -> str: """Classify the active profile from its normalized operations. + 'reconciler' can close already-landed PRs without review/author powers; 'author' can create PRs / push branches; 'reviewer' can approve/merge; 'mixed' can do both (a config smell — the reviewer-deadlock invariant forbids it in v2 configs); 'limited' can do neither. """ + if reconciler_profile.is_reconciler_profile(allowed, forbidden): + return "reconciler" def can(op): return gitea_config.check_operation(op, allowed, forbidden)[0] review = can("gitea.pr.approve") or can("gitea.pr.merge") @@ -4905,6 +4910,10 @@ def gitea_get_runtime_context( "preflight_block_reasons": preflight["preflight_block_reasons"], "preflight_workspace": preflight.get("preflight_workspace"), "session_capabilities": session_capabilities, + "reconciler_profile_assessment": reconciler_profile.assess_reconciler_profile( + allowed, forbidden + ), + "role_kind": _role_kind(allowed, forbidden), } if reveal and h: diff --git a/migrate_profiles.py b/migrate_profiles.py index 3613c04..6bf150c 100755 --- a/migrate_profiles.py +++ b/migrate_profiles.py @@ -31,6 +31,8 @@ REVIEWER_DEFAULT_FORBIDDEN = ["branch", "commit", "push", "open_pr"] def infer_role(name, execution_profile): """Return the unambiguous role for a legacy profile name, or None.""" haystack = f"{name} {execution_profile or ''}".lower() + if "reconciler" in haystack: + return "reconciler" has_author = "author" in haystack has_reviewer = "reviewer" in haystack if has_author == has_reviewer: diff --git a/reconciler_profile.py b/reconciler_profile.py new file mode 100644 index 0000000..4d9d589 --- /dev/null +++ b/reconciler_profile.py @@ -0,0 +1,94 @@ +"""Reconciler profile model for already-landed PR closure (#304). + +Defines the narrowly scoped operation set for a dedicated reconciler profile +such as ``prgs-reconciler``. Close MCP tooling and ancestry gates ship in the +#310 stack; this module validates profile shape only. +""" + +from __future__ import annotations + +import gitea_config + +RECONCILER_REQUIRED_OPERATIONS = ( + "gitea.read", + "gitea.pr.close", +) + +RECONCILER_RECOMMENDED_OPERATIONS = ( + "gitea.pr.comment", + "gitea.issue.comment", + "gitea.issue.close", +) + +RECONCILER_FORBIDDEN_OPERATIONS = ( + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + "gitea.pr.create", + "gitea.branch.push", + "gitea.repo.commit", +) + + +def _normalized_allowed(allowed: list[str]) -> set[str]: + normalized: set[str] = set() + for entry in allowed or []: + try: + normalized.add(gitea_config.normalize_operation(entry)) + except gitea_config.ConfigError: + continue + return normalized + + +def _forbidden_in_allowed(allowed: list[str], ops: tuple[str, ...]) -> list[str]: + """Return forbidden ops that are explicitly listed in *allowed*.""" + allowed_n = _normalized_allowed(allowed) + present: list[str] = [] + for op in ops: + try: + if gitea_config.normalize_operation(op) in allowed_n: + present.append(op) + except gitea_config.ConfigError: + continue + return present + + +def is_reconciler_profile(allowed: list[str], forbidden: list[str]) -> bool: + """Return True when *allowed*/*forbidden* describe a reconciler profile.""" + def can(op: str) -> bool: + return gitea_config.check_operation(op, allowed, forbidden)[0] + + if not can("gitea.pr.close"): + return False + if _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS): + return False + return True + + +def assess_reconciler_profile(allowed: list[str], forbidden: list[str]) -> dict: + """Validate reconciler profile operations (read-only, fail closed).""" + allowed = list(allowed or []) + forbidden = list(forbidden or []) + reasons: list[str] = [] + + for op in RECONCILER_REQUIRED_OPERATIONS: + ok, _ = gitea_config.check_operation(op, allowed, forbidden) + if not ok: + reasons.append(f"missing required operation {op}") + + for op in _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS): + reasons.append(f"forbidden operation must not be allowed: {op}") + + missing_recommended = [ + op for op in RECONCILER_RECOMMENDED_OPERATIONS + if not gitea_config.check_operation(op, allowed, forbidden)[0] + ] + + return { + "is_reconciler_profile": is_reconciler_profile(allowed, forbidden), + "valid": not reasons and is_reconciler_profile(allowed, forbidden), + "reasons": reasons, + "missing_recommended_operations": missing_recommended, + "required_operations": list(RECONCILER_REQUIRED_OPERATIONS), + "forbidden_operations": list(RECONCILER_FORBIDDEN_OPERATIONS), + } \ No newline at end of file diff --git a/tests/test_reconciler_profile.py b/tests/test_reconciler_profile.py new file mode 100644 index 0000000..1c3086d --- /dev/null +++ b/tests/test_reconciler_profile.py @@ -0,0 +1,87 @@ +"""Tests for reconciler profile model (#304).""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import gitea_mcp_server as mcp_server +import migrate_profiles +import reconciler_profile + + +PRGS_RECONCILER_ALLOWED = [ + "gitea.read", + "gitea.pr.comment", + "gitea.issue.comment", + "gitea.issue.close", + "gitea.pr.close", +] +PRGS_RECONCILER_FORBIDDEN = [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.create", + "gitea.branch.push", +] + + +class TestReconcilerProfileModel(unittest.TestCase): + def test_prgs_reconciler_shape_valid(self): + result = reconciler_profile.assess_reconciler_profile( + PRGS_RECONCILER_ALLOWED, + PRGS_RECONCILER_FORBIDDEN, + ) + self.assertTrue(result["is_reconciler_profile"]) + self.assertTrue(result["valid"]) + self.assertEqual(result["reasons"], []) + + def test_author_profile_not_reconciler(self): + allowed = [ + "gitea.read", + "gitea.pr.create", + "gitea.branch.push", + "gitea.issue.comment", + ] + forbidden = ["gitea.pr.approve", "gitea.pr.merge"] + self.assertFalse( + reconciler_profile.is_reconciler_profile(allowed, forbidden) + ) + + def test_reviewer_profile_not_reconciler(self): + allowed = [ + "gitea.read", + "gitea.pr.review", + "gitea.pr.approve", + "gitea.pr.merge", + ] + forbidden = ["gitea.pr.create", "gitea.branch.push"] + self.assertFalse( + reconciler_profile.is_reconciler_profile(allowed, forbidden) + ) + + def test_broad_close_on_author_invalid(self): + allowed = PRGS_RECONCILER_ALLOWED + ["gitea.pr.create"] + result = reconciler_profile.assess_reconciler_profile( + allowed, + PRGS_RECONCILER_FORBIDDEN, + ) + self.assertFalse(result["valid"]) + self.assertFalse(result["is_reconciler_profile"]) + + def test_role_kind_detects_reconciler(self): + role = mcp_server._role_kind( + PRGS_RECONCILER_ALLOWED, + PRGS_RECONCILER_FORBIDDEN, + ) + self.assertEqual(role, "reconciler") + + def test_migrate_infer_role_reconciler(self): + self.assertEqual( + migrate_profiles.infer_role("prgs-reconciler", "prgs-reconciler"), + "reconciler", + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file