feat(profiles): make gitea.branch.delete a documented reconciler-owned capability
Merged-PR source-branch cleanup is reconciler work (task_capability_map maps cleanup_merged_pr_branch -> reconciler / gitea.branch.delete), but the reconciler profile schema, execution-profile docs, and tests never covered the permission, so no configured profile could run the guarded gitea_cleanup_merged_pr_branch path. - reconciler_profile.py: add gitea.branch.delete to RECONCILER_RECOMMENDED_OPERATIONS (not required; not forbidden) - docs/gitea-execution-profiles.md: document merged-branch cleanup ownership, least-privilege constraints, and the no-alias caveat - tests/test_reconciler_profile.py: reconciler profile with branch.delete stays valid and classified reconciler; missing grant is reported as missing-recommended - tests/test_branch_cleanup_guard.py: author- and merger-shaped profiles without gitea.branch.delete fail closed on gitea_cleanup_merged_pr_branch Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -238,11 +238,39 @@ narrow operation set:
|
||||
- `gitea.issue.comment`
|
||||
- `gitea.issue.close`
|
||||
- `gitea.pr.close`
|
||||
- `gitea.branch.delete` (merged-branch cleanup only — see below)
|
||||
|
||||
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
||||
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
||||
`gitea.repo.commit`.
|
||||
|
||||
### Merged-branch cleanup ownership (`gitea.branch.delete`)
|
||||
|
||||
The reconciler is the repository-supported owner of merged-PR source-branch
|
||||
cleanup: `task_capability_map` maps `cleanup_merged_pr_branch` (and
|
||||
`reconciliation_cleanup`) to role `reconciler` with permission
|
||||
`gitea.branch.delete`. Post-merge branch lifecycle is reconciliation work —
|
||||
it happens after the author, reviewer, and merger roles have completed, and
|
||||
it must not be reachable from those roles.
|
||||
|
||||
Least-privilege constraints:
|
||||
|
||||
- `gitea.branch.delete` is granted **only** to reconciler profiles. Author,
|
||||
reviewer, and merger profiles must never hold it; `gitea_delete_branch`
|
||||
and `gitea_cleanup_merged_pr_branch` fail closed on any profile without
|
||||
the permission.
|
||||
- Even with the permission, deletion is only supported through the guarded
|
||||
`gitea_cleanup_merged_pr_branch` path (#514): the PR must be merged, the
|
||||
head an ancestor of the target, the branch not protected
|
||||
(`master`/`main`/`dev`), no open PR may still use the head, and an
|
||||
explicit `CLEANUP MERGED PR <n> BRANCH <branch>` confirmation is
|
||||
required.
|
||||
- Raw `git branch -d` / `git push --delete` cleanup remains blocked by
|
||||
`branch_cleanup_guard` and the final-report validator regardless of
|
||||
profile permissions.
|
||||
- `gitea.branch.delete` has no short alias in `GITEA_OPERATION_ALIASES`;
|
||||
write it fully qualified in `allowed_operations`.
|
||||
|
||||
Launch a static `gitea-reconciler` MCP namespace with
|
||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||
|
||||
@@ -18,6 +18,11 @@ RECONCILER_RECOMMENDED_OPERATIONS = (
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
# Merged-branch cleanup is reconciler-owned (task_capability_map maps
|
||||
# cleanup_merged_pr_branch -> reconciler). The permission is only
|
||||
# exercisable through the guarded gitea_cleanup_merged_pr_branch path
|
||||
# (#514): merged proof, protected-branch refusal, explicit confirmation.
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
|
||||
RECONCILER_FORBIDDEN_OPERATIONS = (
|
||||
|
||||
@@ -93,6 +93,44 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
||||
self.assertEqual(res["required_permission"], "gitea.branch.delete")
|
||||
self.mock_api.assert_not_called()
|
||||
|
||||
def test_author_and_merger_without_delete_authority_fail_closed(self):
|
||||
role_profiles = {
|
||||
"author": [
|
||||
"gitea.read",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
],
|
||||
"merger": ["gitea.read", "gitea.pr.merge"],
|
||||
}
|
||||
for name, allowed in role_profiles.items():
|
||||
with self.subTest(role=name):
|
||||
profile_patch = patch(
|
||||
"mcp_server.get_profile",
|
||||
return_value={
|
||||
"profile_name": name,
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
},
|
||||
)
|
||||
profile_patch.start()
|
||||
try:
|
||||
res = gitea_cleanup_merged_pr_branch(
|
||||
pr_number=487,
|
||||
confirmation="CLEANUP MERGED PR 487 BRANCH feat/branch",
|
||||
branch="feat/branch",
|
||||
remote="prgs",
|
||||
worktree_path="/tmp/repo/branches/cleanup",
|
||||
)
|
||||
finally:
|
||||
profile_patch.stop()
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(
|
||||
res["required_permission"], "gitea.branch.delete"
|
||||
)
|
||||
self.mock_api.assert_not_called()
|
||||
|
||||
def test_root_checkout_cleanup_fails_closed(self):
|
||||
patch(
|
||||
"mcp_server.get_profile",
|
||||
|
||||
@@ -82,6 +82,42 @@ class TestReconcilerProfileModel(unittest.TestCase):
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_branch_delete_is_recommended_for_reconciler(self):
|
||||
self.assertIn(
|
||||
"gitea.branch.delete",
|
||||
reconciler_profile.RECONCILER_RECOMMENDED_OPERATIONS,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"gitea.branch.delete",
|
||||
reconciler_profile.RECONCILER_REQUIRED_OPERATIONS,
|
||||
)
|
||||
|
||||
def test_reconciler_with_branch_delete_stays_valid(self):
|
||||
allowed = PRGS_RECONCILER_ALLOWED + ["gitea.branch.delete"]
|
||||
result = reconciler_profile.assess_reconciler_profile(
|
||||
allowed,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertTrue(result["is_reconciler_profile"])
|
||||
self.assertTrue(result["valid"])
|
||||
self.assertNotIn(
|
||||
"gitea.branch.delete", result["missing_recommended_operations"]
|
||||
)
|
||||
self.assertEqual(
|
||||
mcp_server._role_kind(allowed, PRGS_RECONCILER_FORBIDDEN),
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_reconciler_without_branch_delete_reports_missing_recommended(self):
|
||||
result = reconciler_profile.assess_reconciler_profile(
|
||||
PRGS_RECONCILER_ALLOWED,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertTrue(result["valid"])
|
||||
self.assertIn(
|
||||
"gitea.branch.delete", result["missing_recommended_operations"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user