Merge remote-tracking branch 'prgs/master' into feat/issue-551-codex-gitea-workflow
# Conflicts: # docs/llm-workflow-runbooks.md
This commit is contained in:
+374
-43
@@ -137,13 +137,13 @@ def verify_mutation_authority(remote: str | None, host: str | None = None,
|
||||
)
|
||||
|
||||
# Reviewer/author role pivot boundary: only an authorized pivot
|
||||
# (recorded by gitea_activate_profile) may cross author → reviewer.
|
||||
if (required_role == "reviewer"
|
||||
# (recorded by gitea_activate_profile) may cross author -> reviewer/merger.
|
||||
if (required_role in ("reviewer", "merger")
|
||||
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"):
|
||||
raise RuntimeError(
|
||||
"Attempted reviewer mutation from author session without "
|
||||
f"Attempted {required_role} mutation from author session without "
|
||||
"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``
|
||||
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
|
||||
profile role (#540). ``comment_issue`` preflight stamps
|
||||
@@ -818,6 +818,7 @@ import root_checkout_guard # noqa: E402
|
||||
import remote_repo_guard # noqa: E402
|
||||
import issue_claim_heartbeat # noqa: E402
|
||||
import issue_work_duplicate_gate # noqa: E402
|
||||
import issue_workflow_labels # noqa: E402
|
||||
import reviewer_pr_lease # noqa: E402
|
||||
import merger_lease_adoption # noqa: E402
|
||||
import merged_cleanup_reconcile # noqa: E402
|
||||
@@ -838,6 +839,7 @@ import master_parity_gate # noqa: E402
|
||||
# Read-only operations are never blocked by staleness.
|
||||
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
|
||||
import worktree_cleanup_audit # noqa: E402
|
||||
import canonical_comment_validator as ccv # noqa: E402
|
||||
|
||||
|
||||
# Keyed issue-lock storage (#443): per remote/org/repo/issue files under
|
||||
@@ -1177,6 +1179,108 @@ def extract_linked_issue_numbers(text: str | None, branch_name: str | None = Non
|
||||
issues.update(int(m) for m in pattern.findall(branch_name))
|
||||
return sorted(list(issues))
|
||||
|
||||
def _repo_label_id_map(base: str, auth: str) -> dict[str, int]:
|
||||
labels = api_get_all(f"{base}/labels", auth) or []
|
||||
return {
|
||||
str(lb["name"]): int(lb["id"])
|
||||
for lb in labels
|
||||
if isinstance(lb, dict) and lb.get("name") and lb.get("id") is not None
|
||||
}
|
||||
|
||||
def _issue_label_names(base: str, auth: str, issue_number: int) -> list[str]:
|
||||
issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {}
|
||||
return issue_workflow_labels.label_names(issue)
|
||||
|
||||
def _put_issue_label_names(
|
||||
*,
|
||||
base: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
names: list[str],
|
||||
label_ids_by_name: dict[str, int] | None = None,
|
||||
) -> list[dict]:
|
||||
by_name = label_ids_by_name or _repo_label_id_map(base, auth)
|
||||
missing = [name for name in names if name not in by_name]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
f"The following labels do not exist on the repository: {missing}. "
|
||||
"Create the canonical workflow labels first."
|
||||
)
|
||||
ids = [by_name[name] for name in names]
|
||||
return api_request(
|
||||
"PUT",
|
||||
f"{base}/issues/{issue_number}/labels",
|
||||
auth,
|
||||
{"labels": ids},
|
||||
)
|
||||
|
||||
def _transition_issue_status(
|
||||
*,
|
||||
base: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
status: str,
|
||||
label_ids_by_name: dict[str, int] | None = None,
|
||||
) -> dict:
|
||||
by_name = label_ids_by_name or _repo_label_id_map(base, auth)
|
||||
target_status = issue_workflow_labels.canonical_status_label(status)
|
||||
if target_status not in by_name:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"reasons": [
|
||||
f"required workflow status label '{target_status}' does not exist"
|
||||
],
|
||||
}
|
||||
current = _issue_label_names(base, auth, issue_number)
|
||||
updated = issue_workflow_labels.transition_status_labels(current, target_status)
|
||||
_put_issue_label_names(
|
||||
base=base,
|
||||
auth=auth,
|
||||
issue_number=issue_number,
|
||||
names=updated,
|
||||
label_ids_by_name=by_name,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"labels": updated,
|
||||
}
|
||||
|
||||
def _prepare_issue_status_transition(
|
||||
*,
|
||||
base: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
status: str,
|
||||
) -> dict:
|
||||
by_name = _repo_label_id_map(base, auth)
|
||||
target_status = issue_workflow_labels.canonical_status_label(status)
|
||||
if target_status not in by_name:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"reasons": [
|
||||
f"required workflow status label '{target_status}' does not exist"
|
||||
],
|
||||
}
|
||||
current = _issue_label_names(base, auth, issue_number)
|
||||
updated = issue_workflow_labels.transition_status_labels(current, target_status)
|
||||
return {
|
||||
"success": True,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"labels": updated,
|
||||
"label_ids_by_name": by_name,
|
||||
}
|
||||
|
||||
def release_in_progress_label(issue_numbers: list[int], remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
|
||||
if not issue_numbers:
|
||||
return {}
|
||||
@@ -1509,6 +1613,11 @@ def gitea_create_issue(
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
issue_type: str | None = None,
|
||||
initial_status: str | None = None,
|
||||
discussion: bool = False,
|
||||
require_workflow_labels: bool = False,
|
||||
allow_duplicate_override: bool = False,
|
||||
split_from_issue: int | None = None,
|
||||
worktree_path: str | None = None,
|
||||
@@ -1522,6 +1631,12 @@ def gitea_create_issue(
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
labels: Optional labels to apply by name after creating the issue.
|
||||
issue_type: Optional canonical type, e.g. 'feature' or 'type:feature'.
|
||||
initial_status: Optional initial workflow status, e.g. 'ready'.
|
||||
discussion: If True, require/apply type:discussion.
|
||||
require_workflow_labels: If True, fail closed unless labels include one
|
||||
type:* and one status:* label.
|
||||
allow_duplicate_override: Operator-approved split after duplicate found.
|
||||
split_from_issue: Existing duplicate issue number when overriding.
|
||||
worktree_path: Optional path to verify branches-only guard.
|
||||
@@ -1553,6 +1668,40 @@ def gitea_create_issue(
|
||||
return blocked
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
|
||||
base = repo_api_url(h, o, r)
|
||||
requested_labels = issue_workflow_labels.labels_for_new_issue(
|
||||
issue_type=issue_type,
|
||||
initial_status=initial_status,
|
||||
extra_labels=labels,
|
||||
discussion=discussion,
|
||||
)
|
||||
label_assessment = issue_workflow_labels.assess_issue_labels(
|
||||
requested_labels,
|
||||
discussion=discussion,
|
||||
require_type=True,
|
||||
require_status=True,
|
||||
)
|
||||
if require_workflow_labels and not label_assessment["valid"]:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"number": None,
|
||||
"workflow_label_validation": label_assessment,
|
||||
"reasons": label_assessment["errors"],
|
||||
}
|
||||
label_ids_by_name: dict[str, int] | None = None
|
||||
if requested_labels:
|
||||
label_ids_by_name = _repo_label_id_map(base, auth)
|
||||
missing = [name for name in requested_labels if name not in label_ids_by_name]
|
||||
if missing:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"number": None,
|
||||
"workflow_label_validation": label_assessment,
|
||||
"reasons": [
|
||||
f"requested labels do not exist on the repository: {missing}"
|
||||
],
|
||||
}
|
||||
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
|
||||
closed_issues = api_get_all(
|
||||
f"{base}/issues?state=closed&type=issues", auth, limit=100
|
||||
@@ -1587,7 +1736,29 @@ def gitea_create_issue(
|
||||
_audit("create_issue", host=h, remote=remote, org=o, repo=r,
|
||||
result=gitea_audit.SUCCEEDED, issue_number=data["number"],
|
||||
request_metadata={"title": title}, mutation_task="create_issue")
|
||||
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
|
||||
result = {
|
||||
"number": data["number"],
|
||||
"workflow_label_validation": label_assessment,
|
||||
}
|
||||
if requested_labels:
|
||||
with _audited(
|
||||
"set_issue_labels",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
issue_number=data["number"],
|
||||
request_metadata={"labels": requested_labels, "source": "create_issue"},
|
||||
):
|
||||
applied = _put_issue_label_names(
|
||||
base=base,
|
||||
auth=auth,
|
||||
issue_number=data["number"],
|
||||
names=requested_labels,
|
||||
label_ids_by_name=label_ids_by_name,
|
||||
)
|
||||
result["labels"] = issue_workflow_labels.label_names(applied)
|
||||
return _with_optional_url(result, data.get("html_url"))
|
||||
|
||||
|
||||
def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]:
|
||||
@@ -1996,7 +2167,23 @@ def gitea_create_pr(
|
||||
)
|
||||
|
||||
auth = _auth(h)
|
||||
url = f"{repo_api_url(h, o, r)}/pulls"
|
||||
repo_base = repo_api_url(h, o, r)
|
||||
pr_open_transition = _prepare_issue_status_transition(
|
||||
base=repo_base,
|
||||
auth=auth,
|
||||
issue_number=int(locked_issue),
|
||||
status="status:pr-open",
|
||||
)
|
||||
if not pr_open_transition.get("success"):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"number": None,
|
||||
"issue_number": locked_issue,
|
||||
"issue_status_transition": pr_open_transition,
|
||||
"reasons": pr_open_transition.get("reasons", []),
|
||||
}
|
||||
url = f"{repo_base}/pulls"
|
||||
payload = {"title": title, "body": body, "head": head, "base": base}
|
||||
meta = {"title": title, "head": head, "base": base}
|
||||
try:
|
||||
@@ -2011,7 +2198,37 @@ def gitea_create_pr(
|
||||
result=gitea_audit.SUCCEEDED, pr_number=data["number"],
|
||||
target_branch=head, request_metadata=meta,
|
||||
mutation_task="create_pr")
|
||||
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
|
||||
with _audited(
|
||||
"set_issue_labels",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
issue_number=int(locked_issue),
|
||||
request_metadata={
|
||||
"source": "create_pr",
|
||||
"status": "status:pr-open",
|
||||
"pr_number": data["number"],
|
||||
},
|
||||
):
|
||||
_put_issue_label_names(
|
||||
base=repo_base,
|
||||
auth=auth,
|
||||
issue_number=int(locked_issue),
|
||||
names=pr_open_transition["labels"],
|
||||
label_ids_by_name=pr_open_transition["label_ids_by_name"],
|
||||
)
|
||||
result = {
|
||||
"number": data["number"],
|
||||
"issue_status_transition": {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"issue_number": int(locked_issue),
|
||||
"target_status": "status:pr-open",
|
||||
"labels": pr_open_transition["labels"],
|
||||
},
|
||||
}
|
||||
return _with_optional_url(result, data.get("html_url"))
|
||||
|
||||
|
||||
def _format_list_pr_entry(pr: dict) -> dict:
|
||||
@@ -2281,13 +2498,23 @@ def gitea_check_pr_eligibility(
|
||||
# ("gitea.pr.merge") always match each other and never cross services.
|
||||
allowed = profile["allowed_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 op_reason == "no-allowed-operations":
|
||||
reasons.append(
|
||||
"profile has no configured allowed operations (fail closed)")
|
||||
elif op_reason == "forbidden":
|
||||
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":
|
||||
reasons.append(
|
||||
"profile has an unrecognized forbidden operation entry "
|
||||
@@ -2311,21 +2538,23 @@ def gitea_check_pr_eligibility(
|
||||
missing_perm)
|
||||
|
||||
req_profile = None
|
||||
role_noun = "reviewer"
|
||||
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":
|
||||
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
|
||||
|
||||
switching_supported = gitea_config.is_runtime_switching_enabled()
|
||||
if switching_supported:
|
||||
result["fixable_by_profile_switch"] = True
|
||||
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:
|
||||
result["fixable_by_profile_switch"] = False
|
||||
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
|
||||
return result
|
||||
@@ -3277,6 +3506,13 @@ def _evaluate_pr_review_submission(
|
||||
result["pr_work_lease"] = lease_block
|
||||
return result
|
||||
|
||||
if (body or "").strip():
|
||||
gate = _canonical_comment_gate(body, context="pr_review")
|
||||
if gate["blocked"]:
|
||||
reasons.extend(gate["reasons"])
|
||||
result["canonical_comment_validation"] = gate["canonical_comment_validation"]
|
||||
return result
|
||||
|
||||
result["would_perform"] = True
|
||||
if not live:
|
||||
reasons.append(
|
||||
@@ -4041,6 +4277,14 @@ def gitea_merge_pr(
|
||||
_verify_role_mutation_workspace(
|
||||
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()
|
||||
do = (do or "").strip().lower()
|
||||
result = {
|
||||
@@ -4057,6 +4301,10 @@ def gitea_merge_pr(
|
||||
"merge_result": None,
|
||||
"merge_commit": None,
|
||||
"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"]
|
||||
if workflow_blockers:
|
||||
@@ -4226,7 +4474,7 @@ def gitea_merge_pr(
|
||||
# A profile/identity flip or side-channel override between preflight
|
||||
# and merge fails closed here.
|
||||
try:
|
||||
verify_mutation_authority(remote, host, required_role="reviewer",
|
||||
verify_mutation_authority(remote, host, required_role="merger",
|
||||
active_identity=auth_user)
|
||||
except RuntimeError as e:
|
||||
reasons.append(str(e))
|
||||
@@ -4781,6 +5029,7 @@ def gitea_reconcile_merged_cleanups(
|
||||
remote_branch_exists=remote_branch_exists,
|
||||
head_on_master=head_on_master,
|
||||
delete_capability_allowed=delete_capability_allowed,
|
||||
target_ref=master_ref,
|
||||
active_reviewer_leases=active_reviewer_leases,
|
||||
pr_states=pr_states,
|
||||
)
|
||||
@@ -4822,7 +5071,9 @@ def gitea_reconcile_merged_cleanups(
|
||||
|
||||
if local_assessment.get("safe_to_remove_worktree"):
|
||||
result = merged_cleanup_reconcile.remove_local_worktree(
|
||||
PROJECT_ROOT, head_branch
|
||||
PROJECT_ROOT,
|
||||
head_branch,
|
||||
worktree_path=local_assessment.get("worktree_path"),
|
||||
)
|
||||
actions.append({"action": "remove_local_worktree", **result})
|
||||
|
||||
@@ -5254,6 +5505,12 @@ def gitea_reconcile_already_landed_pr(
|
||||
"gitea.pr.comment"
|
||||
)
|
||||
return result
|
||||
gate = _canonical_comment_gate(comment_body)
|
||||
if gate["blocked"]:
|
||||
result["success"] = False
|
||||
result["reasons"].extend(gate["reasons"])
|
||||
result["canonical_comment_validation"] = gate["canonical_comment_validation"]
|
||||
return result
|
||||
comment_url = f"{base}/issues/{pr_number}/comments"
|
||||
with _audited(
|
||||
"comment_pr",
|
||||
@@ -6517,6 +6774,15 @@ def gitea_create_issue_comment(
|
||||
blocked["permission_report"] = _permission_block_report(
|
||||
"gitea.issue.comment")
|
||||
return blocked
|
||||
canon_gate = _canonical_comment_gate(body, context="issue_comment")
|
||||
if canon_gate["blocked"]:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"reasons": canon_gate["reasons"],
|
||||
"canonical_comment_validation": canon_gate["canonical_comment_validation"],
|
||||
}
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
|
||||
@@ -6545,26 +6811,28 @@ 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;
|
||||
'reconciler' can close already-landed PRs via ``gitea.pr.close`` without
|
||||
review/author powers; 'mixed' can do both (a config smell — the
|
||||
reviewer-deadlock invariant forbids it in v2 configs); 'limited' can do
|
||||
neither.
|
||||
'author' can create PRs / push branches; 'reviewer' can approve;
|
||||
'merger' can merge PRs;
|
||||
'mixed' can do multiple (a config smell); '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")
|
||||
can_merge = can("gitea.pr.merge")
|
||||
can_approve = can("gitea.pr.approve")
|
||||
author = can("gitea.pr.create") or can("gitea.branch.push")
|
||||
reconciler = (
|
||||
can("gitea.pr.close")
|
||||
and not review
|
||||
and not can_approve
|
||||
and not can_merge
|
||||
and not author
|
||||
)
|
||||
if review and author:
|
||||
if (can_approve or can_merge) and author:
|
||||
return "mixed"
|
||||
if review:
|
||||
if can_merge:
|
||||
return "merger"
|
||||
if can_approve:
|
||||
return "reviewer"
|
||||
if reconciler:
|
||||
return "reconciler"
|
||||
@@ -7085,12 +7353,20 @@ def mcp_get_control_plane_guide(
|
||||
"gitea.issue.comment (separate from gitea.pr.comment).")
|
||||
elif role == "reviewer":
|
||||
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 "
|
||||
"pinned (expected_head_sha); the PR author must be a different "
|
||||
"user, and merging additionally requires explicit operator "
|
||||
"authorization plus the 'MERGE PR <n>' confirmation. "
|
||||
"PR comments do not imply issue comments.")
|
||||
"user. Reviewer profiles cannot merge PRs. "
|
||||
"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":
|
||||
guidance.append(
|
||||
"WARNING: this profile allows both authoring and "
|
||||
@@ -8136,6 +8412,34 @@ def gitea_audit_config() -> dict:
|
||||
return report
|
||||
|
||||
|
||||
def _canonical_comment_gate(
|
||||
body: str,
|
||||
*,
|
||||
context: str | None = None,
|
||||
force_workflow: bool = False,
|
||||
) -> dict:
|
||||
"""Fail-closed canonical state validation before comment/review POST (#496)."""
|
||||
assessment = ccv.assess_canonical_comment(
|
||||
body,
|
||||
context=context,
|
||||
force_workflow=force_workflow,
|
||||
)
|
||||
if assessment.get("allowed"):
|
||||
return {
|
||||
"blocked": False,
|
||||
"canonical_comment_validation": assessment,
|
||||
"reasons": [],
|
||||
}
|
||||
correction = (assessment.get("correction_message") or "").strip()
|
||||
return {
|
||||
"blocked": True,
|
||||
"canonical_comment_validation": assessment,
|
||||
"reasons": [
|
||||
correction or "canonical comment validation failed (fail closed before posting)"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _post_structured_issue_comment(
|
||||
*,
|
||||
issue_number: int,
|
||||
@@ -8145,8 +8449,18 @@ def _post_structured_issue_comment(
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
audit_op: str = "create_issue_comment",
|
||||
comment_context: str | None = None,
|
||||
) -> dict:
|
||||
"""Post an issue-thread comment after permission gates already passed."""
|
||||
gate = _canonical_comment_gate(body, context=comment_context)
|
||||
if gate["blocked"]:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"reasons": gate["reasons"],
|
||||
"canonical_comment_validation": gate["canonical_comment_validation"],
|
||||
}
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
|
||||
@@ -8211,15 +8525,8 @@ def gitea_mark_issue(
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
|
||||
# Find the status:in-progress label id
|
||||
labels = api_request("GET", f"{base}/labels?limit=100", auth)
|
||||
label_id = None
|
||||
for lb in labels:
|
||||
if lb["name"] == "status:in-progress":
|
||||
label_id = lb["id"]
|
||||
break
|
||||
|
||||
if label_id is None:
|
||||
label_ids_by_name = _repo_label_id_map(base, auth)
|
||||
if "status:in-progress" not in label_ids_by_name:
|
||||
raise RuntimeError(
|
||||
"Label 'status:in-progress' not found. "
|
||||
"Run manage_labels.py to create it first."
|
||||
@@ -8227,10 +8534,19 @@ def gitea_mark_issue(
|
||||
|
||||
if action == "start":
|
||||
with _audited("label_issue", host=h, remote=remote, org=o, repo=r,
|
||||
issue_number=issue_number,
|
||||
request_metadata={"op": "add", "label": "status:in-progress"}):
|
||||
api_request("POST", f"{base}/issues/{issue_number}/labels", auth,
|
||||
{"labels": [label_id]})
|
||||
issue_number=issue_number, request_metadata={
|
||||
"op": "replace-status",
|
||||
"label": "status:in-progress",
|
||||
}):
|
||||
transition = _transition_issue_status(
|
||||
base=base,
|
||||
auth=auth,
|
||||
issue_number=issue_number,
|
||||
status="status:in-progress",
|
||||
label_ids_by_name=label_ids_by_name,
|
||||
)
|
||||
if not transition.get("success"):
|
||||
raise RuntimeError("; ".join(transition.get("reasons") or []))
|
||||
active_profile = (profile or get_profile().get("profile_name") or "unknown")
|
||||
branch = (branch_name or "pending").strip() or "pending"
|
||||
heartbeat_body = issue_claim_heartbeat.format_heartbeat_body(
|
||||
@@ -8255,8 +8571,10 @@ def gitea_mark_issue(
|
||||
"message": f"Issue #{issue_number} claimed.",
|
||||
"heartbeat_posted": heartbeat.get("success", False),
|
||||
"heartbeat_comment_id": heartbeat.get("comment_id"),
|
||||
"status_transition": transition,
|
||||
}
|
||||
else:
|
||||
label_id = label_ids_by_name["status:in-progress"]
|
||||
with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r,
|
||||
issue_number=issue_number,
|
||||
request_metadata={"op": "remove", "label": "status:in-progress"}):
|
||||
@@ -8673,6 +8991,7 @@ def gitea_create_label(
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new label on a Gitea repository.
|
||||
|
||||
@@ -8684,11 +9003,16 @@ def gitea_create_label(
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
worktree_path: Optional branches worktree to verify before mutation.
|
||||
|
||||
Returns:
|
||||
dict containing the created label details.
|
||||
"""
|
||||
verify_preflight_purity(remote)
|
||||
blocked = _profile_permission_block(
|
||||
task_capability_map.required_permission("create_label"))
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_label")
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
@@ -8714,6 +9038,7 @@ def gitea_set_issue_labels(
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> list:
|
||||
"""Replace all labels on a Gitea issue with a new list of label names.
|
||||
|
||||
@@ -8724,6 +9049,7 @@ def gitea_set_issue_labels(
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
worktree_path: Optional branches worktree to verify before mutation.
|
||||
|
||||
Returns:
|
||||
list of all labels currently applied to the issue.
|
||||
@@ -8732,7 +9058,7 @@ def gitea_set_issue_labels(
|
||||
task_capability_map.required_permission("set_issue_labels"))
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, task="set_issue_labels")
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="set_issue_labels")
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
@@ -9276,6 +9602,11 @@ def gitea_capability_stop_terminal_report() -> dict:
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
# #558: mark this process as the official MCP daemon before any tool
|
||||
# dispatch so direct shell imports cannot reuse mutation/auth paths.
|
||||
import mcp_daemon_guard
|
||||
|
||||
mcp_daemon_guard.mark_sanctioned_daemon()
|
||||
# Lock this session's launch profile into the environment so child CLI
|
||||
# processes (e.g. review_pr.py) can detect and refuse profile
|
||||
# side-channel overrides (#199).
|
||||
|
||||
Reference in New Issue
Block a user