feat: preserve and resume prepared review verdict drafts (#609)
Add non-terminal review draft save/resume tools so a fully validated reviewer verdict can survive MCP disconnect or stale-runtime restart without counting as a formal review. - gitea_save_review_draft: durable draft with PR, head SHA, target SHA, worktree, validation evidence, intended verdict, and blocker reason - gitea_resume_review_draft: live revalidation of head/target/#332/lease/ identity/worktree before mark-ready or optional submit - Drafts never substitute for formal approval without revalidation Closes #609
This commit is contained in:
@@ -4162,6 +4162,335 @@ def gitea_submit_pr_review(
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_save_review_draft(
|
||||
pr_number: int,
|
||||
action: str,
|
||||
body: str = "",
|
||||
expected_head_sha: str | None = None,
|
||||
validation_commands: str | None = None,
|
||||
validation_results: str | None = None,
|
||||
blocker_reason: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Save a prepared review verdict as non-terminal draft evidence without submitting a formal review.
|
||||
|
||||
This permits resuming the review later (e.g. after a stale runtime restart or connection drop).
|
||||
"""
|
||||
action = (action or "").strip().lower()
|
||||
if action not in _REVIEW_ACTIONS:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [
|
||||
f"unknown review action '{action}'; expected one of "
|
||||
f"{sorted(_REVIEW_ACTIONS)}"
|
||||
],
|
||||
}
|
||||
|
||||
# 1. Resolve remote context
|
||||
try:
|
||||
h, resolved_org, resolved_repo = _resolve(remote, host, org, repo)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "reasons": [str(exc)]}
|
||||
|
||||
auth = _auth(h)
|
||||
|
||||
# 2. Fetch current PR details to get target base branch and base branch SHA
|
||||
try:
|
||||
pr_url = f"{repo_api_url(h, resolved_org, resolved_repo)}/pulls/{pr_number}"
|
||||
pr = api_request("GET", pr_url, auth) or {}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [f"failed to fetch PR #{pr_number} from Gitea: {str(exc)}"],
|
||||
}
|
||||
|
||||
current_head = (pr.get("head") or {}).get("sha")
|
||||
target_branch = (pr.get("base") or {}).get("ref")
|
||||
target_branch_sha = (pr.get("base") or {}).get("sha")
|
||||
|
||||
if expected_head_sha and current_head and expected_head_sha != current_head:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [
|
||||
f"expected_head_sha '{expected_head_sha}' does not match current PR head SHA '{current_head}'"
|
||||
],
|
||||
}
|
||||
|
||||
# 3. Resolve worktree path
|
||||
try:
|
||||
resolved_worktree = _resolve_preflight_workspace_path(worktree_path)
|
||||
except Exception as exc:
|
||||
return {"success": False, "reasons": [f"failed to resolve worktree path: {str(exc)}"]}
|
||||
|
||||
# 4. Save payload to durable session state
|
||||
profile = get_profile()
|
||||
payload = {
|
||||
"pr_number": pr_number,
|
||||
"action": action,
|
||||
"body": body,
|
||||
"head_sha": expected_head_sha or current_head,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": target_branch_sha,
|
||||
"worktree_path": resolved_worktree,
|
||||
"validation_commands": validation_commands,
|
||||
"validation_results": validation_results,
|
||||
"blocker_reason": blocker_reason,
|
||||
"saved_by_identity": _authenticated_username(h) or "",
|
||||
"saved_by_profile": profile.get("profile_name"),
|
||||
"remote": remote,
|
||||
"org": resolved_org,
|
||||
"repo": resolved_repo,
|
||||
}
|
||||
|
||||
try:
|
||||
mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||
payload=payload,
|
||||
remote=remote,
|
||||
org=resolved_org,
|
||||
repo=resolved_repo,
|
||||
profile_identity=profile.get("profile_name"),
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"success": False, "reasons": [f"failed to save draft state: {str(exc)}"]}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pr_number": pr_number,
|
||||
"action": action,
|
||||
"head_sha": payload["head_sha"],
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": target_branch_sha,
|
||||
"worktree_path": resolved_worktree,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_resume_review_draft(
|
||||
pr_number: int,
|
||||
submit: bool = False,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Resume a previously saved review draft for the given PR number.
|
||||
|
||||
Performs live-state checks (PR head SHA, target branch SHA, terminal lock, active leases,
|
||||
runtime/master parity, reviewer identity, worktree binding, validation freshness).
|
||||
Refuses to submit or mark ready if any state has changed unsafely.
|
||||
"""
|
||||
profile = get_profile()
|
||||
active_profile = profile.get("profile_name")
|
||||
|
||||
# 1. Load draft state
|
||||
try:
|
||||
draft = mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=active_profile,
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"success": False, "reasons": [f"failed to load draft state: {str(exc)}"]}
|
||||
|
||||
if not draft:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [f"no review draft found for PR #{pr_number} under profile '{active_profile}'"],
|
||||
}
|
||||
|
||||
if draft.get("pr_number") != pr_number:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [
|
||||
f"loaded draft is for PR #{draft.get('pr_number')}, but requested PR #{pr_number}"
|
||||
],
|
||||
}
|
||||
|
||||
# 2. Resolve remote context
|
||||
try:
|
||||
h, resolved_org, resolved_repo = _resolve(remote, host, org, repo)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "reasons": [str(exc)]}
|
||||
|
||||
auth = _auth(h)
|
||||
|
||||
# 3. Fetch live PR state
|
||||
try:
|
||||
pr_url = f"{repo_api_url(h, resolved_org, resolved_repo)}/pulls/{pr_number}"
|
||||
pr = api_request("GET", pr_url, auth) or {}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [f"failed to fetch PR #{pr_number} from Gitea: {str(exc)}"],
|
||||
}
|
||||
|
||||
# 4. Perform live-state checks
|
||||
reasons = []
|
||||
|
||||
# PR must be open
|
||||
if pr.get("state") != "open":
|
||||
reasons.append(f"PR #{pr_number} is not open (state: '{pr.get('state')}')")
|
||||
|
||||
# Live head SHA must match saved head_sha
|
||||
live_head = (pr.get("head") or {}).get("sha")
|
||||
saved_head = draft.get("head_sha")
|
||||
if live_head != saved_head:
|
||||
reasons.append(
|
||||
f"PR head SHA changed (live={live_head!r}, saved={saved_head!r})"
|
||||
)
|
||||
|
||||
# Live target branch SHA must match saved target_branch_sha
|
||||
live_target_sha = (pr.get("base") or {}).get("sha")
|
||||
saved_target_sha = draft.get("target_branch_sha")
|
||||
if live_target_sha != saved_target_sha:
|
||||
reasons.append(
|
||||
f"target branch SHA changed (live={live_target_sha!r}, saved={saved_target_sha!r})"
|
||||
)
|
||||
|
||||
# Check #332 terminal lock
|
||||
hard_stop = terminal_review_hard_stop_reasons(pr_number, "resume")
|
||||
if hard_stop:
|
||||
reasons.extend(hard_stop)
|
||||
|
||||
# Check active reviewer lease (must be claimed by the current session)
|
||||
try:
|
||||
comments = _fetch_pr_comments(pr_number, remote=remote, host=host, org=resolved_org, repo=resolved_repo)
|
||||
except Exception as exc:
|
||||
reasons.append(f"failed to fetch PR comments: {str(exc)}")
|
||||
comments = []
|
||||
|
||||
active_lease = reviewer_pr_lease.find_active_reviewer_lease(comments, pr_number=pr_number)
|
||||
if not active_lease:
|
||||
reasons.append(f"no active reviewer lease found on PR #{pr_number}")
|
||||
else:
|
||||
# Check that lease owner is our session_id
|
||||
session = reviewer_pr_lease.get_session_lease()
|
||||
session_id = (session or {}).get("session_id")
|
||||
owner_session_id = active_lease.get("session_id")
|
||||
if owner_session_id != session_id:
|
||||
reasons.append(
|
||||
f"active reviewer lease belongs to session_id={owner_session_id!r}, "
|
||||
f"but current session has session_id={session_id!r}"
|
||||
)
|
||||
|
||||
# Check runtime/master parity
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||
config = gitea_config.load_config()
|
||||
matching_profiles = []
|
||||
if config and "profiles" in config:
|
||||
for p_name, p_data in config["profiles"].items():
|
||||
p_allowed = p_data.get("allowed_operations") or []
|
||||
p_forbidden = p_data.get("forbidden_operations") or []
|
||||
p_allowed_n = []
|
||||
for op in p_allowed:
|
||||
try:
|
||||
p_allowed_n.append(gitea_config.normalize_operation(op))
|
||||
except Exception:
|
||||
pass
|
||||
p_forbidden_n = []
|
||||
for op in p_forbidden:
|
||||
try:
|
||||
p_forbidden_n.append(gitea_config.normalize_operation(op))
|
||||
except Exception:
|
||||
pass
|
||||
# Check for "review_pr" or similar capability
|
||||
ok, _ = gitea_config.check_operation("gitea.pr.review", p_allowed_n, p_forbidden_n)
|
||||
if ok:
|
||||
matching_profiles.append(p_name)
|
||||
runtime_reasons = _check_mcp_runtimes_diagnostics("review_pr", matching_profiles)
|
||||
if runtime_reasons:
|
||||
reasons.extend(runtime_reasons)
|
||||
|
||||
# Check reviewer identity
|
||||
identity = _authenticated_username(h)
|
||||
if identity != draft.get("saved_by_identity"):
|
||||
reasons.append(
|
||||
f"reviewer identity mismatch (active={identity!r}, saved={draft.get('saved_by_identity')!r})"
|
||||
)
|
||||
|
||||
# Check worktree binding
|
||||
try:
|
||||
resolved_worktree = _resolve_preflight_workspace_path(worktree_path)
|
||||
except Exception as exc:
|
||||
reasons.append(f"failed to resolve current worktree: {str(exc)}")
|
||||
resolved_worktree = None
|
||||
if resolved_worktree != draft.get("worktree_path"):
|
||||
reasons.append(
|
||||
f"worktree binding mismatch (current={resolved_worktree!r}, saved={draft.get('worktree_path')!r})"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {"success": False, "reasons": reasons}
|
||||
|
||||
# 5. All checks pass! Mark decision lock as ready.
|
||||
action = draft.get("action")
|
||||
body = draft.get("body")
|
||||
saved_head = draft.get("head_sha")
|
||||
|
||||
lock = _load_review_decision_lock() or {}
|
||||
lock["final_review_decision_ready"] = True
|
||||
lock["ready_pr_number"] = pr_number
|
||||
lock["ready_action"] = action
|
||||
lock["ready_expected_head_sha"] = saved_head
|
||||
lock["ready_remote"] = remote
|
||||
lock["ready_org"] = resolved_org
|
||||
lock["ready_repo"] = resolved_repo
|
||||
_save_review_decision_lock(lock)
|
||||
|
||||
submitted = False
|
||||
submission_res = None
|
||||
if submit:
|
||||
# Submit the review!
|
||||
submission_res = gitea_submit_pr_review(
|
||||
pr_number=pr_number,
|
||||
action=action,
|
||||
body=body,
|
||||
expected_head_sha=saved_head,
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=resolved_org,
|
||||
repo=resolved_repo,
|
||||
final_review_decision_ready=True,
|
||||
worktree_path=resolved_worktree,
|
||||
)
|
||||
if submission_res.get("performed") or submission_res.get("success"):
|
||||
submitted = True
|
||||
# Delete the draft payload on success
|
||||
mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||
payload=None,
|
||||
remote=remote,
|
||||
org=resolved_org,
|
||||
repo=resolved_repo,
|
||||
profile_identity=active_profile,
|
||||
)
|
||||
else:
|
||||
# If submit fails, return the submit failure reasons
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": submission_res.get("reasons") or ["submission failed"],
|
||||
"submission_result": submission_res,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pr_number": pr_number,
|
||||
"action": action,
|
||||
"marked_ready": True,
|
||||
"submitted": submitted,
|
||||
"submission_result": submission_res,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_edit_pr(
|
||||
pr_number: int,
|
||||
|
||||
Reference in New Issue
Block a user