Clarify and enforce reviewer-vs-merger role boundaries (#483) #486
@@ -22,9 +22,12 @@ launched with exactly one static execution profile:
|
|||||||
| Namespace (MCP server name) | Profile (role) | Typical use |
|
| Namespace (MCP server name) | Profile (role) | Typical use |
|
||||||
|-----------------------------|----------------|-------------|
|
|-----------------------------|----------------|-------------|
|
||||||
| `gitea-author` | an author profile | implement issues, push branches, open PRs, comment |
|
| `gitea-author` | an author profile | implement issues, push branches, open PRs, comment |
|
||||||
| `gitea-reviewer` | a reviewer profile | review, approve/request changes, merge |
|
| `gitea-reviewer` | a reviewer profile | review, approve/request changes |
|
||||||
|
| `gitea-merger` | a merger profile | merge PRs after approval and verification |
|
||||||
| `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304 profile; #310 close tool) |
|
| `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304 profile; #310 close tool) |
|
||||||
|
|
||||||
|
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
|
|
||||||
Properties:
|
Properties:
|
||||||
|
|
||||||
- **One process, one credential.** Each namespace authenticates as exactly
|
- **One process, one credential.** Each namespace authenticates as exactly
|
||||||
@@ -106,6 +109,14 @@ syntax to the client):
|
|||||||
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||||
"GITEA_MCP_PROFILE": "<reviewer-profile-name>"
|
"GITEA_MCP_PROFILE": "<reviewer-profile-name>"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"gitea-merger": {
|
||||||
|
"command": "<path-to>/venv/bin/python3",
|
||||||
|
"args": ["<path-to>/mcp_server.py"],
|
||||||
|
"env": {
|
||||||
|
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||||
|
"GITEA_MCP_PROFILE": "<merger-profile-name>"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,7 +311,8 @@ To make Gitea MCP profile activation and runtime identity state explicit, the fo
|
|||||||
### 2. Dual MCP Namespaces Recommendation
|
### 2. Dual MCP Namespaces Recommendation
|
||||||
For security-sensitive or high-risk tasks, the preferred safety model uses separate, isolated MCP server instances (namespaces/sessions) launched with static profiles:
|
For security-sensitive or high-risk tasks, the preferred safety model uses separate, isolated MCP server instances (namespaces/sessions) launched with static profiles:
|
||||||
- `gitea-author`: Exposes tools configured with author permissions; cannot perform approvals or merges.
|
- `gitea-author`: Exposes tools configured with author permissions; cannot perform approvals or merges.
|
||||||
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews and merges.
|
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews. Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
|
- `gitea-merger`: Exposes tools configured with merger permissions; used for PR merges.
|
||||||
This layout maintains physical separation of credentials and prevents privilege escalation within a single session.
|
This layout maintains physical separation of credentials and prevents privilege escalation within a single session.
|
||||||
This is the model accepted in #139; deployment details, rationale, and client
|
This is the model accepted in #139; deployment details, rationale, and client
|
||||||
setup live in
|
setup live in
|
||||||
|
|||||||
@@ -687,10 +687,10 @@ loop and do **not** substitute WebFetch/Playwright/manual base64.
|
|||||||
### Merge a PR
|
### Merge a PR
|
||||||
|
|
||||||
- **Profile:** merger (allowed to merge; must **not** be the PR author).
|
- **Profile:** merger (allowed to merge; must **not** be the PR author).
|
||||||
- **Steps:** confirm eligibility; require explicit confirmation
|
- **Steps:**
|
||||||
(`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when
|
- Merger workflow starts only after formal approval at current head. Reviewer workflow ends with review decision and separate merger handoff.
|
||||||
Gitea reports the PR mergeable (branch-protection checks satisfied). No force,
|
- Confirm eligibility; require explicit confirmation (`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when Gitea reports the PR mergeable (branch-protection checks satisfied). No force, no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
|
||||||
no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
|
- Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
|
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
|
||||||
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
|
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
|
||||||
|
|
||||||
@@ -779,7 +779,7 @@ an author.
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Review PR (`review_pr`) | reviewer (e.g. `sysadmin` / `prgs-reviewer`) | read, gated review verdicts | commits, pushes, file edits, author comments, merge without eligibility | active profile is an author profile — stop immediately; do **not** switch to author-side fixes unless the operator explicitly re-tasks |
|
| Review PR (`review_pr`) | reviewer (e.g. `sysadmin` / `prgs-reviewer`) | read, gated review verdicts | commits, pushes, file edits, author comments, merge without eligibility | active profile is an author profile — stop immediately; do **not** switch to author-side fixes unless the operator explicitly re-tasks |
|
||||||
| Address PR change requests (`address_pr_change_requests`) | author (e.g. `jcwalker3` / `prgs-author`) | commit/push fixes to the PR branch, PR comment summarizing fixes | review verdicts, approve, request-changes, merge | active profile lacks branch push |
|
| Address PR change requests (`address_pr_change_requests`) | author (e.g. `jcwalker3` / `prgs-author`) | commit/push fixes to the PR branch, PR comment summarizing fixes | review verdicts, approve, request-changes, merge | active profile lacks branch push |
|
||||||
| Merge PR (`merge_pr`) | reviewer/merger | gated merge after eligibility + approval | merging own PR, merging without pinned head match | active profile is an author profile, or any merge gate fails |
|
| Merge PR (`merge_pr`) | merger (e.g. `sysadmin` / `prgs-merger`) | gated merge after eligibility + approval | merging own PR, merging without pinned head match, reviewing PRs | active profile is an author or reviewer-only profile, or any merge gate fails |
|
||||||
| Comment on issue discussion (`comment_issue`) | any profile with `gitea.issue.comment` | issue thread comments | review verdicts, closing via comment | permission missing (`gitea.pr.comment` does **not** imply it) |
|
| Comment on issue discussion (`comment_issue`) | any profile with `gitea.issue.comment` | issue thread comments | review verdicts, closing via comment | permission missing (`gitea.pr.comment` does **not** imply it) |
|
||||||
| Comment on PR (`comment_pr`) | any profile with `gitea.pr.comment` | PR thread comments | review verdicts | permission missing |
|
| Comment on PR (`comment_pr`) | any profile with `gitea.pr.comment` | PR thread comments | review verdicts | permission missing |
|
||||||
| Author implementation (`create_branch`/`push_branch`/`create_pr`) | author | branch, commit, push, open PR | self-review, self-merge | profile lacks the author permissions |
|
| Author implementation (`create_branch`/`push_branch`/`create_pr`) | author | branch, commit, push, open PR | self-review, self-merge | profile lacks the author permissions |
|
||||||
|
|||||||
@@ -16,12 +16,21 @@ bind an authenticated Gitea identity to an allowed operation set.
|
|||||||
- **Allowed:** branch create/push, PR create, issue comment/create/close, repo commit, read.
|
- **Allowed:** branch create/push, PR create, issue comment/create/close, repo commit, read.
|
||||||
- **Forbidden:** PR approve, merge, request_changes.
|
- **Forbidden:** PR approve, merge, request_changes.
|
||||||
|
|
||||||
### Reviewer / merger
|
### Reviewer
|
||||||
|
|
||||||
- **Profile:** `prgs-reviewer`
|
- **Profile:** `prgs-reviewer`
|
||||||
- **Typical identity:** `sysadmin`
|
- **Typical identity:** `sysadmin`
|
||||||
- **Allowed:** PR review/approve/merge/request_changes, issue comment, read.
|
- **Allowed:** PR review/approve/request_changes, issue comment, read.
|
||||||
- **Forbidden:** branch push, PR create, repo commit.
|
- **Forbidden:** branch push, PR create, repo commit, PR merge.
|
||||||
|
|
||||||
|
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
|
|
||||||
|
### Merger
|
||||||
|
|
||||||
|
- **Profile:** `prgs-merger`
|
||||||
|
- **Typical identity:** `sysadmin`
|
||||||
|
- **Allowed:** PR merge, issue comment, read.
|
||||||
|
- **Forbidden:** branch push, PR create, repo commit, PR approve, PR review, PR request_changes.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from validation_status_vocabulary import assess_validation_status_vocabulary
|
|||||||
|
|
||||||
FINAL_REPORT_TASK_KINDS = frozenset({
|
FINAL_REPORT_TASK_KINDS = frozenset({
|
||||||
"review_pr",
|
"review_pr",
|
||||||
|
"merge_pr",
|
||||||
"reconcile_already_landed",
|
"reconcile_already_landed",
|
||||||
"author_issue",
|
"author_issue",
|
||||||
"work_issue",
|
"work_issue",
|
||||||
@@ -40,6 +41,8 @@ FINAL_REPORT_TASK_KINDS = frozenset({
|
|||||||
_TASK_KIND_ALIASES = {
|
_TASK_KIND_ALIASES = {
|
||||||
"review": "review_pr",
|
"review": "review_pr",
|
||||||
"review-merge-pr": "review_pr",
|
"review-merge-pr": "review_pr",
|
||||||
|
"merge": "merge_pr",
|
||||||
|
"merge_pr": "merge_pr",
|
||||||
"reconcile-landed-pr": "reconcile_already_landed",
|
"reconcile-landed-pr": "reconcile_already_landed",
|
||||||
"reconcile_already_landed": "reconcile_already_landed",
|
"reconcile_already_landed": "reconcile_already_landed",
|
||||||
"work-issue": "work_issue",
|
"work-issue": "work_issue",
|
||||||
@@ -48,6 +51,7 @@ _TASK_KIND_ALIASES = {
|
|||||||
|
|
||||||
_HANDOFF_ROLE_BY_TASK = {
|
_HANDOFF_ROLE_BY_TASK = {
|
||||||
"review_pr": "review",
|
"review_pr": "review",
|
||||||
|
"merge_pr": "merger",
|
||||||
"reconcile_already_landed": None,
|
"reconcile_already_landed": None,
|
||||||
"author_issue": "author",
|
"author_issue": "author",
|
||||||
"work_issue": "author",
|
"work_issue": "author",
|
||||||
@@ -1362,6 +1366,17 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
*_SHARED_CLEANUP_PROOF_RULES,
|
*_SHARED_CLEANUP_PROOF_RULES,
|
||||||
_rule_reviewer_stale_head_proof,
|
_rule_reviewer_stale_head_proof,
|
||||||
],
|
],
|
||||||
|
"merge_pr": [
|
||||||
|
_rule_shared_controller_handoff,
|
||||||
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
|
_rule_reviewer_legacy_workspace_mutations,
|
||||||
|
_rule_reviewer_vague_mutations_none,
|
||||||
|
_rule_reviewer_mutation_categories,
|
||||||
|
_rule_reviewer_git_fetch_readonly,
|
||||||
|
_rule_reviewer_linked_issue,
|
||||||
|
_rule_reviewer_stale_head_proof,
|
||||||
|
],
|
||||||
"reconcile_already_landed": [
|
"reconcile_already_landed": [
|
||||||
_rule_reconcile_controller_handoff,
|
_rule_reconcile_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
|
|||||||
@@ -52,8 +52,19 @@
|
|||||||
"execution_profile": "example-reviewer",
|
"execution_profile": "example-reviewer",
|
||||||
"audit_label": "example-reviewer",
|
"audit_label": "example-reviewer",
|
||||||
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
||||||
"allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes", "merge"],
|
"allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes"],
|
||||||
"forbidden_operations": ["branch", "commit", "push", "open_pr"]
|
"forbidden_operations": ["branch", "commit", "push", "open_pr", "merge"]
|
||||||
|
},
|
||||||
|
"example-merger": {
|
||||||
|
"enabled": true,
|
||||||
|
"context": "example-context",
|
||||||
|
"role": "merger",
|
||||||
|
"username": "reviewer-user",
|
||||||
|
"execution_profile": "example-merger",
|
||||||
|
"audit_label": "example-merger",
|
||||||
|
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
||||||
|
"allowed_operations": ["read", "comment", "issue.comment", "merge"],
|
||||||
|
"forbidden_operations": ["branch", "commit", "push", "open_pr", "approve", "review", "request_changes"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
@@ -63,7 +74,8 @@
|
|||||||
"default_owner": "Example-Org",
|
"default_owner": "Example-Org",
|
||||||
"default_repo": "Example-Repo",
|
"default_repo": "Example-Repo",
|
||||||
"default_author_profile": "example-author",
|
"default_author_profile": "example-author",
|
||||||
"default_reviewer_profile": "example-reviewer"
|
"default_reviewer_profile": "example-reviewer",
|
||||||
|
"default_merger_profile": "example-merger"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
+58
-24
@@ -137,13 +137,13 @@ def verify_mutation_authority(remote: str | None, host: str | None = None,
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Reviewer/author role pivot boundary: only an authorized pivot
|
# Reviewer/author role pivot boundary: only an authorized pivot
|
||||||
# (recorded by gitea_activate_profile) may cross author → reviewer.
|
# (recorded by gitea_activate_profile) may cross author -> reviewer/merger.
|
||||||
if (required_role == "reviewer"
|
if (required_role in ("reviewer", "merger")
|
||||||
and "author" in str(data.get("initial_profile")).lower()
|
and "author" in str(data.get("initial_profile")).lower()
|
||||||
and "reviewer" in str(active_profile).lower()):
|
and ("reviewer" in str(active_profile).lower() or "merger" in str(active_profile).lower())):
|
||||||
if not data.get("role_pivot_authorized"):
|
if not data.get("role_pivot_authorized"):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Attempted reviewer mutation from author session without "
|
f"Attempted {required_role} mutation from author session without "
|
||||||
"authorized role pivot (fail closed)"
|
"authorized role pivot (fail closed)"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -562,7 +562,7 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
|
|||||||
|
|
||||||
Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
|
Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
|
||||||
is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
|
is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
|
||||||
(#468). Non-author namespaces use dedicated workspace env vars (#510).
|
(#468, #483). Non-author namespaces use dedicated workspace env vars (#510).
|
||||||
|
|
||||||
The exemption honours BOTH the effective workspace role and the actual
|
The exemption honours BOTH the effective workspace role and the actual
|
||||||
profile role (#540). ``comment_issue`` preflight stamps
|
profile role (#540). ``comment_issue`` preflight stamps
|
||||||
@@ -2279,13 +2279,23 @@ def gitea_check_pr_eligibility(
|
|||||||
# ("gitea.pr.merge") always match each other and never cross services.
|
# ("gitea.pr.merge") always match each other and never cross services.
|
||||||
allowed = profile["allowed_operations"]
|
allowed = profile["allowed_operations"]
|
||||||
forbidden = profile["forbidden_operations"]
|
forbidden = profile["forbidden_operations"]
|
||||||
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
|
active_role = _role_kind(allowed, forbidden)
|
||||||
|
if action == "merge" and active_role == "reviewer":
|
||||||
|
op_ok = False
|
||||||
|
op_reason = "reviewer-cannot-merge"
|
||||||
|
else:
|
||||||
|
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
|
||||||
|
|
||||||
if not op_ok:
|
if not op_ok:
|
||||||
if op_reason == "no-allowed-operations":
|
if op_reason == "no-allowed-operations":
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"profile has no configured allowed operations (fail closed)")
|
"profile has no configured allowed operations (fail closed)")
|
||||||
elif op_reason == "forbidden":
|
elif op_reason == "forbidden":
|
||||||
reasons.append(f"profile forbids '{action}'")
|
reasons.append(f"profile forbids '{action}'")
|
||||||
|
elif op_reason == "reviewer-cannot-merge":
|
||||||
|
reasons.append(
|
||||||
|
"Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head."
|
||||||
|
)
|
||||||
elif op_reason == "invalid-forbidden-entry":
|
elif op_reason == "invalid-forbidden-entry":
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"profile has an unrecognized forbidden operation entry "
|
"profile has an unrecognized forbidden operation entry "
|
||||||
@@ -2309,21 +2319,23 @@ def gitea_check_pr_eligibility(
|
|||||||
missing_perm)
|
missing_perm)
|
||||||
|
|
||||||
req_profile = None
|
req_profile = None
|
||||||
|
role_noun = "reviewer"
|
||||||
if action in ("approve", "request_changes", "review"):
|
if action in ("approve", "request_changes", "review"):
|
||||||
req_profile = "A profile with reviewer role permissions (allowing approve/merge/review, and forbidding author operations)"
|
req_profile = "A profile with reviewer role permissions (allowing approve/review, and forbidding author operations)"
|
||||||
elif action == "merge":
|
elif action == "merge":
|
||||||
req_profile = "A profile with reviewer role permissions and explicit merge permission"
|
req_profile = "A profile with merger role permissions (allowing merge, and forbidding author operations)"
|
||||||
|
role_noun = "merger"
|
||||||
result["required_profile"] = req_profile
|
result["required_profile"] = req_profile
|
||||||
|
|
||||||
switching_supported = gitea_config.is_runtime_switching_enabled()
|
switching_supported = gitea_config.is_runtime_switching_enabled()
|
||||||
if switching_supported:
|
if switching_supported:
|
||||||
result["fixable_by_profile_switch"] = True
|
result["fixable_by_profile_switch"] = True
|
||||||
result["requires_different_namespace"] = False
|
result["requires_different_namespace"] = False
|
||||||
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
|
safe_step = f"Switch to a {role_noun} profile by calling gitea_activate_profile with a profile that allows {action}."
|
||||||
else:
|
else:
|
||||||
result["fixable_by_profile_switch"] = False
|
result["fixable_by_profile_switch"] = False
|
||||||
result["requires_different_namespace"] = True
|
result["requires_different_namespace"] = True
|
||||||
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
|
safe_step = f"Switch to the {role_noun} MCP session (e.g. gitea-{role_noun}) which has {role_noun} permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a {role_noun} profile."
|
||||||
|
|
||||||
result["safe_next_step"] = safe_step
|
result["safe_next_step"] = safe_step
|
||||||
return result
|
return result
|
||||||
@@ -4039,6 +4051,14 @@ def gitea_merge_pr(
|
|||||||
_verify_role_mutation_workspace(
|
_verify_role_mutation_workspace(
|
||||||
remote, worktree_path=worktree_path, task="merge_pr"
|
remote, worktree_path=worktree_path, task="merge_pr"
|
||||||
)
|
)
|
||||||
|
allowed = get_profile()["allowed_operations"]
|
||||||
|
forbidden = get_profile()["forbidden_operations"]
|
||||||
|
active_role = _role_kind(allowed, forbidden)
|
||||||
|
if active_role == "reviewer":
|
||||||
|
raise RuntimeError(
|
||||||
|
"Reviewer profile cannot merge. Use a merger profile/session "
|
||||||
|
"after formal approval at current head."
|
||||||
|
)
|
||||||
workflow_blockers = _review_workflow_load_gate_reasons()
|
workflow_blockers = _review_workflow_load_gate_reasons()
|
||||||
do = (do or "").strip().lower()
|
do = (do or "").strip().lower()
|
||||||
result = {
|
result = {
|
||||||
@@ -4055,6 +4075,10 @@ def gitea_merge_pr(
|
|||||||
"merge_result": None,
|
"merge_result": None,
|
||||||
"merge_commit": None,
|
"merge_commit": None,
|
||||||
"reasons": [],
|
"reasons": [],
|
||||||
|
"active_profile": get_profile()["profile_name"],
|
||||||
|
"role_kind": active_role,
|
||||||
|
"merge_capability_source": "profile" if "gitea.pr.merge" in allowed else "config",
|
||||||
|
"explicit_operator_auth": confirmation,
|
||||||
}
|
}
|
||||||
reasons = result["reasons"]
|
reasons = result["reasons"]
|
||||||
if workflow_blockers:
|
if workflow_blockers:
|
||||||
@@ -4224,7 +4248,7 @@ def gitea_merge_pr(
|
|||||||
# A profile/identity flip or side-channel override between preflight
|
# A profile/identity flip or side-channel override between preflight
|
||||||
# and merge fails closed here.
|
# and merge fails closed here.
|
||||||
try:
|
try:
|
||||||
verify_mutation_authority(remote, host, required_role="reviewer",
|
verify_mutation_authority(remote, host, required_role="merger",
|
||||||
active_identity=auth_user)
|
active_identity=auth_user)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
reasons.append(str(e))
|
reasons.append(str(e))
|
||||||
@@ -6543,26 +6567,28 @@ def _role_kind(allowed, forbidden) -> str:
|
|||||||
"""Classify the active profile from its normalized operations.
|
"""Classify the active profile from its normalized operations.
|
||||||
|
|
||||||
'reconciler' can close already-landed PRs without review/author powers;
|
'reconciler' can close already-landed PRs without review/author powers;
|
||||||
'author' can create PRs / push branches; 'reviewer' can approve/merge;
|
'author' can create PRs / push branches; 'reviewer' can approve;
|
||||||
'reconciler' can close already-landed PRs via ``gitea.pr.close`` without
|
'merger' can merge PRs;
|
||||||
review/author powers; 'mixed' can do both (a config smell — the
|
'mixed' can do multiple (a config smell); 'limited' can do neither.
|
||||||
reviewer-deadlock invariant forbids it in v2 configs); 'limited' can do
|
|
||||||
neither.
|
|
||||||
"""
|
"""
|
||||||
if reconciler_profile.is_reconciler_profile(allowed, forbidden):
|
if reconciler_profile.is_reconciler_profile(allowed, forbidden):
|
||||||
return "reconciler"
|
return "reconciler"
|
||||||
def can(op):
|
def can(op):
|
||||||
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||||
review = can("gitea.pr.approve") or can("gitea.pr.merge")
|
can_merge = can("gitea.pr.merge")
|
||||||
|
can_approve = can("gitea.pr.approve")
|
||||||
author = can("gitea.pr.create") or can("gitea.branch.push")
|
author = can("gitea.pr.create") or can("gitea.branch.push")
|
||||||
reconciler = (
|
reconciler = (
|
||||||
can("gitea.pr.close")
|
can("gitea.pr.close")
|
||||||
and not review
|
and not can_approve
|
||||||
|
and not can_merge
|
||||||
and not author
|
and not author
|
||||||
)
|
)
|
||||||
if review and author:
|
if (can_approve or can_merge) and author:
|
||||||
return "mixed"
|
return "mixed"
|
||||||
if review:
|
if can_merge:
|
||||||
|
return "merger"
|
||||||
|
if can_approve:
|
||||||
return "reviewer"
|
return "reviewer"
|
||||||
if reconciler:
|
if reconciler:
|
||||||
return "reconciler"
|
return "reconciler"
|
||||||
@@ -7080,12 +7106,20 @@ def mcp_get_control_plane_guide(
|
|||||||
"gitea.issue.comment (separate from gitea.pr.comment).")
|
"gitea.issue.comment (separate from gitea.pr.comment).")
|
||||||
elif role == "reviewer":
|
elif role == "reviewer":
|
||||||
guidance.append(
|
guidance.append(
|
||||||
"Reviewer profile: review/approve/merge may proceed ONLY after "
|
"Reviewer profile: review/approve may proceed ONLY after "
|
||||||
"gitea_check_pr_eligibility passes and the PR head SHA is "
|
"gitea_check_pr_eligibility passes and the PR head SHA is "
|
||||||
"pinned (expected_head_sha); the PR author must be a different "
|
"pinned (expected_head_sha); the PR author must be a different "
|
||||||
"user, and merging additionally requires explicit operator "
|
"user. Reviewer profiles cannot merge PRs. "
|
||||||
"authorization plus the 'MERGE PR <n>' confirmation. "
|
"PR comments do not imply issue comments. "
|
||||||
"PR comments do not imply issue comments.")
|
"Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
|
||||||
|
elif role == "merger":
|
||||||
|
guidance.append(
|
||||||
|
"Merger profile: merge may proceed ONLY after formal approval at "
|
||||||
|
"current head, gitea_check_pr_eligibility passes, and the PR head "
|
||||||
|
"SHA is pinned (expected_head_sha); the PR author must be a different "
|
||||||
|
"user, and merging requires explicit operator authorization plus the "
|
||||||
|
"'MERGE PR <n>' confirmation. "
|
||||||
|
"Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
|
||||||
elif role == "mixed":
|
elif role == "mixed":
|
||||||
guidance.append(
|
guidance.append(
|
||||||
"WARNING: this profile allows both authoring and "
|
"WARNING: this profile allows both authoring and "
|
||||||
|
|||||||
+15
-3
@@ -2243,6 +2243,18 @@ HANDOFF_ROLE_FIELDS = {
|
|||||||
("Linked issue status", ("linked issue status", "linked issue")),
|
("Linked issue status", ("linked issue status", "linked issue")),
|
||||||
("Cleanup status", ("cleanup status", "cleanup")),
|
("Cleanup status", ("cleanup status", "cleanup")),
|
||||||
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
||||||
|
"merger": (
|
||||||
|
("Selected PR", ("selected pr",)),
|
||||||
|
("Pinned reviewed head", ("pinned reviewed head", "pinned head")),
|
||||||
|
("Active profile", ("active profile",)),
|
||||||
|
("Role kind", ("role kind",)),
|
||||||
|
("Merge capability source", ("merge capability source",)),
|
||||||
|
("Explicit operator authorization", ("explicit operator authorization", "operator authorization", "authorization")),
|
||||||
|
("Expected head SHA", ("expected head sha", "expected head")),
|
||||||
|
("Approval at current head", ("approval at current head", "approval")),
|
||||||
|
("Merge result", ("merge result",)),
|
||||||
|
("Cleanup status", ("cleanup status", "cleanup")),
|
||||||
|
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
||||||
"author": (
|
"author": (
|
||||||
("Selected issue", ("selected issue",)),
|
("Selected issue", ("selected issue",)),
|
||||||
("Issue lock proof", ("issue lock proof", "lock before diff")),
|
("Issue lock proof", ("issue lock proof", "lock before diff")),
|
||||||
@@ -2417,8 +2429,8 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
|
|||||||
field for field in required
|
field for field in required
|
||||||
if field[0] not in ("Workspace mutations", "Mutations")
|
if field[0] not in ("Workspace mutations", "Mutations")
|
||||||
]
|
]
|
||||||
if role == "review":
|
if role in ("review", "merger"):
|
||||||
# Issue #320: reviewer handoffs use the precise mutation categories
|
# Issue #320: reviewer and merger handoffs use the precise mutation categories
|
||||||
# in HANDOFF_REVIEW_MUTATION_FIELDS instead of the legacy ambiguous
|
# in HANDOFF_REVIEW_MUTATION_FIELDS instead of the legacy ambiguous
|
||||||
# "Workspace mutations" field, which is rejected below.
|
# "Workspace mutations" field, which is rejected below.
|
||||||
required = [
|
required = [
|
||||||
@@ -2431,7 +2443,7 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
|
|||||||
"downgraded": True,
|
"downgraded": True,
|
||||||
"missing_fields": [],
|
"missing_fields": [],
|
||||||
"reasons": [
|
"reasons": [
|
||||||
"review handoff must not include legacy "
|
"review or merger handoff must not include legacy "
|
||||||
"'Workspace mutations' field; report the precise "
|
"'Workspace mutations' field; report the precise "
|
||||||
"mutation categories instead (issue #320)"
|
"mutation categories instead (issue #320)"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Template: merge a PR (eligible reviewer only)
|
# Template: merge a PR (eligible merger only)
|
||||||
|
|
||||||
Copy, fill the `<...>` fields, and paste as the task prompt.
|
Copy, fill the `<...>` fields, and paste as the task prompt.
|
||||||
|
|
||||||
@@ -14,8 +14,9 @@ Rules (llm-project-workflow):
|
|||||||
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
|
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
|
||||||
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
|
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
|
||||||
and is blocked when it disagrees with the local git remote URL.
|
and is blocked when it disagrees with the local git remote URL.
|
||||||
- Only an eligible, NON-author reviewer merges. If authenticated user == PR
|
- Only an eligible, NON-author merger merges. If authenticated user == PR
|
||||||
author → STOP.
|
author → STOP.
|
||||||
|
- Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
|
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
|
||||||
- No force-merge, no bypassing branch protections.
|
- No force-merge, no bypassing branch protections.
|
||||||
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
|
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
|
||||||
@@ -63,10 +64,12 @@ Then run the cleanup template (worktree-cleanup.md) in a reconciler session:
|
|||||||
- fetch/prune; confirm main checkout is clean and current (0 0).
|
- fetch/prune; confirm main checkout is clean and current (0 0).
|
||||||
|
|
||||||
Handoff: end with a section titled exactly `Controller Handoff` per SKILL.md
|
Handoff: end with a section titled exactly `Controller Handoff` per SKILL.md
|
||||||
§K (long form — a merge is always high-risk), including the review/merge role
|
§K (long form — a merge is always high-risk), including the merger role
|
||||||
fields (Selected PR, Reviewer eligibility, Pinned reviewed head, Review
|
fields (Selected PR, Merger eligibility, Pinned reviewed head, Review
|
||||||
decision, Merge result, Linked issue status, Cleanup status) plus: merge
|
decision, Merge result, Linked issue status, Cleanup status) plus: merge
|
||||||
commit, PR metadata state/merged flag/hash, remote master hash, and the
|
commit, PR metadata state/merged flag/hash, remote master hash, and the
|
||||||
post-merge verification method used & verification results. Reports missing
|
post-merge verification method used & verification results. Reports missing
|
||||||
the handoff are downgraded (review_proofs.assess_controller_handoff).
|
the handoff are downgraded (review_proofs.assess_controller_handoff).
|
||||||
|
|
||||||
|
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -110,8 +110,9 @@ Steps:
|
|||||||
- base branch unchanged
|
- base branch unchanged
|
||||||
- no undismissed REQUEST_CHANGES / blocking review state left unaccounted
|
- no undismissed REQUEST_CHANGES / blocking review state left unaccounted
|
||||||
If anything moved → STOP, re-pin, re-validate before any verdict.
|
If anything moved → STOP, re-pin, re-validate before any verdict.
|
||||||
10. Post the review verdict: approve only if scope is clean and checks pass;
|
10. Post the review verdict: approve only if scope is clean and checks pass;
|
||||||
otherwise request changes with specifics. Never merge from this review step.
|
otherwise request changes with specifics. Never merge from this review step.
|
||||||
|
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
Include a "Review Metadata" block (attribution only — docs/llm-agent-sha.md):
|
Include a "Review Metadata" block (attribution only — docs/llm-agent-sha.md):
|
||||||
|
|
||||||
Review Metadata:
|
Review Metadata:
|
||||||
@@ -128,6 +129,8 @@ Pinned reviewed head, Review decision, Merge result, Linked issue status,
|
|||||||
Cleanup status. If you could not merge, name the exact gate. Reports missing
|
Cleanup status. If you could not merge, name the exact gate. Reports missing
|
||||||
the handoff are downgraded (review_proofs.assess_controller_handoff).
|
the handoff are downgraded (review_proofs.assess_controller_handoff).
|
||||||
|
|
||||||
|
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
|
|
||||||
Baseline failure proof (#533): if a validation command exits non-zero, do NOT
|
Baseline failure proof (#533): if a validation command exits non-zero, do NOT
|
||||||
call it a clean pass. Only label a failure "baseline"/"pre-existing" with
|
call it a clean pass. Only label a failure "baseline"/"pre-existing" with
|
||||||
pre-merge proof — the failure reproduced on the PR's pre-merge base commit, or a
|
pre-merge proof — the failure reproduced on the PR's pre-merge base commit, or a
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
},
|
},
|
||||||
"merge_pr": {
|
"merge_pr": {
|
||||||
"permission": "gitea.pr.merge",
|
"permission": "gitea.pr.merge",
|
||||||
"role": "reviewer",
|
"role": "merger",
|
||||||
},
|
},
|
||||||
"adopt_merger_pr_lease": {
|
"adopt_merger_pr_lease": {
|
||||||
"permission": "gitea.pr.comment",
|
"permission": "gitea.pr.comment",
|
||||||
|
|||||||
@@ -435,6 +435,40 @@ class ValidateConfigTests(unittest.TestCase):
|
|||||||
with open(example, encoding="utf-8") as fh:
|
with open(example, encoding="utf-8") as fh:
|
||||||
self.assertEqual(gitea_config.validate_config(json.load(fh)), [])
|
self.assertEqual(gitea_config.validate_config(json.load(fh)), [])
|
||||||
|
|
||||||
|
def test_repo_example_file_reviewer_vs_merger_boundaries(self):
|
||||||
|
example_path = __import__("pathlib").Path(__file__).resolve().parent.parent \
|
||||||
|
/ "gitea-mcp.v2-contexts.example.json"
|
||||||
|
with open(example_path, encoding="utf-8") as fh:
|
||||||
|
cfg = json.load(fh)
|
||||||
|
|
||||||
|
# 1. Config includes separate reviewer and merger examples
|
||||||
|
self.assertIn("example-reviewer", cfg["profiles"])
|
||||||
|
self.assertIn("example-merger", cfg["profiles"])
|
||||||
|
|
||||||
|
reviewer = cfg["profiles"]["example-reviewer"]
|
||||||
|
merger = cfg["profiles"]["example-merger"]
|
||||||
|
|
||||||
|
# 2. Example reviewer config does not include merge capability
|
||||||
|
reviewer_allowed = reviewer.get("allowed_operations") or []
|
||||||
|
reviewer_forbidden = reviewer.get("forbidden_operations") or []
|
||||||
|
|
||||||
|
self.assertNotIn("merge", reviewer_allowed)
|
||||||
|
self.assertNotIn("gitea.pr.merge", reviewer_allowed)
|
||||||
|
self.assertIn("merge", reviewer_forbidden)
|
||||||
|
|
||||||
|
# 3. Example reviewer can resolve review task, cannot resolve merge task
|
||||||
|
# 4. Example merger can resolve merge task
|
||||||
|
rev_review_ok, _ = gitea_config.check_operation("gitea.pr.review", reviewer_allowed, reviewer_forbidden)
|
||||||
|
rev_merge_ok, _ = gitea_config.check_operation("gitea.pr.merge", reviewer_allowed, reviewer_forbidden)
|
||||||
|
|
||||||
|
merg_allowed = merger.get("allowed_operations") or []
|
||||||
|
merg_forbidden = merger.get("forbidden_operations") or []
|
||||||
|
merg_merge_ok, _ = gitea_config.check_operation("gitea.pr.merge", merg_allowed, merg_forbidden)
|
||||||
|
|
||||||
|
self.assertTrue(rev_review_ok, "example-reviewer must allow review")
|
||||||
|
self.assertFalse(rev_merge_ok, "example-reviewer must not allow merge")
|
||||||
|
self.assertTrue(merg_merge_ok, "example-merger must allow merge")
|
||||||
|
|
||||||
def test_broken_contexts_config_reports_problems(self):
|
def test_broken_contexts_config_reports_problems(self):
|
||||||
data = contexts_config()
|
data = contexts_config()
|
||||||
data["profiles"]["prgs-author"]["context"] = "nope"
|
data["profiles"]["prgs-author"]["context"] = "nope"
|
||||||
|
|||||||
@@ -594,8 +594,100 @@ class TestEntryPoint(unittest.TestCase):
|
|||||||
|
|
||||||
def test_supported_task_kinds_include_review_and_reconcile(self):
|
def test_supported_task_kinds_include_review_and_reconcile(self):
|
||||||
self.assertIn("review_pr", FINAL_REPORT_TASK_KINDS)
|
self.assertIn("review_pr", FINAL_REPORT_TASK_KINDS)
|
||||||
|
self.assertIn("merge_pr", FINAL_REPORT_TASK_KINDS)
|
||||||
self.assertIn("reconcile_already_landed", FINAL_REPORT_TASK_KINDS)
|
self.assertIn("reconcile_already_landed", FINAL_REPORT_TASK_KINDS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergePrRules(unittest.TestCase):
|
||||||
|
def test_clean_merger_report_passes(self):
|
||||||
|
lines = [
|
||||||
|
"## Controller Handoff",
|
||||||
|
"",
|
||||||
|
"- Task: merge PR #203",
|
||||||
|
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
"- Role: merger",
|
||||||
|
"- Identity: sysadmin / prgs-merger",
|
||||||
|
"- Issue/PR: #182 / PR #203",
|
||||||
|
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Files changed: review_proofs.py",
|
||||||
|
"- Validation: pytest in branches/review-203",
|
||||||
|
"- Mutations: merge completed",
|
||||||
|
"- File edits by reviewer: none",
|
||||||
|
"- Worktree/index mutations: none",
|
||||||
|
"- Git ref mutations: git fetch prgs master",
|
||||||
|
"- MCP/Gitea mutations: merge completed",
|
||||||
|
"- Review mutations: none",
|
||||||
|
"- Merge mutations: merged PR #203",
|
||||||
|
"- Cleanup mutations: none",
|
||||||
|
"- External-state mutations: none",
|
||||||
|
"- Read-only diagnostics: metadata check",
|
||||||
|
"- Current status: PR merged",
|
||||||
|
"- Blockers: none",
|
||||||
|
"- Next: none",
|
||||||
|
"- Safety: review and merge are separate roles",
|
||||||
|
"- Selected PR: #203",
|
||||||
|
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Push occurred during validation: no",
|
||||||
|
"- Active profile: prgs-merger",
|
||||||
|
"- Role kind: merger",
|
||||||
|
"- Merge capability source: profile",
|
||||||
|
"- Explicit operator authorization: MERGE PR 203",
|
||||||
|
"- Expected head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Approval at current head: yes",
|
||||||
|
"- Merge result: PR merged successfully",
|
||||||
|
"- Cleanup status: complete",
|
||||||
|
]
|
||||||
|
report = "\n".join(lines)
|
||||||
|
result = assess_final_report_validator(
|
||||||
|
report,
|
||||||
|
"merge_pr",
|
||||||
|
)
|
||||||
|
self.assertEqual(result["grade"], "A")
|
||||||
|
self.assertFalse(result["blocked"])
|
||||||
|
|
||||||
|
def test_missing_merger_fields_blocks(self):
|
||||||
|
lines = [
|
||||||
|
"## Controller Handoff",
|
||||||
|
"",
|
||||||
|
"- Task: merge PR #203",
|
||||||
|
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
"- Role: merger",
|
||||||
|
"- Identity: sysadmin / prgs-merger",
|
||||||
|
"- Issue/PR: #182 / PR #203",
|
||||||
|
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Files changed: review_proofs.py",
|
||||||
|
"- Validation: pytest in branches/review-203",
|
||||||
|
"- Mutations: merge completed",
|
||||||
|
"- File edits by reviewer: none",
|
||||||
|
"- Worktree/index mutations: none",
|
||||||
|
"- Git ref mutations: git fetch prgs master",
|
||||||
|
"- MCP/Gitea mutations: merge completed",
|
||||||
|
"- Review mutations: none",
|
||||||
|
"- Merge mutations: merged PR #203",
|
||||||
|
"- Cleanup mutations: none",
|
||||||
|
"- External-state mutations: none",
|
||||||
|
"- Read-only diagnostics: metadata check",
|
||||||
|
"- Current status: PR merged",
|
||||||
|
"- Blockers: none",
|
||||||
|
"- Next: none",
|
||||||
|
"- Safety: review and merge are separate roles",
|
||||||
|
"- Selected PR: #203",
|
||||||
|
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Push occurred during validation: no",
|
||||||
|
]
|
||||||
|
report = "\n".join(lines)
|
||||||
|
result = assess_final_report_validator(
|
||||||
|
report,
|
||||||
|
"merge_pr",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["downgraded"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -861,6 +861,19 @@ class TestMergePR(unittest.TestCase):
|
|||||||
|
|
||||||
# -- identity / profile / eligibility fail-closed -------------------------
|
# -- identity / profile / eligibility fail-closed -------------------------
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_reviewer_profile_cannot_merge(self, _auth, mock_api):
|
||||||
|
env = {"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "read,approve"}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_merge_pr(
|
||||||
|
pr_number=8, confirmation=self._confirm(8), remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertIn("Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head.", str(ctx.exception))
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_self_author_cannot_merge(self, _auth, mock_api):
|
def test_self_author_cannot_merge(self, _auth, mock_api):
|
||||||
@@ -902,14 +915,13 @@ class TestMergePR(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
|
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
|
||||||
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
gitea_merge_pr(
|
||||||
self.assertFalse(r["performed"])
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertIn("profile is not allowed to merge", r["reasons"])
|
self.assertIn("Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head.", str(ctx.exception))
|
||||||
self._assert_no_merge_call(mock_api)
|
self._assert_no_merge_call(mock_api)
|
||||||
|
|
||||||
# -- PR state / mergeability ----------------------------------------------
|
# -- PR state / mergeability ----------------------------------------------
|
||||||
|
|||||||
@@ -33,8 +33,15 @@ REVIEWER_ENV = {
|
|||||||
"GITEA_PROFILE_NAME": "reviewer-test",
|
"GITEA_PROFILE_NAME": "reviewer-test",
|
||||||
"GITEA_ALLOWED_OPERATIONS":
|
"GITEA_ALLOWED_OPERATIONS":
|
||||||
"gitea.read,gitea.pr.review,gitea.pr.comment,gitea.pr.approve,"
|
"gitea.read,gitea.pr.review,gitea.pr.comment,gitea.pr.approve,"
|
||||||
"gitea.pr.request_changes,gitea.pr.merge",
|
"gitea.pr.request_changes",
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push",
|
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push,gitea.pr.merge",
|
||||||
|
}
|
||||||
|
|
||||||
|
MERGER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "merger-test",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS":
|
||||||
|
"gitea.read,gitea.pr.comment,gitea.pr.merge",
|
||||||
|
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push,gitea.pr.approve",
|
||||||
}
|
}
|
||||||
|
|
||||||
EXPECTED_SKILLS = [
|
EXPECTED_SKILLS = [
|
||||||
@@ -88,6 +95,15 @@ class TestControlPlaneGuide(GuideTestBase):
|
|||||||
self.assertIn("eligibility", blob)
|
self.assertIn("eligibility", blob)
|
||||||
self.assertIn("pinned", blob)
|
self.assertIn("pinned", blob)
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "merger-bot"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_merger_profile_guidance(self, _auth, _api):
|
||||||
|
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
||||||
|
g = mcp_get_control_plane_guide(remote="prgs")
|
||||||
|
self.assertEqual(g["profile"]["role_kind"], "merger")
|
||||||
|
blob = " ".join(g["guidance"]).lower()
|
||||||
|
self.assertIn("merge", blob)
|
||||||
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=None)
|
@patch("mcp_server.get_auth_header", return_value=None)
|
||||||
def test_unresolved_identity_instructs_stop(self, _auth):
|
def test_unresolved_identity_instructs_stop(self, _auth):
|
||||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||||
|
|||||||
@@ -44,9 +44,19 @@ CONFIG_RESOLVER = {
|
|||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
"username": "reviewer-user",
|
"username": "reviewer-user",
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
|
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.issue.comment"],
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||||
"execution_profile": "reviewer-profile"
|
"execution_profile": "reviewer-profile"
|
||||||
|
},
|
||||||
|
"merger-profile": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "merger",
|
||||||
|
"username": "merger-user",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.merge", "gitea.issue.comment"],
|
||||||
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
|
||||||
|
"execution_profile": "merger-profile"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -83,6 +93,7 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
"GITEA_MCP_PROFILE": profile,
|
"GITEA_MCP_PROFILE": profile,
|
||||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||||
|
"GITEA_TOKEN_MERGER": "merger-pass",
|
||||||
}
|
}
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||||
@@ -231,9 +242,9 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
res = mcp_server.gitea_resolve_task_capability(task="merge_pr", remote="prgs")
|
res = mcp_server.gitea_resolve_task_capability(task="merge_pr", remote="prgs")
|
||||||
self.assertFalse(res["allowed_in_current_session"])
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
self.assertIn("reviewer", res["exact_safe_next_action"].lower())
|
self.assertIn("merger", res["exact_safe_next_action"].lower())
|
||||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.merge")
|
self.assertEqual(res["required_operation_permission"], "gitea.pr.merge")
|
||||||
self.assertEqual(res["required_role_kind"], "reviewer")
|
self.assertEqual(res["required_role_kind"], "merger")
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_self_review_blocked_structured(self, mock_api):
|
def test_self_review_blocked_structured(self, mock_api):
|
||||||
|
|||||||
@@ -43,9 +43,19 @@ CONFIG_SWITCHING_DISABLED = {
|
|||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
"username": "reviewer-user",
|
"username": "reviewer-user",
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.approve", "gitea.pr.merge"],
|
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"],
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||||
"execution_profile": "reviewer-profile"
|
"execution_profile": "reviewer-profile"
|
||||||
|
},
|
||||||
|
"merger-profile": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "merger",
|
||||||
|
"username": "merger-user",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.merge"],
|
||||||
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
|
||||||
|
"execution_profile": "merger-profile"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -90,6 +100,7 @@ class TestRuntimeClarity(unittest.TestCase):
|
|||||||
"GITEA_MCP_REVEAL_ENDPOINTS": reveal,
|
"GITEA_MCP_REVEAL_ENDPOINTS": reveal,
|
||||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||||
|
"GITEA_TOKEN_MERGER": "merger-pass",
|
||||||
}
|
}
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -125,7 +136,7 @@ class TestRuntimeClarity(unittest.TestCase):
|
|||||||
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
||||||
)
|
)
|
||||||
self.assertFalse(merge_entry["allowed_in_current_session"])
|
self.assertFalse(merge_entry["allowed_in_current_session"])
|
||||||
self.assertIn("reviewer-profile", merge_entry["matching_configured_profiles"])
|
self.assertIn("merger-profile", merge_entry["matching_configured_profiles"])
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"mcp_server.assess_preflight_status",
|
"mcp_server.assess_preflight_status",
|
||||||
@@ -144,6 +155,30 @@ class TestRuntimeClarity(unittest.TestCase):
|
|||||||
caps = ctx["session_capabilities"]
|
caps = ctx["session_capabilities"]
|
||||||
self.assertFalse(caps["can_author_prs"])
|
self.assertFalse(caps["can_author_prs"])
|
||||||
self.assertFalse(caps["can_create_issues"])
|
self.assertFalse(caps["can_create_issues"])
|
||||||
|
self.assertTrue(caps["can_review_prs"])
|
||||||
|
self.assertFalse(caps["can_merge_prs"])
|
||||||
|
merge_entry = next(
|
||||||
|
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
||||||
|
)
|
||||||
|
self.assertFalse(merge_entry["allowed_in_current_session"])
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"mcp_server.assess_preflight_status",
|
||||||
|
return_value={"preflight_ready": True, "preflight_block_reasons": []},
|
||||||
|
)
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "merger-user"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value="token merger-pass")
|
||||||
|
def test_get_runtime_context_merger(self, _auth, _api, _preflight):
|
||||||
|
with patch.dict(os.environ, self._env("merger-profile"), clear=True):
|
||||||
|
ctx = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
||||||
|
self.assertEqual(ctx["active_profile"], "merger-profile")
|
||||||
|
self.assertEqual(ctx["authenticated_username"], "merger-user")
|
||||||
|
self.assertTrue(ctx["review_merge_allowed"])
|
||||||
|
self.assertEqual(ctx["suggested_fix"], "none")
|
||||||
|
self.assertEqual(ctx["safe_next_action"], "None; ready for operations.")
|
||||||
|
caps = ctx["session_capabilities"]
|
||||||
|
self.assertFalse(caps["can_author_prs"])
|
||||||
|
self.assertFalse(caps["can_create_issues"])
|
||||||
self.assertFalse(caps["can_review_prs"])
|
self.assertFalse(caps["can_review_prs"])
|
||||||
self.assertTrue(caps["can_merge_prs"])
|
self.assertTrue(caps["can_merge_prs"])
|
||||||
merge_entry = next(
|
merge_entry = next(
|
||||||
@@ -160,7 +195,7 @@ class TestRuntimeClarity(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, self._env("author-profile", reveal="0"), clear=True):
|
with patch.dict(os.environ, self._env("author-profile", reveal="0"), clear=True):
|
||||||
res = mcp_server.gitea_list_profiles()
|
res = mcp_server.gitea_list_profiles()
|
||||||
profiles = res["profiles"]
|
profiles = res["profiles"]
|
||||||
self.assertEqual(len(profiles), 2)
|
self.assertEqual(len(profiles), 3)
|
||||||
|
|
||||||
author_prof = next(p for p in profiles if p["name"] == "author-profile")
|
author_prof = next(p for p in profiles if p["name"] == "author-profile")
|
||||||
self.assertTrue(author_prof["is_active"])
|
self.assertTrue(author_prof["is_active"])
|
||||||
@@ -176,6 +211,13 @@ class TestRuntimeClarity(unittest.TestCase):
|
|||||||
self.assertEqual(reviewer_prof["base_url"], "<redacted>")
|
self.assertEqual(reviewer_prof["base_url"], "<redacted>")
|
||||||
self.assertEqual(reviewer_prof["identity_status"], "credentials present")
|
self.assertEqual(reviewer_prof["identity_status"], "credentials present")
|
||||||
|
|
||||||
|
merger_prof = next(p for p in profiles if p["name"] == "merger-profile")
|
||||||
|
self.assertFalse(merger_prof["is_active"])
|
||||||
|
self.assertEqual(merger_prof["role_kind"], "merger")
|
||||||
|
self.assertEqual(merger_prof["auth"]["name"], "<redacted>")
|
||||||
|
self.assertEqual(merger_prof["base_url"], "<redacted>")
|
||||||
|
self.assertEqual(merger_prof["identity_status"], "credentials present")
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_list_profiles_revealed_under_opt_in(self, _auth, _api):
|
def test_list_profiles_revealed_under_opt_in(self, _auth, _api):
|
||||||
|
|||||||
Reference in New Issue
Block a user