Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78777d4baa |
@@ -1,105 +0,0 @@
|
|||||||
"""Branches-only author mutation worktree guard (#274).
|
|
||||||
|
|
||||||
Author/coder mutations must run from a session-owned worktree under the
|
|
||||||
project's ``branches/`` directory, never from the stable control checkout.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_path(path: str) -> str:
|
|
||||||
return (path or "").replace("\\", "/").rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
def is_path_under_branches(path: str, project_root: str | None = None) -> bool:
|
|
||||||
"""True when *path* resolves inside ``<project_root>/branches/``."""
|
|
||||||
normalized = _normalize_path(path)
|
|
||||||
if not normalized:
|
|
||||||
return False
|
|
||||||
if "/branches/" in f"{normalized}/":
|
|
||||||
return True
|
|
||||||
if normalized.endswith("/branches"):
|
|
||||||
return True
|
|
||||||
if project_root:
|
|
||||||
root = _normalize_path(os.path.realpath(project_root))
|
|
||||||
real = _normalize_path(os.path.realpath(path))
|
|
||||||
if real.startswith(f"{root}/"):
|
|
||||||
rel = real[len(root) + 1 :]
|
|
||||||
return rel == "branches" or rel.startswith("branches/")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_mutation_workspace(
|
|
||||||
worktree_path: str | None,
|
|
||||||
project_root: str,
|
|
||||||
*,
|
|
||||||
active_worktree_env: str | None = None,
|
|
||||||
author_worktree_env: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Resolve the workspace path inspected before author mutations."""
|
|
||||||
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
|
|
||||||
text = (candidate or "").strip()
|
|
||||||
if text:
|
|
||||||
return os.path.realpath(os.path.abspath(text))
|
|
||||||
return os.path.realpath(project_root)
|
|
||||||
|
|
||||||
|
|
||||||
def assess_author_mutation_worktree(
|
|
||||||
*,
|
|
||||||
workspace_path: str,
|
|
||||||
project_root: str,
|
|
||||||
current_branch: str | None = None,
|
|
||||||
base_branches: frozenset[str] | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Fail closed when author mutations are not rooted under ``branches/``."""
|
|
||||||
bases = base_branches or BASE_BRANCHES
|
|
||||||
reasons: list[str] = []
|
|
||||||
root = os.path.realpath(project_root)
|
|
||||||
workspace = os.path.realpath(workspace_path)
|
|
||||||
branch = (current_branch or "").strip()
|
|
||||||
|
|
||||||
under_branches = is_path_under_branches(workspace, root)
|
|
||||||
if not under_branches:
|
|
||||||
if workspace == root:
|
|
||||||
reasons.append(
|
|
||||||
"author mutation blocked: workspace is the stable control checkout; "
|
|
||||||
"create or switch to a session-owned worktree under branches/"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
reasons.append(
|
|
||||||
f"author mutation blocked: workspace '{workspace}' is not under "
|
|
||||||
f"'{root}/branches/'; create a branches/<task> worktree first"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not under_branches and workspace == root and branch and branch not in bases:
|
|
||||||
reasons.append(
|
|
||||||
f"control checkout drift: branch '{branch}' is not a stable base "
|
|
||||||
f"branch ({'/'.join(sorted(bases))})"
|
|
||||||
)
|
|
||||||
|
|
||||||
proven = not reasons
|
|
||||||
return {
|
|
||||||
"proven": proven,
|
|
||||||
"block": not proven,
|
|
||||||
"reasons": reasons,
|
|
||||||
"project_root": root,
|
|
||||||
"workspace_path": workspace,
|
|
||||||
"under_branches": is_path_under_branches(workspace, root),
|
|
||||||
"current_branch": branch or None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def format_author_mutation_worktree_error(assessment: dict) -> str:
|
|
||||||
"""Single RuntimeError message for MCP preflight gates."""
|
|
||||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
|
||||||
root = assessment.get("project_root") or "(unknown)"
|
|
||||||
reasons = "; ".join(assessment.get("reasons") or ["unknown branches-only violation"])
|
|
||||||
return (
|
|
||||||
f"Branches-only mutation guard (#274): {reasons}. "
|
|
||||||
f"project root: {root}; workspace: {workspace}. "
|
|
||||||
"Create a session-owned worktree under branches/ before mutating."
|
|
||||||
)
|
|
||||||
@@ -23,7 +23,7 @@ launched with exactly one static execution profile:
|
|||||||
|-----------------------------|----------------|-------------|
|
|-----------------------------|----------------|-------------|
|
||||||
| `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, merge |
|
||||||
| `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 (#310) |
|
||||||
|
|
||||||
Properties:
|
Properties:
|
||||||
|
|
||||||
|
|||||||
@@ -226,12 +226,11 @@ explicit operator-directed closure of a contaminated PR). If `close_pr` ever
|
|||||||
resolves as unknown, agents must fail closed rather than fall back to the
|
resolves as unknown, agents must fail closed rather than fall back to the
|
||||||
edit path.
|
edit path.
|
||||||
|
|
||||||
## Reconciler profile for already-landed open PRs (#304 / #310)
|
## Reconciler profile for already-landed open PRs (#310)
|
||||||
|
|
||||||
Normal author and reviewer profiles must not gain broad `gitea.pr.close`
|
Normal author and reviewer profiles must not gain broad PR-close authority.
|
||||||
authority. Already-landed open PRs (head SHA is an ancestor of the target
|
Already-landed open PRs (head SHA is an ancestor of the target branch) need a
|
||||||
branch) need a dedicated reconciler profile such as `prgs-reconciler` with a
|
dedicated reconciler profile such as `prgs-reconciler` with:
|
||||||
narrow operation set:
|
|
||||||
|
|
||||||
- `gitea.read`
|
- `gitea.read`
|
||||||
- `gitea.pr.comment`
|
- `gitea.pr.comment`
|
||||||
@@ -239,14 +238,8 @@ narrow operation set:
|
|||||||
- `gitea.issue.close`
|
- `gitea.issue.close`
|
||||||
- `gitea.pr.close`
|
- `gitea.pr.close`
|
||||||
|
|
||||||
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
Use the `gitea-reconciler` MCP namespace (static profile launch) and the
|
||||||
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
`gitea_reconcile_already_landed_pr` tool. The resolver task is
|
||||||
`gitea.repo.commit`.
|
|
||||||
|
|
||||||
Launch a static `gitea-reconciler` MCP namespace with
|
|
||||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
|
||||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
|
||||||
`gitea_reconcile_already_landed_pr` tool (#310). The resolver task is
|
|
||||||
`reconcile_already_landed_pr`. PR close is allowed only after live PR fetch,
|
`reconcile_already_landed_pr`. PR close is allowed only after live PR fetch,
|
||||||
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
||||||
heads are not already landed cannot be closed through this path.
|
heads are not already landed cannot be closed through this path.
|
||||||
|
|||||||
@@ -273,20 +273,7 @@ Never create a parallel branch or PR for the same issue unless the old branch
|
|||||||
is proven abandoned and the takeover is recorded.
|
is proven abandoned and the takeover is recorded.
|
||||||
|
|
||||||
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
|
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
|
||||||
mutations), `status:in-progress`, and claim comments. `gitea_lock_issue`
|
mutations), `status:in-progress`, and claim comments. Full portable wording:
|
||||||
records an `author_issue_work` lease in the issue-lock payload with issue
|
|
||||||
number, optional PR number, branch, worktree path, claimant identity/profile,
|
|
||||||
created timestamp, expiry timestamp, and last heartbeat timestamp. An active
|
|
||||||
same-issue/same-operation lease blocks duplicate work. An expired lease still
|
|
||||||
blocks takeover until a recovery review records why the prior work is abandoned,
|
|
||||||
completed, or unsafe to continue.
|
|
||||||
|
|
||||||
Remote branches matching the issue number are also treated as active work unless
|
|
||||||
the recovery review proves the branch is abandoned or superseded. Never delete
|
|
||||||
or clean up a branch when it has an active lease, dirty worktree, open PR, or is
|
|
||||||
the only copy of unmerged work.
|
|
||||||
|
|
||||||
Full portable wording:
|
|
||||||
[`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
[`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
||||||
|
|
||||||
## Global LLM Worktree Rule
|
## Global LLM Worktree Rule
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
# Internal web UI — local development (#426)
|
|
||||||
|
|
||||||
Read-only MVP skeleton for the MCP Control Plane operator console. Gitea,
|
|
||||||
MCP capability gates, and `skills/llm-project-workflow/` remain the source of
|
|
||||||
truth; this UI only provides route stubs and layout.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Python 3.11+ with project dependencies installed (`pip install -r requirements.txt`)
|
|
||||||
- No secrets in repo, config, or client bundle
|
|
||||||
|
|
||||||
## Start the server
|
|
||||||
|
|
||||||
From the repository root (or an issue worktree):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/run-webui
|
|
||||||
```
|
|
||||||
|
|
||||||
Or directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m webui
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional environment variables:
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
|
|
||||||
| `WEBUI_PORT` | `8765` | Listen port |
|
|
||||||
|
|
||||||
## Routes (MVP)
|
|
||||||
|
|
||||||
| Path | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `/` | Home / operator overview |
|
|
||||||
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
|
|
||||||
| `/projects` | Project registry list (#427) |
|
|
||||||
| `/projects/{id}` | Project detail + onboarding checklist |
|
|
||||||
| `/api/projects` | JSON registry export |
|
|
||||||
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
|
|
||||||
| `/api/prompts` | JSON prompt export with workflow hashes |
|
|
||||||
| `/runtime` | Stub — MCP runtime health (#430) |
|
|
||||||
| `/audit` | Stub — report audit paste (#431) |
|
|
||||||
| `/worktrees` | Stub — hygiene dashboard (#432) |
|
|
||||||
| `/leases` | Stub — lease visibility (#433) |
|
|
||||||
|
|
||||||
All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
|
||||||
`read-only-mvp`.
|
|
||||||
|
|
||||||
## Project registry (#427)
|
|
||||||
|
|
||||||
Versioned registry file: `webui/data/projects.registry.json` (schema version `1`).
|
|
||||||
|
|
||||||
Override path with `WEBUI_PROJECT_REGISTRY` when operators keep a machine-local
|
|
||||||
copy outside git. The registry stores repo identity, remotes, profile names,
|
|
||||||
workflow/schema path references, and onboarding checklist steps — never tokens
|
|
||||||
or credentials.
|
|
||||||
|
|
||||||
Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
|
|
||||||
`prgs-reviewer`, and `prgs-reconciler` profiles.
|
|
||||||
|
|
||||||
## Prompt library (#428)
|
|
||||||
|
|
||||||
Prompts are generated at load time from canonical workflow files under
|
|
||||||
`skills/llm-project-workflow/workflows/`. SHA-256 hashes are computed from
|
|
||||||
`WEBUI_REPO_ROOT` (defaults to the repository root). Prompt bodies are short
|
|
||||||
copy/paste starters; canonical workflow files remain the only full policy
|
|
||||||
source.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py -q
|
|
||||||
```
|
|
||||||
@@ -17,10 +17,6 @@
|
|||||||
0. Work Selection Rule — verify a work lease before any mutations (open PRs,
|
0. Work Selection Rule — verify a work lease before any mutations (open PRs,
|
||||||
issue-linked PRs, branches, worktrees, dirty worktrees, active leases/
|
issue-linked PRs, branches, worktrees, dirty worktrees, active leases/
|
||||||
handoffs, merged-PR completion). Stop if another session owns the lease.
|
handoffs, merged-PR completion). Stop if another session owns the lease.
|
||||||
`gitea_lock_issue` records an operation-scoped `author_issue_work` lease
|
|
||||||
with issue, branch, worktree, claimant, created, expiry, and heartbeat
|
|
||||||
fields; active or expired same-operation leases require recovery review
|
|
||||||
before takeover.
|
|
||||||
0b. Global LLM Worktree Rule — main checkout on `master`/`main`/`dev` only;
|
0b. Global LLM Worktree Rule — main checkout on `master`/`main`/`dev` only;
|
||||||
mutate only from a `branches/` worktree after proving root, cwd, branch,
|
mutate only from a `branches/` worktree after proving root, cwd, branch,
|
||||||
stable main-checkout branch, and session worktree path (no exceptions).
|
stable main-checkout branch, and session worktree path (no exceptions).
|
||||||
@@ -35,4 +31,4 @@
|
|||||||
Wiki publication, credential provisioning, and cross-repo mirror operations are
|
Wiki publication, credential provisioning, and cross-repo mirror operations are
|
||||||
operator-confirmed actions — see [Runbooks](Runbooks.md).
|
operator-confirmed actions — see [Runbooks](Runbooks.md).
|
||||||
|
|
||||||
Full Gitea-specific runbooks: `docs/llm-workflow-runbooks.md` in the repository.
|
Full Gitea-specific runbooks: `docs/llm-workflow-runbooks.md` in the repository.
|
||||||
@@ -92,8 +92,7 @@ _ALREADY_LANDED_GATE_FIRED_RE = re.compile(
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_ELIGIBLE_REVIEW_RE = re.compile(
|
_ELIGIBLE_REVIEW_RE = re.compile(
|
||||||
r"eligible for (?:review|merge)|ready for (?:review|merge)|"
|
r"eligible for (?:review|merge)|ready for (?:review|merge)|next eligible pr",
|
||||||
r"(?:next|oldest)\s+eligible\s+pr",
|
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_APPROVED_STATE_RE = re.compile(r"\bapproved?\b", re.IGNORECASE)
|
_APPROVED_STATE_RE = re.compile(r"\bapproved?\b", re.IGNORECASE)
|
||||||
@@ -122,37 +121,11 @@ _RECONCILE_STALE_FIELDS = (
|
|||||||
"scratch worktree used",
|
"scratch worktree used",
|
||||||
"review decision",
|
"review decision",
|
||||||
"merge result",
|
"merge result",
|
||||||
"issue lock proof",
|
|
||||||
"claim/comment status",
|
|
||||||
"workspace mutations",
|
|
||||||
)
|
)
|
||||||
# Issue #307: reconciliation handoffs must carry the full canonical schema.
|
|
||||||
_RECONCILE_REQUIRED_FIELDS = (
|
_RECONCILE_REQUIRED_FIELDS = (
|
||||||
"Task",
|
|
||||||
"Repo",
|
|
||||||
"Role/profile",
|
|
||||||
"Identity",
|
|
||||||
"Selected PR",
|
"Selected PR",
|
||||||
"PR live state",
|
|
||||||
"Candidate head SHA",
|
|
||||||
"Target branch",
|
|
||||||
"Target branch SHA",
|
|
||||||
"Ancestor proof",
|
|
||||||
"Linked issue",
|
|
||||||
"Linked issue live status",
|
|
||||||
"Eligibility class",
|
"Eligibility class",
|
||||||
"Capabilities proven",
|
"Linked issue live status",
|
||||||
"Missing capabilities",
|
|
||||||
"PR comments posted",
|
|
||||||
"Issue comments posted",
|
|
||||||
"PRs closed",
|
|
||||||
"Issues closed",
|
|
||||||
"Git ref mutations",
|
|
||||||
"Worktree mutations",
|
|
||||||
"MCP/Gitea mutations",
|
|
||||||
"External-state mutations",
|
|
||||||
"Read-only diagnostics",
|
|
||||||
"Blocker",
|
|
||||||
"Safe next action",
|
"Safe next action",
|
||||||
"No review/merge confirmation",
|
"No review/merge confirmation",
|
||||||
)
|
)
|
||||||
@@ -441,55 +414,19 @@ def _rule_reviewer_git_fetch_readonly(
|
|||||||
"classify git fetch under Git ref mutations, not read-only diagnostics",
|
"classify git fetch under Git ref mutations, not read-only diagnostics",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
if not git_ref_reported:
|
if not git_ref_reported and _GIT_FETCH_RE.search(text):
|
||||||
# Issue #307: an observed git fetch (report text or action log)
|
|
||||||
# must be listed under Git ref mutations.
|
|
||||||
return [
|
return [
|
||||||
validator_finding(
|
validator_finding(
|
||||||
"reviewer.git_fetch_readonly",
|
"reviewer.git_fetch_readonly",
|
||||||
"downgrade",
|
"downgrade",
|
||||||
"Git ref mutations",
|
"Git ref mutations",
|
||||||
"git fetch occurred without Git ref mutation classification",
|
"git fetch mentioned without Git ref mutation classification",
|
||||||
"record git fetch under Git ref mutations",
|
"record git fetch under Git ref mutations",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_validation_failure_history(
|
|
||||||
report_text: str,
|
|
||||||
*,
|
|
||||||
validation_session: dict | None = None,
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
from reviewer_validation_failure_history import (
|
|
||||||
assess_validation_failure_history_report,
|
|
||||||
)
|
|
||||||
|
|
||||||
session = validation_session or {}
|
|
||||||
observed = session.get("observed_failures") or []
|
|
||||||
if not observed:
|
|
||||||
return []
|
|
||||||
|
|
||||||
result = assess_validation_failure_history_report(
|
|
||||||
report_text,
|
|
||||||
validation_session=session,
|
|
||||||
)
|
|
||||||
if result.get("proven"):
|
|
||||||
return []
|
|
||||||
severity = "block" if result.get("violations") else "downgrade"
|
|
||||||
return [
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.validation_failure_history",
|
|
||||||
severity,
|
|
||||||
"Validation failure history",
|
|
||||||
reason,
|
|
||||||
result.get("safe_next_action")
|
|
||||||
or "document every observed validation failure before claiming pass",
|
|
||||||
)
|
|
||||||
for reason in (result.get("violations") or result.get("reasons") or ["incomplete"])
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
|
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
|
||||||
text = report_text or ""
|
text = report_text or ""
|
||||||
if not _BARE_PYTEST_RE.search(text):
|
if not _BARE_PYTEST_RE.search(text):
|
||||||
@@ -731,18 +668,10 @@ def _rule_reconcile_controller_handoff(report_text: str) -> list[dict[str, str]]
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _rule_reconcile_stale_author_fields(
|
def _rule_reconcile_stale_author_fields(report_text: str) -> list[dict[str, str]]:
|
||||||
report_text: str,
|
|
||||||
*,
|
|
||||||
session_pr_opened: bool = False,
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
text = (report_text or "").lower()
|
text = (report_text or "").lower()
|
||||||
findings = []
|
findings = []
|
||||||
for field in _RECONCILE_STALE_FIELDS:
|
for field in _RECONCILE_STALE_FIELDS:
|
||||||
if field == "pr number opened" and session_pr_opened:
|
|
||||||
# Issue #307: allowed only when a PR was actually opened
|
|
||||||
# in this session.
|
|
||||||
continue
|
|
||||||
if f"{field}:" in text:
|
if f"{field}:" in text:
|
||||||
findings.append(
|
findings.append(
|
||||||
validator_finding(
|
validator_finding(
|
||||||
@@ -898,7 +827,6 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_reviewer_mutation_categories,
|
_rule_reviewer_mutation_categories,
|
||||||
_rule_reviewer_git_fetch_readonly,
|
_rule_reviewer_git_fetch_readonly,
|
||||||
_rule_reviewer_validation_command,
|
_rule_reviewer_validation_command,
|
||||||
_rule_reviewer_validation_failure_history,
|
|
||||||
_rule_reviewer_validation_structured,
|
_rule_reviewer_validation_structured,
|
||||||
_rule_reviewer_linked_issue,
|
_rule_reviewer_linked_issue,
|
||||||
_rule_reviewer_baseline_on_failure,
|
_rule_reviewer_baseline_on_failure,
|
||||||
@@ -1004,8 +932,6 @@ def assess_final_report_validator(
|
|||||||
mutations_observed: bool = False,
|
mutations_observed: bool = False,
|
||||||
local_edits: bool = False,
|
local_edits: bool = False,
|
||||||
issue_filing_lock: dict | None = None,
|
issue_filing_lock: dict | None = None,
|
||||||
session_pr_opened: bool = False,
|
|
||||||
validation_session: dict | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Validate final-report text against task-specific proof rules (#327).
|
"""Validate final-report text against task-specific proof rules (#327).
|
||||||
|
|
||||||
@@ -1060,8 +986,6 @@ def assess_final_report_validator(
|
|||||||
"action_log": action_log,
|
"action_log": action_log,
|
||||||
"mutations_observed": mutations_observed,
|
"mutations_observed": mutations_observed,
|
||||||
"local_edits": local_edits,
|
"local_edits": local_edits,
|
||||||
"session_pr_opened": session_pr_opened,
|
|
||||||
"validation_session": validation_session,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||||
|
|||||||
+28
-461
@@ -21,7 +21,6 @@ import json
|
|||||||
import functools
|
import functools
|
||||||
import contextlib
|
import contextlib
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
|
|
||||||
# Mutation-authority record (#199, refs #194). Deliberately in-process, NOT a
|
# Mutation-authority record (#199, refs #194). Deliberately in-process, NOT a
|
||||||
@@ -398,28 +397,6 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
|
|||||||
_preflight_resolved_role = resolved_role
|
_preflight_resolved_role = resolved_role
|
||||||
|
|
||||||
|
|
||||||
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
|
|
||||||
"""#274: author mutations must run from a branches/ session worktree."""
|
|
||||||
if _preflight_resolved_role == "reviewer":
|
|
||||||
return
|
|
||||||
workspace = author_mutation_worktree.resolve_mutation_workspace(
|
|
||||||
worktree_path,
|
|
||||||
PROJECT_ROOT,
|
|
||||||
active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
|
|
||||||
author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
|
|
||||||
)
|
|
||||||
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
|
|
||||||
assessment = author_mutation_worktree.assess_author_mutation_worktree(
|
|
||||||
workspace_path=workspace,
|
|
||||||
project_root=PROJECT_ROOT,
|
|
||||||
current_branch=git_state.get("current_branch"),
|
|
||||||
)
|
|
||||||
if assessment["block"]:
|
|
||||||
raise RuntimeError(
|
|
||||||
author_mutation_worktree.format_author_mutation_worktree_error(assessment)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_preflight_purity(remote: str | None = None, worktree_path: str | None = None):
|
def verify_preflight_purity(remote: str | None = None, worktree_path: str | None = None):
|
||||||
"""Verify that identity and capability were verified prior to session edits."""
|
"""Verify that identity and capability were verified prior to session edits."""
|
||||||
global _preflight_reviewer_violation_files
|
global _preflight_reviewer_violation_files
|
||||||
@@ -449,33 +426,32 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
|
|||||||
"file edits before mutation (fail closed). "
|
"file edits before mutation (fail closed). "
|
||||||
f"{_format_preflight_workspace_details(details)}"
|
f"{_format_preflight_workspace_details(details)}"
|
||||||
)
|
)
|
||||||
else:
|
return
|
||||||
if _preflight_whoami_violation:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Pre-flight order violation: Workspace file edits occurred before "
|
|
||||||
f"gitea_whoami verification (fail closed). Offending files: "
|
|
||||||
f"{_format_preflight_files(_preflight_whoami_violation_files)}"
|
|
||||||
)
|
|
||||||
if _preflight_capability_violation:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Pre-flight order violation: Workspace file edits occurred before "
|
|
||||||
f"gitea_resolve_task_capability verification (fail closed). Offending files: "
|
|
||||||
f"{_format_preflight_files(_preflight_capability_violation_files)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if _preflight_resolved_role == "reviewer":
|
if _preflight_whoami_violation:
|
||||||
current = _get_workspace_porcelain()
|
raise RuntimeError(
|
||||||
baseline = _preflight_capability_baseline_porcelain or ""
|
"Pre-flight order violation: Workspace file edits occurred before "
|
||||||
reviewer_delta = _new_tracked_changes_since(baseline, current)
|
f"gitea_whoami verification (fail closed). Offending files: "
|
||||||
_preflight_reviewer_violation_files = reviewer_delta
|
f"{_format_preflight_files(_preflight_whoami_violation_files)}"
|
||||||
if reviewer_delta:
|
)
|
||||||
raise RuntimeError(
|
if _preflight_capability_violation:
|
||||||
"Reviewer role violation: Reviewer profile is forbidden from modifying "
|
raise RuntimeError(
|
||||||
"tracked workspace files (fail closed). Offending files: "
|
"Pre-flight order violation: Workspace file edits occurred before "
|
||||||
f"{_format_preflight_files(reviewer_delta)}"
|
f"gitea_resolve_task_capability verification (fail closed). Offending files: "
|
||||||
)
|
f"{_format_preflight_files(_preflight_capability_violation_files)}"
|
||||||
|
)
|
||||||
|
|
||||||
_enforce_branches_only_author_mutation(worktree_path)
|
if _preflight_resolved_role == "reviewer":
|
||||||
|
current = _get_workspace_porcelain()
|
||||||
|
baseline = _preflight_capability_baseline_porcelain or ""
|
||||||
|
reviewer_delta = _new_tracked_changes_since(baseline, current)
|
||||||
|
_preflight_reviewer_violation_files = reviewer_delta
|
||||||
|
if reviewer_delta:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Reviewer role violation: Reviewer profile is forbidden from modifying "
|
||||||
|
"tracked workspace files (fail closed). Offending files: "
|
||||||
|
f"{_format_preflight_files(reviewer_delta)}"
|
||||||
|
)
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP # noqa: E402
|
from mcp.server.fastmcp import FastMCP # noqa: E402
|
||||||
|
|
||||||
@@ -501,11 +477,8 @@ import review_proofs # noqa: E402
|
|||||||
import agent_temp_artifacts
|
import agent_temp_artifacts
|
||||||
import issue_lock_worktree # noqa: E402
|
import issue_lock_worktree # noqa: E402
|
||||||
import already_landed_reconcile # noqa: E402
|
import already_landed_reconcile # noqa: E402
|
||||||
import author_mutation_worktree # noqa: E402
|
|
||||||
import issue_claim_heartbeat # noqa: E402
|
import issue_claim_heartbeat # noqa: E402
|
||||||
import merged_cleanup_reconcile # noqa: E402
|
import merged_cleanup_reconcile # noqa: E402
|
||||||
import reconciler_profile # noqa: E402
|
|
||||||
import reconciliation_workflow # noqa: E402
|
|
||||||
import review_merge_state_machine # noqa: E402
|
import review_merge_state_machine # noqa: E402
|
||||||
import native_mcp_preference # noqa: E402
|
import native_mcp_preference # noqa: E402
|
||||||
|
|
||||||
@@ -513,132 +486,6 @@ import native_mcp_preference # noqa: E402
|
|||||||
# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
|
# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
|
||||||
# consumed by gitea_create_pr and scripts/worktree-start.
|
# consumed by gitea_create_pr and scripts/worktree-start.
|
||||||
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
|
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
|
||||||
WORK_LEASE_TTL_HOURS = 4
|
|
||||||
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
|
|
||||||
VALID_WORK_LEASE_OPERATIONS = frozenset({
|
|
||||||
AUTHOR_ISSUE_WORK_LEASE,
|
|
||||||
"review_pr_work",
|
|
||||||
"fix_pr_changes",
|
|
||||||
"cleanup_branch_work",
|
|
||||||
"issue_filing_work",
|
|
||||||
"recovery_work",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _work_lease_now() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _work_lease_timestamp(value: datetime) -> str:
|
|
||||||
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_work_lease_timestamp(value: str | None) -> datetime | None:
|
|
||||||
text = (value or "").strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _load_existing_issue_lock() -> dict | None:
|
|
||||||
if not os.path.exists(ISSUE_LOCK_FILE):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
return data if isinstance(data, dict) else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _work_lease_claimant(host: str | None) -> dict:
|
|
||||||
profile = get_profile()
|
|
||||||
username = _IDENTITY_CACHE.get(host) if host else None
|
|
||||||
if username is None and host and not _preflight_in_test_mode():
|
|
||||||
username = _authenticated_username(host)
|
|
||||||
return {
|
|
||||||
"username": username,
|
|
||||||
"profile": profile.get("profile_name"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_author_issue_work_lease(
|
|
||||||
*,
|
|
||||||
issue_number: int,
|
|
||||||
branch_name: str,
|
|
||||||
worktree_path: str,
|
|
||||||
host: str | None,
|
|
||||||
) -> dict:
|
|
||||||
created = _work_lease_now()
|
|
||||||
expires = created + timedelta(hours=WORK_LEASE_TTL_HOURS)
|
|
||||||
return {
|
|
||||||
"operation_type": AUTHOR_ISSUE_WORK_LEASE,
|
|
||||||
"issue_number": issue_number,
|
|
||||||
"pr_number": None,
|
|
||||||
"branch": branch_name,
|
|
||||||
"worktree_path": worktree_path,
|
|
||||||
"claimant": _work_lease_claimant(host),
|
|
||||||
"created_at": _work_lease_timestamp(created),
|
|
||||||
"expires_at": _work_lease_timestamp(expires),
|
|
||||||
"last_heartbeat_at": _work_lease_timestamp(created),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _active_work_lease_block(
|
|
||||||
existing_lock: dict | None,
|
|
||||||
*,
|
|
||||||
issue_number: int,
|
|
||||||
branch_name: str,
|
|
||||||
worktree_path: str,
|
|
||||||
operation_type: str,
|
|
||||||
) -> str | None:
|
|
||||||
if not existing_lock:
|
|
||||||
return None
|
|
||||||
existing_issue = existing_lock.get("issue_number")
|
|
||||||
existing_branch = existing_lock.get("branch_name")
|
|
||||||
existing_worktree = existing_lock.get("worktree_path")
|
|
||||||
lease = existing_lock.get("work_lease")
|
|
||||||
existing_operation = (
|
|
||||||
lease.get("operation_type")
|
|
||||||
if isinstance(lease, dict)
|
|
||||||
else AUTHOR_ISSUE_WORK_LEASE
|
|
||||||
)
|
|
||||||
if existing_issue != issue_number or existing_operation != operation_type:
|
|
||||||
return None
|
|
||||||
|
|
||||||
same_owner = (
|
|
||||||
existing_branch == branch_name
|
|
||||||
and os.path.realpath(str(existing_worktree or "")) == os.path.realpath(worktree_path)
|
|
||||||
)
|
|
||||||
expires_at = (
|
|
||||||
_parse_work_lease_timestamp(lease.get("expires_at"))
|
|
||||||
if isinstance(lease, dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if expires_at and expires_at <= _work_lease_now():
|
|
||||||
return (
|
|
||||||
f"Issue #{issue_number} has an expired {operation_type} lease on "
|
|
||||||
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
|
|
||||||
"Recovery review is required before takeover (fail closed)"
|
|
||||||
)
|
|
||||||
if same_owner:
|
|
||||||
return None
|
|
||||||
return (
|
|
||||||
f"Issue #{issue_number} already has an active {operation_type} lease on "
|
|
||||||
f"branch '{existing_branch}' from worktree '{existing_worktree}' "
|
|
||||||
"(fail closed)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _branch_entry_name(branch: dict | str) -> str:
|
|
||||||
if isinstance(branch, str):
|
|
||||||
return branch
|
|
||||||
if not isinstance(branch, dict):
|
|
||||||
return ""
|
|
||||||
return str(branch.get("name") or branch.get("ref") or "")
|
|
||||||
|
|
||||||
|
|
||||||
def _reveal_endpoints() -> bool:
|
def _reveal_endpoints() -> bool:
|
||||||
@@ -1086,16 +933,6 @@ def gitea_lock_issue(
|
|||||||
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
|
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
|
||||||
worktree_path, PROJECT_ROOT
|
worktree_path, PROJECT_ROOT
|
||||||
)
|
)
|
||||||
active_lease_block = _active_work_lease_block(
|
|
||||||
_load_existing_issue_lock(),
|
|
||||||
issue_number=issue_number,
|
|
||||||
branch_name=branch_name,
|
|
||||||
worktree_path=resolved_worktree,
|
|
||||||
operation_type=AUTHOR_ISSUE_WORK_LEASE,
|
|
||||||
)
|
|
||||||
if active_lease_block:
|
|
||||||
raise RuntimeError(active_lease_block)
|
|
||||||
|
|
||||||
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
|
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
|
||||||
verify_preflight_purity(remote, worktree_path=resolved_worktree)
|
verify_preflight_purity(remote, worktree_path=resolved_worktree)
|
||||||
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
||||||
@@ -1141,25 +978,6 @@ def gitea_lock_issue(
|
|||||||
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}) via Closes/Fixes reference (fail closed)"
|
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}) via Closes/Fixes reference (fail closed)"
|
||||||
)
|
)
|
||||||
|
|
||||||
branch_url = f"{repo_api_url(h, o, r)}/branches"
|
|
||||||
try:
|
|
||||||
branches = api_get_all(branch_url, auth)
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError(f"Could not list branches to verify issue lock: {e}")
|
|
||||||
for branch in branches:
|
|
||||||
name = _branch_entry_name(branch)
|
|
||||||
if expected_pattern in name:
|
|
||||||
raise ValueError(
|
|
||||||
f"Issue #{issue_number} already has matching branch '{name}' "
|
|
||||||
"(fail closed)"
|
|
||||||
)
|
|
||||||
|
|
||||||
work_lease = _build_author_issue_work_lease(
|
|
||||||
issue_number=issue_number,
|
|
||||||
branch_name=branch_name,
|
|
||||||
worktree_path=resolved_worktree,
|
|
||||||
host=h,
|
|
||||||
)
|
|
||||||
data = {
|
data = {
|
||||||
"issue_number": issue_number,
|
"issue_number": issue_number,
|
||||||
"branch_name": branch_name,
|
"branch_name": branch_name,
|
||||||
@@ -1167,7 +985,6 @@ def gitea_lock_issue(
|
|||||||
"org": o,
|
"org": o,
|
||||||
"repo": r,
|
"repo": r,
|
||||||
"worktree_path": resolved_worktree,
|
"worktree_path": resolved_worktree,
|
||||||
"work_lease": work_lease,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1188,7 +1005,6 @@ def gitea_lock_issue(
|
|||||||
"issue_number": issue_number,
|
"issue_number": issue_number,
|
||||||
"branch_name": branch_name,
|
"branch_name": branch_name,
|
||||||
"worktree_path": resolved_worktree,
|
"worktree_path": resolved_worktree,
|
||||||
"work_lease": work_lease,
|
|
||||||
}
|
}
|
||||||
if agent_artifacts:
|
if agent_artifacts:
|
||||||
result["warnings"] = [
|
result["warnings"] = [
|
||||||
@@ -1245,7 +1061,7 @@ def gitea_create_pr(
|
|||||||
)
|
)
|
||||||
if blocked:
|
if blocked:
|
||||||
return blocked
|
return blocked
|
||||||
verify_preflight_purity(remote, worktree_path=worktree_path)
|
verify_preflight_purity(remote)
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
|
||||||
# ── Issue Lock Validation (Issue #194 / #196) ──
|
# ── Issue Lock Validation (Issue #194 / #196) ──
|
||||||
@@ -3464,32 +3280,16 @@ def gitea_delete_branch(
|
|||||||
repo: Override the repository name.
|
repo: Override the repository name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with 'success' and 'message'; on a permission block,
|
dict with 'success' and 'message'.
|
||||||
'success'/'performed' False, 'reasons', and a structured
|
|
||||||
'permission_report' (#142) with no API call made.
|
|
||||||
"""
|
"""
|
||||||
gate_reasons = _profile_operation_gate("gitea.branch.delete")
|
|
||||||
if gate_reasons:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"performed": False,
|
|
||||||
"required_permission": "gitea.branch.delete",
|
|
||||||
"reasons": gate_reasons,
|
|
||||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
|
||||||
}
|
|
||||||
|
|
||||||
verify_preflight_purity(remote)
|
verify_preflight_purity(remote)
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
encoded_branch = urllib.parse.quote(branch, safe="")
|
encoded_branch = urllib.parse.quote(branch, safe="")
|
||||||
url = f"{repo_api_url(h, o, r)}/branches/{encoded_branch}"
|
url = f"{repo_api_url(h, o, r)}/branches/{encoded_branch}"
|
||||||
request_metadata = {
|
|
||||||
"branch": branch,
|
|
||||||
"required_permission": "gitea.branch.delete",
|
|
||||||
}
|
|
||||||
with _audited("delete_branch", host=h, remote=remote, org=o, repo=r,
|
with _audited("delete_branch", host=h, remote=remote, org=o, repo=r,
|
||||||
target_branch=branch, request_metadata=request_metadata):
|
target_branch=branch, request_metadata={"branch": branch}):
|
||||||
api_request("DELETE", url, auth)
|
api_request("DELETE", url, auth)
|
||||||
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
|
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
|
||||||
|
|
||||||
@@ -3629,178 +3429,6 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
return {"success": True, "performed": True, **report}
|
return {"success": True, "performed": True, **report}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def gitea_assess_already_landed_reconciliation(
|
|
||||||
pr_number: int,
|
|
||||||
target_branch: str = "master",
|
|
||||||
remote: str = "dadeschools",
|
|
||||||
host: str | None = None,
|
|
||||||
org: str | None = None,
|
|
||||||
repo: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Read-only: assess already-landed reconciliation for one open PR (#301).
|
|
||||||
|
|
||||||
Fetches the PR live, refreshes the target branch, checks ancestor proof,
|
|
||||||
and returns a capability plan for the active profile. Does not review,
|
|
||||||
approve, request changes, merge, or mutate Gitea state.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pr_number: Open PR to assess.
|
|
||||||
target_branch: Branch used for ancestry proof (default master).
|
|
||||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
|
||||||
host: Override the Gitea host.
|
|
||||||
org: Override the owner/organization.
|
|
||||||
repo: Override the repository name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict with eligibility proof, capability plan, and safe next action.
|
|
||||||
"""
|
|
||||||
read_block = _profile_operation_gate("gitea.read")
|
|
||||||
if read_block:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"performed": False,
|
|
||||||
"reasons": read_block,
|
|
||||||
"permission_report": _permission_block_report("gitea.read"),
|
|
||||||
}
|
|
||||||
|
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
|
||||||
auth = _auth(h)
|
|
||||||
pr = api_request("GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth)
|
|
||||||
|
|
||||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
|
||||||
pr=pr,
|
|
||||||
project_root=PROJECT_ROOT,
|
|
||||||
remote=remote,
|
|
||||||
target_branch=target_branch,
|
|
||||||
)
|
|
||||||
|
|
||||||
profile = get_profile()
|
|
||||||
capabilities = reconciliation_workflow.profile_reconciliation_capabilities(
|
|
||||||
profile.get("allowed_operations"),
|
|
||||||
profile.get("forbidden_operations"),
|
|
||||||
)
|
|
||||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
|
||||||
assessment=assessment,
|
|
||||||
capabilities=capabilities,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"performed": False,
|
|
||||||
"pr_number": pr_number,
|
|
||||||
"assessment": assessment,
|
|
||||||
"capabilities": capabilities,
|
|
||||||
"plan": plan,
|
|
||||||
"workflow_source": "skills/llm-project-workflow/workflows/reconcile-landed-pr.md",
|
|
||||||
"task_mode": "reconcile-landed-pr",
|
|
||||||
"review_merge_allowed": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def gitea_scan_already_landed_open_prs(
|
|
||||||
target_branch: str = "master",
|
|
||||||
limit: int = 50,
|
|
||||||
remote: str = "dadeschools",
|
|
||||||
host: str | None = None,
|
|
||||||
org: str | None = None,
|
|
||||||
repo: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Read-only: list open PRs already landed on the target branch (#301).
|
|
||||||
|
|
||||||
Traverses open PR inventory with pagination proof, assesses each candidate,
|
|
||||||
and returns PRs classified as ``ALREADY_LANDED_RECONCILE_REQUIRED``. Does
|
|
||||||
not review, approve, merge, or mutate Gitea state.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
target_branch: Branch used for ancestry proof (default master).
|
|
||||||
limit: Max open PRs to inspect across pages.
|
|
||||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
|
||||||
host: Override the Gitea host.
|
|
||||||
org: Override the owner/organization.
|
|
||||||
repo: Override the repository name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict with candidates, pagination metadata, and target branch SHA proof.
|
|
||||||
"""
|
|
||||||
read_block = _profile_operation_gate("gitea.read")
|
|
||||||
if read_block:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"performed": False,
|
|
||||||
"reasons": read_block,
|
|
||||||
"permission_report": _permission_block_report("gitea.read"),
|
|
||||||
}
|
|
||||||
|
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
|
||||||
auth = _auth(h)
|
|
||||||
url = f"{repo_api_url(h, o, r)}/pulls?state=open"
|
|
||||||
|
|
||||||
open_prs: list[dict] = []
|
|
||||||
current_page = 1
|
|
||||||
pages_fetched = 0
|
|
||||||
last_meta: dict = {}
|
|
||||||
per_page = min(50, max(1, int(limit)))
|
|
||||||
while pages_fetched < 100 and len(open_prs) < limit:
|
|
||||||
raw_page, last_meta = api_fetch_page(
|
|
||||||
url, auth, page=current_page, limit=per_page
|
|
||||||
)
|
|
||||||
pages_fetched += 1
|
|
||||||
remaining = limit - len(open_prs)
|
|
||||||
open_prs.extend(raw_page[:remaining])
|
|
||||||
if last_meta["is_final_page"] or len(open_prs) >= limit:
|
|
||||||
break
|
|
||||||
current_page += 1
|
|
||||||
|
|
||||||
pagination = {
|
|
||||||
"page": 1,
|
|
||||||
"per_page": per_page,
|
|
||||||
"returned_count": len(open_prs),
|
|
||||||
"has_more": False,
|
|
||||||
"next_page": None,
|
|
||||||
"is_final_page": bool(last_meta.get("is_final_page")),
|
|
||||||
"pages_fetched": pages_fetched,
|
|
||||||
"inventory_complete": bool(last_meta.get("is_final_page")),
|
|
||||||
"total_count": len(open_prs) if last_meta.get("is_final_page") else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
target_fetch = reconciliation_workflow.fetch_target_branch(
|
|
||||||
PROJECT_ROOT, remote, target_branch
|
|
||||||
)
|
|
||||||
|
|
||||||
candidates: list[dict] = []
|
|
||||||
for pr in open_prs:
|
|
||||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
|
||||||
pr=pr,
|
|
||||||
project_root=PROJECT_ROOT,
|
|
||||||
remote=remote,
|
|
||||||
target_branch=target_branch,
|
|
||||||
target_fetch=target_fetch,
|
|
||||||
)
|
|
||||||
if assessment.get("eligibility_class") == (
|
|
||||||
reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED
|
|
||||||
):
|
|
||||||
candidates.append(assessment)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"performed": False,
|
|
||||||
"target_branch": target_branch,
|
|
||||||
"target_branch_sha": target_fetch.get("target_branch_sha"),
|
|
||||||
"git_ref_mutations": (
|
|
||||||
[target_fetch["git_fetch_command"]]
|
|
||||||
if target_fetch.get("git_fetch_command")
|
|
||||||
else []
|
|
||||||
),
|
|
||||||
"candidates": candidates,
|
|
||||||
"candidate_count": len(candidates),
|
|
||||||
"pagination": pagination,
|
|
||||||
"task_mode": "reconcile-landed-pr",
|
|
||||||
"review_merge_allowed": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_reconcile_already_landed_pr(
|
def gitea_reconcile_already_landed_pr(
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
@@ -3980,6 +3608,7 @@ def gitea_reconcile_already_landed_pr(
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_close_issue(
|
def gitea_close_issue(
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
@@ -4489,15 +4118,12 @@ def gitea_create_issue_comment(
|
|||||||
def _role_kind(allowed, forbidden) -> str:
|
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;
|
|
||||||
'author' can create PRs / push branches; 'reviewer' can approve/merge;
|
'author' can create PRs / push branches; 'reviewer' can approve/merge;
|
||||||
'reconciler' can close already-landed PRs via ``gitea.pr.close`` without
|
'reconciler' can close already-landed PRs via ``gitea.pr.close`` without
|
||||||
review/author powers; 'mixed' can do both (a config smell — the
|
review/author powers; 'mixed' can do both (a config smell — the
|
||||||
reviewer-deadlock invariant forbids it in v2 configs); 'limited' can do
|
reviewer-deadlock invariant forbids it in v2 configs); 'limited' can do
|
||||||
neither.
|
neither.
|
||||||
"""
|
"""
|
||||||
if reconciler_profile.is_reconciler_profile(allowed, forbidden):
|
|
||||||
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")
|
review = can("gitea.pr.approve") or can("gitea.pr.merge")
|
||||||
@@ -4765,28 +4391,6 @@ _PROJECT_SKILLS = {
|
|||||||
"Do not merge unless the operator explicitly authorizes it.",
|
"Do not merge unless the operator explicitly authorizes it.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"gitea-reconcile-landed-pr": {
|
|
||||||
"description": "Reconcile open PRs whose heads are already on the "
|
|
||||||
"target branch without review or merge.",
|
|
||||||
"when_to_use": "An open PR blocks the review queue but its head SHA "
|
|
||||||
"is already an ancestor of master; use the dedicated "
|
|
||||||
"reconciliation path instead of review/merge.",
|
|
||||||
"required_operations": ["gitea.read"],
|
|
||||||
"status": "available",
|
|
||||||
"notes": "Load skills/llm-project-workflow/workflows/reconcile-landed-pr.md "
|
|
||||||
"first. PR close requires exact gitea.pr.close; review/merge "
|
|
||||||
"capabilities must not be used on this path.",
|
|
||||||
"steps": [
|
|
||||||
"Resolve task: gitea_resolve_task_capability(task='reconcile_landed_pr').",
|
|
||||||
"Load canonical workflow via mcp_get_skill_guide or "
|
|
||||||
"skills/llm-project-workflow/workflows/reconcile-landed-pr.md.",
|
|
||||||
"Scan queue: gitea_scan_already_landed_open_prs (pagination proof).",
|
|
||||||
"Assess one PR: gitea_assess_already_landed_reconciliation.",
|
|
||||||
"Follow the plan outcome: full close only with gitea.pr.close, "
|
|
||||||
"comment-then-stop when close is missing, recovery handoff otherwise.",
|
|
||||||
"Never approve, request changes, or merge on this path.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"gitea-pr-merge": {
|
"gitea-pr-merge": {
|
||||||
"description": "Gated merge of an approved pull request.",
|
"description": "Gated merge of an approved pull request.",
|
||||||
"when_to_use": "Reviewer/merger profile with explicit operator "
|
"when_to_use": "Reviewer/merger profile with explicit operator "
|
||||||
@@ -5311,7 +4915,6 @@ _RUNTIME_CAPABILITY_TASKS = (
|
|||||||
"create_issue",
|
"create_issue",
|
||||||
"comment_issue",
|
"comment_issue",
|
||||||
"create_pr",
|
"create_pr",
|
||||||
"work_issue",
|
|
||||||
"review_pr",
|
"review_pr",
|
||||||
"merge_pr",
|
"merge_pr",
|
||||||
"close_issue",
|
"close_issue",
|
||||||
@@ -5364,7 +4967,6 @@ def _build_runtime_task_capabilities(
|
|||||||
"create_issue": "can_create_issues",
|
"create_issue": "can_create_issues",
|
||||||
"comment_issue": "can_comment_on_issues",
|
"comment_issue": "can_comment_on_issues",
|
||||||
"create_pr": "can_author_prs",
|
"create_pr": "can_author_prs",
|
||||||
"work_issue": "can_work_issues",
|
|
||||||
"review_pr": "can_review_prs",
|
"review_pr": "can_review_prs",
|
||||||
"merge_pr": "can_merge_prs",
|
"merge_pr": "can_merge_prs",
|
||||||
"close_issue": "can_close_issues",
|
"close_issue": "can_close_issues",
|
||||||
@@ -5517,37 +5119,6 @@ def gitea_get_shell_health() -> dict:
|
|||||||
return native_mcp_preference.shell_health_status()
|
return native_mcp_preference.shell_health_status()
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def gitea_validate_review_final_report(
|
|
||||||
report_text: str,
|
|
||||||
review_decision_lock: dict | None = None,
|
|
||||||
linked_issue_lock: dict | None = None,
|
|
||||||
validation_report: dict | None = None,
|
|
||||||
action_log: list[dict] | None = None,
|
|
||||||
mutations_observed: bool = False,
|
|
||||||
local_edits: bool = False,
|
|
||||||
validation_session: dict | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Read-only: machine-validate reviewer final report schema before output (#391).
|
|
||||||
|
|
||||||
Composes the composable final-report validator with review-specific schema
|
|
||||||
gates (legacy fields, mutation categories, merge/issue claims, blocked
|
|
||||||
handoff replay, and narrative/handoff consistency).
|
|
||||||
"""
|
|
||||||
from review_final_report_schema import assess_review_final_report_schema
|
|
||||||
|
|
||||||
return assess_review_final_report_schema(
|
|
||||||
report_text,
|
|
||||||
review_decision_lock=review_decision_lock,
|
|
||||||
linked_issue_lock=linked_issue_lock,
|
|
||||||
validation_report=validation_report,
|
|
||||||
action_log=action_log,
|
|
||||||
mutations_observed=mutations_observed,
|
|
||||||
local_edits=local_edits,
|
|
||||||
validation_session=validation_session,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_get_runtime_context(
|
def gitea_get_runtime_context(
|
||||||
remote: str = "dadeschools",
|
remote: str = "dadeschools",
|
||||||
@@ -5666,10 +5237,6 @@ def gitea_get_runtime_context(
|
|||||||
"preflight_block_reasons": preflight["preflight_block_reasons"],
|
"preflight_block_reasons": preflight["preflight_block_reasons"],
|
||||||
"preflight_workspace": preflight.get("preflight_workspace"),
|
"preflight_workspace": preflight.get("preflight_workspace"),
|
||||||
"session_capabilities": session_capabilities,
|
"session_capabilities": session_capabilities,
|
||||||
"reconciler_profile_assessment": reconciler_profile.assess_reconciler_profile(
|
|
||||||
allowed, forbidden
|
|
||||||
),
|
|
||||||
"role_kind": _role_kind(allowed, forbidden),
|
|
||||||
"shell_health": native_mcp_preference.shell_health_status(),
|
"shell_health": native_mcp_preference.shell_health_status(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
"""Reconciler profile model for already-landed PR closure (#304).
|
|
||||||
|
|
||||||
Defines the narrowly scoped operation set for a dedicated reconciler profile
|
|
||||||
such as ``prgs-reconciler``. Close MCP tooling and ancestry gates ship in the
|
|
||||||
#310 stack; this module validates profile shape only.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import gitea_config
|
|
||||||
|
|
||||||
RECONCILER_REQUIRED_OPERATIONS = (
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.pr.close",
|
|
||||||
)
|
|
||||||
|
|
||||||
RECONCILER_RECOMMENDED_OPERATIONS = (
|
|
||||||
"gitea.pr.comment",
|
|
||||||
"gitea.issue.comment",
|
|
||||||
"gitea.issue.close",
|
|
||||||
)
|
|
||||||
|
|
||||||
RECONCILER_FORBIDDEN_OPERATIONS = (
|
|
||||||
"gitea.pr.approve",
|
|
||||||
"gitea.pr.merge",
|
|
||||||
"gitea.pr.review",
|
|
||||||
"gitea.pr.create",
|
|
||||||
"gitea.branch.push",
|
|
||||||
"gitea.repo.commit",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalized_allowed(allowed: list[str]) -> set[str]:
|
|
||||||
normalized: set[str] = set()
|
|
||||||
for entry in allowed or []:
|
|
||||||
try:
|
|
||||||
normalized.add(gitea_config.normalize_operation(entry))
|
|
||||||
except gitea_config.ConfigError:
|
|
||||||
continue
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _forbidden_in_allowed(allowed: list[str], ops: tuple[str, ...]) -> list[str]:
|
|
||||||
"""Return forbidden ops that are explicitly listed in *allowed*."""
|
|
||||||
allowed_n = _normalized_allowed(allowed)
|
|
||||||
present: list[str] = []
|
|
||||||
for op in ops:
|
|
||||||
try:
|
|
||||||
if gitea_config.normalize_operation(op) in allowed_n:
|
|
||||||
present.append(op)
|
|
||||||
except gitea_config.ConfigError:
|
|
||||||
continue
|
|
||||||
return present
|
|
||||||
|
|
||||||
|
|
||||||
def is_reconciler_profile(allowed: list[str], forbidden: list[str]) -> bool:
|
|
||||||
"""Return True when *allowed*/*forbidden* describe a reconciler profile."""
|
|
||||||
def can(op: str) -> bool:
|
|
||||||
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
|
||||||
|
|
||||||
if not can("gitea.pr.close"):
|
|
||||||
return False
|
|
||||||
if _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def assess_reconciler_profile(allowed: list[str], forbidden: list[str]) -> dict:
|
|
||||||
"""Validate reconciler profile operations (read-only, fail closed)."""
|
|
||||||
allowed = list(allowed or [])
|
|
||||||
forbidden = list(forbidden or [])
|
|
||||||
reasons: list[str] = []
|
|
||||||
|
|
||||||
for op in RECONCILER_REQUIRED_OPERATIONS:
|
|
||||||
ok, _ = gitea_config.check_operation(op, allowed, forbidden)
|
|
||||||
if not ok:
|
|
||||||
reasons.append(f"missing required operation {op}")
|
|
||||||
|
|
||||||
for op in _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS):
|
|
||||||
reasons.append(f"forbidden operation must not be allowed: {op}")
|
|
||||||
|
|
||||||
missing_recommended = [
|
|
||||||
op for op in RECONCILER_RECOMMENDED_OPERATIONS
|
|
||||||
if not gitea_config.check_operation(op, allowed, forbidden)[0]
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"is_reconciler_profile": is_reconciler_profile(allowed, forbidden),
|
|
||||||
"valid": not reasons and is_reconciler_profile(allowed, forbidden),
|
|
||||||
"reasons": reasons,
|
|
||||||
"missing_recommended_operations": missing_recommended,
|
|
||||||
"required_operations": list(RECONCILER_REQUIRED_OPERATIONS),
|
|
||||||
"forbidden_operations": list(RECONCILER_FORBIDDEN_OPERATIONS),
|
|
||||||
}
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
"""Already-landed PR reconciliation workflow helpers (#301).
|
|
||||||
|
|
||||||
Read-only assessment and capability planning for reconciling open PRs whose
|
|
||||||
heads are already ancestors of the target branch. Does not invoke review or
|
|
||||||
merge paths.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import gitea_config
|
|
||||||
from merged_cleanup_reconcile import extract_linked_issue, is_head_ancestor_of_ref
|
|
||||||
|
|
||||||
ELIGIBILITY_ALREADY_LANDED = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
|
||||||
ELIGIBILITY_NOT_LANDED = "NOT_ALREADY_LANDED"
|
|
||||||
ELIGIBILITY_STALE_TARGET = "TARGET_BRANCH_UNVERIFIED"
|
|
||||||
ELIGIBILITY_PR_NOT_OPEN = "PR_NOT_OPEN"
|
|
||||||
|
|
||||||
OUTCOME_FULL_RECONCILE = "FULL_RECONCILE_CLOSE_ALLOWED"
|
|
||||||
OUTCOME_PARTIAL_COMMENT = "PARTIAL_RECONCILE_COMMENT_THEN_STOP"
|
|
||||||
OUTCOME_RECOVERY_HANDOFF = "RECOVERY_HANDOFF_ONLY"
|
|
||||||
OUTCOME_NOT_LANDED = "NOT_LANDED_NO_ACTION"
|
|
||||||
OUTCOME_GATE_NOT_PROVEN = "GATE_NOT_PROVEN"
|
|
||||||
|
|
||||||
RECONCILE_WORKFLOW_MARKERS = (
|
|
||||||
"workflows/reconcile-landed-pr.md",
|
|
||||||
"reconcile-landed-pr.md",
|
|
||||||
)
|
|
||||||
|
|
||||||
RECONCILE_TASK_MARKERS = (
|
|
||||||
"reconcile-landed-pr",
|
|
||||||
"reconcile already-landed",
|
|
||||||
"reconcile_already_landed",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_target_branch(project_root: str, remote: str, branch: str) -> dict[str, Any]:
|
|
||||||
"""Fetch *branch* from *remote* and return the resolved SHA."""
|
|
||||||
ref = f"{remote}/{branch}"
|
|
||||||
fetch = subprocess.run(
|
|
||||||
["git", "-C", project_root, "fetch", remote, branch],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if fetch.returncode != 0:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"target_branch": branch,
|
|
||||||
"target_ref": ref,
|
|
||||||
"target_branch_sha": None,
|
|
||||||
"reasons": [
|
|
||||||
f"git fetch {remote} {branch} failed: "
|
|
||||||
f"{(fetch.stderr or fetch.stdout or '').strip()}"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
rev = subprocess.run(
|
|
||||||
["git", "-C", project_root, "rev-parse", ref],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if rev.returncode != 0:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"target_branch": branch,
|
|
||||||
"target_ref": ref,
|
|
||||||
"target_branch_sha": None,
|
|
||||||
"reasons": [
|
|
||||||
f"git rev-parse {ref} failed: "
|
|
||||||
f"{(rev.stderr or rev.stdout or '').strip()}"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"target_branch": branch,
|
|
||||||
"target_ref": ref,
|
|
||||||
"target_branch_sha": (rev.stdout or "").strip(),
|
|
||||||
"reasons": [],
|
|
||||||
"git_fetch_command": f"git fetch {remote} {branch}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def profile_reconciliation_capabilities(
|
|
||||||
allowed: list[str] | None,
|
|
||||||
forbidden: list[str] | None,
|
|
||||||
) -> dict[str, bool]:
|
|
||||||
"""Map active profile operations to reconciliation capabilities."""
|
|
||||||
allowed = allowed or []
|
|
||||||
forbidden = forbidden or []
|
|
||||||
|
|
||||||
def can(op: str) -> bool:
|
|
||||||
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"read": can("gitea.read"),
|
|
||||||
"comment_pr": can("gitea.pr.comment"),
|
|
||||||
"comment_issue": can("gitea.issue.comment"),
|
|
||||||
"close_pr": can("gitea.pr.close"),
|
|
||||||
"close_issue": can("gitea.issue.close"),
|
|
||||||
"review_pr": can("gitea.pr.review") or can("gitea.pr.approve"),
|
|
||||||
"merge_pr": can("gitea.pr.merge"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def assess_open_pr_reconciliation(
|
|
||||||
*,
|
|
||||||
pr: dict[str, Any],
|
|
||||||
project_root: str,
|
|
||||||
remote: str,
|
|
||||||
target_branch: str,
|
|
||||||
target_fetch: dict[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Assess whether an open PR is eligible for already-landed reconciliation."""
|
|
||||||
pr_number = int(pr.get("number") or 0)
|
|
||||||
pr_state = (pr.get("state") or "").strip().lower()
|
|
||||||
head = pr.get("head") or {}
|
|
||||||
head_sha = head.get("sha") if isinstance(head, dict) else None
|
|
||||||
head_ref = head.get("ref") if isinstance(head, dict) else None
|
|
||||||
base = pr.get("base") or {}
|
|
||||||
base_ref = base.get("ref") if isinstance(base, dict) else None
|
|
||||||
title = pr.get("title") or ""
|
|
||||||
body = pr.get("body") or ""
|
|
||||||
|
|
||||||
fetch_result = target_fetch or fetch_target_branch(
|
|
||||||
project_root, remote, target_branch
|
|
||||||
)
|
|
||||||
linked_issue = extract_linked_issue(title, body)
|
|
||||||
|
|
||||||
result: dict[str, Any] = {
|
|
||||||
"pr_number": pr_number,
|
|
||||||
"pr_state": pr_state,
|
|
||||||
"candidate_head_sha": head_sha,
|
|
||||||
"head_ref": head_ref,
|
|
||||||
"base_ref": base_ref or target_branch,
|
|
||||||
"target_branch": target_branch,
|
|
||||||
"target_branch_sha": fetch_result.get("target_branch_sha"),
|
|
||||||
"linked_issue": linked_issue,
|
|
||||||
"git_ref_mutations": [],
|
|
||||||
"reasons": [],
|
|
||||||
"review_merge_allowed": False,
|
|
||||||
}
|
|
||||||
if fetch_result.get("git_fetch_command"):
|
|
||||||
result["git_ref_mutations"].append(fetch_result["git_fetch_command"])
|
|
||||||
|
|
||||||
if pr_state != "open":
|
|
||||||
result["eligibility_class"] = ELIGIBILITY_PR_NOT_OPEN
|
|
||||||
result["ancestor_proof"] = None
|
|
||||||
result["reconciliation_allowed"] = False
|
|
||||||
result["reasons"].append(f"PR #{pr_number} state is {pr_state!r}, not open")
|
|
||||||
return result
|
|
||||||
|
|
||||||
if not fetch_result.get("success"):
|
|
||||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
|
||||||
result["ancestor_proof"] = None
|
|
||||||
result["reconciliation_allowed"] = False
|
|
||||||
result["reasons"].extend(fetch_result.get("reasons") or [])
|
|
||||||
return result
|
|
||||||
|
|
||||||
target_ref = fetch_result.get("target_ref") or f"{remote}/{target_branch}"
|
|
||||||
ancestor = is_head_ancestor_of_ref(project_root, head_sha, target_ref)
|
|
||||||
result["ancestor_proof"] = ancestor
|
|
||||||
|
|
||||||
if ancestor is None:
|
|
||||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
|
||||||
result["reconciliation_allowed"] = False
|
|
||||||
result["reasons"].append(
|
|
||||||
f"ancestor check failed for head {head_sha!r} against {target_ref}"
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
if ancestor:
|
|
||||||
result["eligibility_class"] = ELIGIBILITY_ALREADY_LANDED
|
|
||||||
result["reconciliation_allowed"] = True
|
|
||||||
return result
|
|
||||||
|
|
||||||
result["eligibility_class"] = ELIGIBILITY_NOT_LANDED
|
|
||||||
result["reconciliation_allowed"] = False
|
|
||||||
result["reasons"].append(
|
|
||||||
f"PR head {head_sha} is not an ancestor of {target_ref}"
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_reconciliation_plan(
|
|
||||||
*,
|
|
||||||
assessment: dict[str, Any],
|
|
||||||
capabilities: dict[str, bool] | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Map eligibility + profile capabilities to a reconciliation outcome."""
|
|
||||||
caps = capabilities or {}
|
|
||||||
reasons: list[str] = []
|
|
||||||
|
|
||||||
if not assessment.get("reconciliation_allowed"):
|
|
||||||
outcome = OUTCOME_NOT_LANDED
|
|
||||||
if assessment.get("eligibility_class") in {
|
|
||||||
ELIGIBILITY_STALE_TARGET,
|
|
||||||
ELIGIBILITY_PR_NOT_OPEN,
|
|
||||||
}:
|
|
||||||
outcome = OUTCOME_GATE_NOT_PROVEN
|
|
||||||
reasons.extend(assessment.get("reasons") or [])
|
|
||||||
return {
|
|
||||||
"outcome": outcome,
|
|
||||||
"close_pr_allowed": False,
|
|
||||||
"comment_pr_allowed": False,
|
|
||||||
"close_issue_allowed": False,
|
|
||||||
"comment_issue_allowed": False,
|
|
||||||
"review_merge_allowed": False,
|
|
||||||
"missing_capabilities": _missing_reconciliation_capabilities(caps),
|
|
||||||
"safe_next_action": (
|
|
||||||
"Do not reconcile via review/merge; repair missing proof first."
|
|
||||||
),
|
|
||||||
"reasons": reasons,
|
|
||||||
}
|
|
||||||
|
|
||||||
close_pr = bool(caps.get("close_pr"))
|
|
||||||
comment_pr = bool(caps.get("comment_pr"))
|
|
||||||
close_issue = bool(caps.get("close_issue"))
|
|
||||||
comment_issue = bool(caps.get("comment_issue"))
|
|
||||||
|
|
||||||
if caps.get("review_pr") or caps.get("merge_pr"):
|
|
||||||
reasons.append(
|
|
||||||
"reconciliation path must not use review/merge capabilities"
|
|
||||||
)
|
|
||||||
|
|
||||||
if close_pr:
|
|
||||||
outcome = OUTCOME_FULL_RECONCILE
|
|
||||||
safe_next_action = (
|
|
||||||
"Run reconciliation close via exact gitea.pr.close after proof; "
|
|
||||||
"do not approve or merge."
|
|
||||||
)
|
|
||||||
elif comment_pr:
|
|
||||||
outcome = OUTCOME_PARTIAL_COMMENT
|
|
||||||
safe_next_action = (
|
|
||||||
"Post one reconciliation comment with ancestor proof, then stop "
|
|
||||||
"for an authorized close profile."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
outcome = OUTCOME_RECOVERY_HANDOFF
|
|
||||||
safe_next_action = (
|
|
||||||
"Produce a recovery handoff naming missing gitea.pr.close and/or "
|
|
||||||
"gitea.pr.comment; do not loop through review/merge."
|
|
||||||
)
|
|
||||||
reasons.append("gitea.pr.close is not available in the active profile")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"outcome": outcome,
|
|
||||||
"close_pr_allowed": close_pr,
|
|
||||||
"comment_pr_allowed": comment_pr,
|
|
||||||
"close_issue_allowed": close_issue,
|
|
||||||
"comment_issue_allowed": comment_issue,
|
|
||||||
"review_merge_allowed": False,
|
|
||||||
"missing_capabilities": _missing_reconciliation_capabilities(caps),
|
|
||||||
"safe_next_action": safe_next_action,
|
|
||||||
"reasons": reasons,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _missing_reconciliation_capabilities(caps: dict[str, bool]) -> list[str]:
|
|
||||||
missing = []
|
|
||||||
if not caps.get("read"):
|
|
||||||
missing.append("gitea.read")
|
|
||||||
if not caps.get("close_pr"):
|
|
||||||
missing.append("gitea.pr.close")
|
|
||||||
if not caps.get("comment_pr"):
|
|
||||||
missing.append("gitea.pr.comment")
|
|
||||||
return missing
|
|
||||||
|
|
||||||
|
|
||||||
def assess_reconcile_workflow_source(report_text: str) -> dict[str, Any]:
|
|
||||||
"""Reconciliation reports must cite the canonical workflow file."""
|
|
||||||
lower = (report_text or "").lower()
|
|
||||||
reasons = []
|
|
||||||
if not any(marker in lower for marker in RECONCILE_WORKFLOW_MARKERS):
|
|
||||||
reasons.append(
|
|
||||||
"reconciliation report missing workflow source "
|
|
||||||
"(workflows/reconcile-landed-pr.md)"
|
|
||||||
)
|
|
||||||
if not any(marker in lower for marker in RECONCILE_TASK_MARKERS):
|
|
||||||
reasons.append(
|
|
||||||
"reconciliation report missing task mode declaration "
|
|
||||||
"(reconcile-landed-pr)"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"complete": not reasons,
|
|
||||||
"downgraded": bool(reasons),
|
|
||||||
"reasons": reasons,
|
|
||||||
}
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
"""Reviewer final-report schema verification before session output (#391).
|
|
||||||
|
|
||||||
Composes the composable ``assess_final_report_validator`` (#327) with
|
|
||||||
review-specific schema gates required before a reviewer session completes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from final_report_validator import (
|
|
||||||
assess_final_report_validator,
|
|
||||||
validator_finding,
|
|
||||||
_handoff_fields,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LEGACY_STALE_FIELDS = (
|
|
||||||
"pinned reviewed head",
|
|
||||||
"scratch worktree used",
|
|
||||||
"workspace mutations",
|
|
||||||
)
|
|
||||||
|
|
||||||
_REVIEWED_HEAD_RE = re.compile(
|
|
||||||
r"(?:pinned reviewed head|reviewed head sha)\s*:\s*([0-9a-f]{7,40})",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_VALIDATION_PASS_RE = re.compile(
|
|
||||||
r"validation\s*:\s*(?:pass|passed|strong|ok|green)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_MERGED_CLAIM_RE = re.compile(
|
|
||||||
r"\b(?:merged|merge result\s*:\s*merged|pr\s+#\d+\s+merged)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_MERGE_RESULT_RE = re.compile(r"merge result\s*:", re.IGNORECASE)
|
|
||||||
_ANCESTRY_PROOF_RE = re.compile(
|
|
||||||
r"(?:ancestor proof|target branch sha|merge commit)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_ISSUE_CLOSED_RE = re.compile(
|
|
||||||
r"(?:linked issue(?:\s+live)?\s+status\s*:\s*closed|issue\s+#\d+\s+closed)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_LIVE_ISSUE_PROOF_RE = re.compile(
|
|
||||||
r"gitea_view_issue|(?:issue|linked issue).{0,40}fetched live|live fetch proof",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_FULL_SUITE_PASS_RE = re.compile(
|
|
||||||
r"(?:full suite passed|full test suite passed|\d+\s+passed,\s*0\s+failed)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_TESTS_IGNORED_RE = re.compile(
|
|
||||||
r"(?:ignored tests|tests?\s+ignored|skipped tests|not run|tests?\s+skipped)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_BLOCKED_HANDOFF_RE = re.compile(
|
|
||||||
r"(?:blocker\s*:|recovery handoff|infra_stop|capability stop|mutation blocked)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_REPLAY_COMMAND_RE = re.compile(
|
|
||||||
r"(?:gitea_submit_pr_review|gitea_merge_pr|gitea_review_pr|"
|
|
||||||
r"submitted\s+['\"]approve['\"]|replay approve|replay merge)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_PENDING_RE = re.compile(r"\bPENDING\b", re.IGNORECASE)
|
|
||||||
_APPROVED_RE = re.compile(
|
|
||||||
r"(?:review decision\s*:\s*approve|submitted\s+approve|official review submitted)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_DRY_RUN_RE = re.compile(r"dry[- ]run", re.IGNORECASE)
|
|
||||||
_FINDING_READY_RE = re.compile(
|
|
||||||
r"(?:finding ready|ready to submit|not yet submitted)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_NARRATIVE_APPROVE_RE = re.compile(
|
|
||||||
r"(?:^|\n)\s*(?:##\s+)?(?:summary|verdict|recommendation)\b[^\n]*\bapprove\b",
|
|
||||||
re.IGNORECASE | re.MULTILINE,
|
|
||||||
)
|
|
||||||
_HANDOFF_DECISION_RE = re.compile(
|
|
||||||
r"review decision\s*:\s*(\w+)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_legacy_stale_fields(report_text: str) -> list[dict[str, str]]:
|
|
||||||
fields = _handoff_fields(report_text)
|
|
||||||
findings: list[dict[str, str]] = []
|
|
||||||
for stale in _LEGACY_STALE_FIELDS:
|
|
||||||
if stale in fields and fields[stale].lower() not in {"", "none", "n/a"}:
|
|
||||||
findings.append(
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.legacy_stale_field",
|
|
||||||
"block",
|
|
||||||
stale.title(),
|
|
||||||
f"legacy or stale handoff field '{stale}' must not appear in "
|
|
||||||
"canonical reviewer final reports",
|
|
||||||
"remove stale fields; use Candidate head SHA and precise "
|
|
||||||
"mutation categories instead",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewed_head_without_validation(report_text: str) -> list[dict[str, str]]:
|
|
||||||
text = report_text or ""
|
|
||||||
if not _REVIEWED_HEAD_RE.search(text):
|
|
||||||
return []
|
|
||||||
if _VALIDATION_PASS_RE.search(text):
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.reviewed_head_without_validation",
|
|
||||||
"block",
|
|
||||||
"Pinned reviewed head",
|
|
||||||
"reviewed/pinned head SHA reported without validation pass proof",
|
|
||||||
"document validation command, cwd, and pass result before pinning head SHA",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_merged_without_proof(report_text: str) -> list[dict[str, str]]:
|
|
||||||
text = report_text or ""
|
|
||||||
if not _MERGED_CLAIM_RE.search(text):
|
|
||||||
return []
|
|
||||||
if _MERGE_RESULT_RE.search(text) and _ANCESTRY_PROOF_RE.search(text):
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.merged_without_proof",
|
|
||||||
"block",
|
|
||||||
"Merge result",
|
|
||||||
"merged outcome claimed without merge result and target ancestry proof",
|
|
||||||
"include Merge result, target branch SHA, and ancestor proof before claiming merged",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_issue_closed_without_live_proof(report_text: str) -> list[dict[str, str]]:
|
|
||||||
text = report_text or ""
|
|
||||||
if not _ISSUE_CLOSED_RE.search(text):
|
|
||||||
return []
|
|
||||||
fields = _handoff_fields(text)
|
|
||||||
status_value = fields.get("linked issue live status", "")
|
|
||||||
readonly_value = fields.get("read-only diagnostics", "")
|
|
||||||
proof_blob = f"{status_value} {readonly_value}".lower()
|
|
||||||
if _LIVE_ISSUE_PROOF_RE.search(proof_blob):
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.issue_closed_without_live_proof",
|
|
||||||
"block",
|
|
||||||
"Linked issue",
|
|
||||||
"issue closed claimed without live linked-issue verification proof",
|
|
||||||
"fetch linked issue live (gitea_view_issue) and record Linked issue live status",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_full_suite_pass_ignored_tests(report_text: str) -> list[dict[str, str]]:
|
|
||||||
text = report_text or ""
|
|
||||||
if not (_FULL_SUITE_PASS_RE.search(text) and _TESTS_IGNORED_RE.search(text)):
|
|
||||||
return []
|
|
||||||
if re.search(r"explicitly\s+(?:ignored|skipped)", text, re.IGNORECASE):
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.full_suite_pass_ignored_tests",
|
|
||||||
"block",
|
|
||||||
"Validation",
|
|
||||||
"full suite pass claimed while tests were ignored or skipped without "
|
|
||||||
"explicit disclosure",
|
|
||||||
"state which tests were ignored/skipped or downgrade validation verdict",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_blocked_handoff_replay(report_text: str) -> list[dict[str, str]]:
|
|
||||||
text = report_text or ""
|
|
||||||
if not _BLOCKED_HANDOFF_RE.search(text):
|
|
||||||
return []
|
|
||||||
if not _REPLAY_COMMAND_RE.search(text):
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.blocked_handoff_replay",
|
|
||||||
"block",
|
|
||||||
"Safe next action",
|
|
||||||
"blocked recovery handoff includes direct approve or merge replay commands",
|
|
||||||
"restart the full workflow after the blocker clears; do not replay mutations",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_narrative_handoff_drift(report_text: str) -> list[dict[str, str]]:
|
|
||||||
text = report_text or ""
|
|
||||||
narrative_approve = bool(_NARRATIVE_APPROVE_RE.search(text))
|
|
||||||
decision_match = _HANDOFF_DECISION_RE.search(text)
|
|
||||||
if not narrative_approve or not decision_match:
|
|
||||||
return []
|
|
||||||
handoff_decision = decision_match.group(1).strip().lower()
|
|
||||||
if handoff_decision in {"approve", "approved"}:
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.narrative_handoff_drift",
|
|
||||||
"block",
|
|
||||||
"Review decision",
|
|
||||||
f"narrative approves PR but controller handoff says '{handoff_decision}'",
|
|
||||||
"align narrative summary with controller handoff Review decision field",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_review_state_ambiguous(report_text: str) -> list[dict[str, str]]:
|
|
||||||
text = report_text or ""
|
|
||||||
findings: list[dict[str, str]] = []
|
|
||||||
if _PENDING_RE.search(text) and _APPROVED_RE.search(text):
|
|
||||||
findings.append(
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.pending_vs_approved",
|
|
||||||
"block",
|
|
||||||
"Review decision",
|
|
||||||
"report conflates PENDING review state with APPROVED outcome",
|
|
||||||
"distinguish official submitted review from pending or ready-to-submit state",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if _DRY_RUN_RE.search(text) and _APPROVED_RE.search(text):
|
|
||||||
if "dry-run only" not in text.lower():
|
|
||||||
findings.append(
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.dry_run_vs_submitted",
|
|
||||||
"block",
|
|
||||||
"Review mutations",
|
|
||||||
"dry-run language mixed with official approve/submitted claims",
|
|
||||||
"mark dry-run explicitly or document the live review mutation proof",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if _FINDING_READY_RE.search(text) and _APPROVED_RE.search(text):
|
|
||||||
findings.append(
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.finding_ready_vs_submitted",
|
|
||||||
"downgrade",
|
|
||||||
"Review decision",
|
|
||||||
"finding-ready wording combined with submitted-approve claims",
|
|
||||||
"state whether review was submitted live or only prepared for submission",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
_SCHEMA_RULES = (
|
|
||||||
_rule_legacy_stale_fields,
|
|
||||||
_rule_reviewed_head_without_validation,
|
|
||||||
_rule_merged_without_proof,
|
|
||||||
_rule_issue_closed_without_live_proof,
|
|
||||||
_rule_full_suite_pass_ignored_tests,
|
|
||||||
_rule_blocked_handoff_replay,
|
|
||||||
_rule_narrative_handoff_drift,
|
|
||||||
_rule_review_state_ambiguous,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_validator_results(
|
|
||||||
base: dict[str, Any],
|
|
||||||
extra_findings: list[dict[str, str]],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
findings = list(base.get("findings") or []) + extra_findings
|
|
||||||
blocked = base.get("blocked") or any(f["severity"] == "block" for f in extra_findings)
|
|
||||||
downgraded = base.get("downgraded") or any(
|
|
||||||
f["severity"] == "downgrade" for f in extra_findings
|
|
||||||
)
|
|
||||||
grade = base.get("grade", "A")
|
|
||||||
if blocked:
|
|
||||||
grade = "blocked"
|
|
||||||
elif downgraded and grade == "A":
|
|
||||||
grade = "downgraded"
|
|
||||||
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
|
|
||||||
safe_next = base.get("safe_next_action") or "none"
|
|
||||||
if extra_findings:
|
|
||||||
safe_next = extra_findings[0].get("safe_next_action", safe_next)
|
|
||||||
return {
|
|
||||||
**base,
|
|
||||||
"grade": grade,
|
|
||||||
"blocked": blocked,
|
|
||||||
"downgraded": downgraded,
|
|
||||||
"findings": findings,
|
|
||||||
"reasons": reasons,
|
|
||||||
"safe_next_action": safe_next,
|
|
||||||
"complete": grade == "A",
|
|
||||||
"schema_rules_applied": len(_SCHEMA_RULES),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def assess_review_final_report_schema(
|
|
||||||
report_text: str,
|
|
||||||
*,
|
|
||||||
review_decision_lock: dict | None = None,
|
|
||||||
linked_issue_lock: dict | None = None,
|
|
||||||
validation_report: dict | None = None,
|
|
||||||
action_log: list[dict] | None = None,
|
|
||||||
mutations_observed: bool = False,
|
|
||||||
local_edits: bool = False,
|
|
||||||
validation_session: dict | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Validate reviewer final report text before session completion (#391)."""
|
|
||||||
base = assess_final_report_validator(
|
|
||||||
report_text,
|
|
||||||
"review_pr",
|
|
||||||
review_decision_lock=review_decision_lock,
|
|
||||||
linked_issue_lock=linked_issue_lock,
|
|
||||||
validation_report=validation_report,
|
|
||||||
action_log=action_log,
|
|
||||||
mutations_observed=mutations_observed,
|
|
||||||
local_edits=local_edits,
|
|
||||||
validation_session=validation_session,
|
|
||||||
)
|
|
||||||
extra: list[dict[str, str]] = []
|
|
||||||
for rule in _SCHEMA_RULES:
|
|
||||||
extra.extend(rule(report_text))
|
|
||||||
return _merge_validator_results(base, extra)
|
|
||||||
@@ -18,11 +18,6 @@ import hashlib
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
import issue_duplicate_gate
|
import issue_duplicate_gate
|
||||||
from reconciliation_workflow import (
|
|
||||||
RECONCILE_TASK_MARKERS,
|
|
||||||
RECONCILE_WORKFLOW_MARKERS,
|
|
||||||
assess_reconcile_workflow_source,
|
|
||||||
)
|
|
||||||
from reviewer_worktree import assess_reviewer_worktree_proof
|
from reviewer_worktree import assess_reviewer_worktree_proof
|
||||||
|
|
||||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
|
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
|
||||||
@@ -1735,11 +1730,6 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
|
|||||||
if report_text
|
if report_text
|
||||||
else {"proven": True, "block": False, "reasons": []}
|
else {"proven": True, "block": False, "reasons": []}
|
||||||
)
|
)
|
||||||
proof_backed_handoff = (
|
|
||||||
assess_proof_backed_handoff_report(report_text)
|
|
||||||
if report_text and _PROOF_BACKED_HANDOFF_HINT.search(report_text)
|
|
||||||
else {"proven": True, "block": False, "reasons": []}
|
|
||||||
)
|
|
||||||
if baseline_validation is None and report_text:
|
if baseline_validation is None and report_text:
|
||||||
baseline_validation = assess_reviewer_baseline_validation_proof(
|
baseline_validation = assess_reviewer_baseline_validation_proof(
|
||||||
report_text=report_text,
|
report_text=report_text,
|
||||||
@@ -1752,17 +1742,6 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
|
|||||||
"reasons": [],
|
"reasons": [],
|
||||||
"violations": [],
|
"violations": [],
|
||||||
}
|
}
|
||||||
if report_text:
|
|
||||||
already_landed_classification = assess_already_landed_classification_report(
|
|
||||||
report_text
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
already_landed_classification = {
|
|
||||||
"proven": True,
|
|
||||||
"block": False,
|
|
||||||
"reasons": [],
|
|
||||||
"violations": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
contamination_status = contamination.get("status", "unknown")
|
contamination_status = contamination.get("status", "unknown")
|
||||||
checkout_proven = bool(checkout_proof.get("proven"))
|
checkout_proven = bool(checkout_proof.get("proven"))
|
||||||
@@ -1942,16 +1921,6 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
|
|||||||
"reviewer baseline validation proof missing or failed (#325)"
|
"reviewer baseline validation proof missing or failed (#325)"
|
||||||
)
|
)
|
||||||
downgrade_reasons.extend(baseline_validation.get("reasons", []))
|
downgrade_reasons.extend(baseline_validation.get("reasons", []))
|
||||||
if not proof_backed_handoff.get("proven"):
|
|
||||||
downgrade_reasons.append(
|
|
||||||
"proof-sensitive handoff claims lack command/tool evidence (#395)"
|
|
||||||
)
|
|
||||||
downgrade_reasons.extend(proof_backed_handoff.get("reasons", []))
|
|
||||||
if not already_landed_classification.get("proven"):
|
|
||||||
downgrade_reasons.append(
|
|
||||||
"already-landed classification wording missing or invalid (#295)"
|
|
||||||
)
|
|
||||||
downgrade_reasons.extend(already_landed_classification.get("reasons", []))
|
|
||||||
|
|
||||||
merge_allowed = (
|
merge_allowed = (
|
||||||
identity_eligible
|
identity_eligible
|
||||||
@@ -2066,12 +2035,6 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
|
|||||||
"baseline_validation_violations": list(
|
"baseline_validation_violations": list(
|
||||||
baseline_validation.get("violations") or []
|
baseline_validation.get("violations") or []
|
||||||
),
|
),
|
||||||
"already_landed_classification_proven": bool(
|
|
||||||
already_landed_classification.get("proven")
|
|
||||||
),
|
|
||||||
"already_landed_classification_violations": list(
|
|
||||||
already_landed_classification.get("reasons") or []
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2338,30 +2301,6 @@ CREATE_ISSUE_FORBIDDEN_CROSS_MODE_FIELDS = (
|
|||||||
("Terminal review mutation", ("terminal review mutation",)),
|
("Terminal review mutation", ("terminal review mutation",)),
|
||||||
)
|
)
|
||||||
|
|
||||||
WORK_ISSUE_WORKFLOW_MARKERS = (
|
|
||||||
"workflows/work-issue.md",
|
|
||||||
"work-issue.md",
|
|
||||||
)
|
|
||||||
|
|
||||||
WORK_ISSUE_TASK_MARKERS = (
|
|
||||||
"work-issue",
|
|
||||||
"work issue",
|
|
||||||
)
|
|
||||||
|
|
||||||
WORK_ISSUE_FORBIDDEN_CROSS_MODE_FIELDS = (
|
|
||||||
("Selected PR", ("selected pr",)),
|
|
||||||
("Review decision", ("review decision",)),
|
|
||||||
("Merge result", ("merge result",)),
|
|
||||||
("Merge preflight", ("merge preflight",)),
|
|
||||||
("Already-landed gate", ("already-landed gate",)),
|
|
||||||
("Pinned reviewed head", ("pinned reviewed head",)),
|
|
||||||
("Terminal review mutation", ("terminal review mutation",)),
|
|
||||||
("Duplicate search terms", ("duplicate search terms",)),
|
|
||||||
("Issues created", ("issues created",)),
|
|
||||||
("Issues skipped as duplicates", ("issues skipped as duplicates",)),
|
|
||||||
("Requested issue task", ("requested issue task",)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _handoff_section_lines(report_text):
|
def _handoff_section_lines(report_text):
|
||||||
"""Return the lines of the Controller Handoff section, or None."""
|
"""Return the lines of the Controller Handoff section, or None."""
|
||||||
@@ -3554,104 +3493,6 @@ def assess_create_issue_workflow_source(report_text: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def assess_work_issue_workflow_source(report_text: str) -> dict:
|
|
||||||
"""#139: work-issue reports must cite the canonical workflow file."""
|
|
||||||
lower = (report_text or "").lower()
|
|
||||||
reasons = []
|
|
||||||
if not any(marker in lower for marker in WORK_ISSUE_WORKFLOW_MARKERS):
|
|
||||||
reasons.append(
|
|
||||||
"work-issue report missing workflow source "
|
|
||||||
"(workflows/work-issue.md)"
|
|
||||||
)
|
|
||||||
if not any(marker in lower for marker in WORK_ISSUE_TASK_MARKERS):
|
|
||||||
reasons.append(
|
|
||||||
"work-issue report missing task mode declaration (work-issue)"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"complete": not reasons,
|
|
||||||
"downgraded": bool(reasons),
|
|
||||||
"reasons": reasons,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def assess_work_issue_mode_isolation(report_text: str) -> dict:
|
|
||||||
"""#139: reject review-merge/create-issue handoff fields in work-issue runs."""
|
|
||||||
section = _handoff_section_lines(report_text)
|
|
||||||
reasons = []
|
|
||||||
if section is None:
|
|
||||||
return {
|
|
||||||
"complete": True,
|
|
||||||
"downgraded": False,
|
|
||||||
"reasons": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = []
|
|
||||||
for line in section:
|
|
||||||
stripped = line.strip().lstrip("-*").strip()
|
|
||||||
if ":" in stripped:
|
|
||||||
labels.append(stripped.split(":", 1)[0].strip().lower())
|
|
||||||
|
|
||||||
for name, aliases in WORK_ISSUE_FORBIDDEN_CROSS_MODE_FIELDS:
|
|
||||||
if any(
|
|
||||||
label.startswith(alias)
|
|
||||||
for label in labels
|
|
||||||
for alias in aliases
|
|
||||||
):
|
|
||||||
reasons.append(
|
|
||||||
f"work-issue handoff must not include cross-mode field "
|
|
||||||
f"'{name}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
legacy_workspace = any(
|
|
||||||
label.startswith("workspace mutations") for label in labels
|
|
||||||
)
|
|
||||||
precise_categories = any(
|
|
||||||
label.startswith("file edits by author")
|
|
||||||
for label in labels
|
|
||||||
)
|
|
||||||
if legacy_workspace and not precise_categories:
|
|
||||||
reasons.append(
|
|
||||||
"work-issue handoff must use precise mutation categories "
|
|
||||||
"instead of legacy 'Workspace mutations'"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"complete": not reasons,
|
|
||||||
"downgraded": bool(reasons),
|
|
||||||
"reasons": reasons,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def assess_work_issue_final_report(report_text: str) -> dict:
|
|
||||||
"""#139: composite verifier for work-issue final reports."""
|
|
||||||
checks = {
|
|
||||||
"workflow_source": assess_work_issue_workflow_source(report_text),
|
|
||||||
"mode_isolation": assess_work_issue_mode_isolation(report_text),
|
|
||||||
}
|
|
||||||
|
|
||||||
reasons = []
|
|
||||||
downgraded = False
|
|
||||||
for name, result in checks.items():
|
|
||||||
verdict = result.get("verdict")
|
|
||||||
if verdict in ("missing", "incomplete"):
|
|
||||||
downgraded = True
|
|
||||||
reasons.extend(result.get("reasons") or [])
|
|
||||||
elif result.get("downgraded") or not result.get("complete", True):
|
|
||||||
downgraded = True
|
|
||||||
reasons.extend(
|
|
||||||
f"{name}: {r}" for r in (result.get("reasons") or [])
|
|
||||||
)
|
|
||||||
|
|
||||||
grade = "A" if not downgraded else "downgraded"
|
|
||||||
return {
|
|
||||||
"grade": grade,
|
|
||||||
"downgraded": downgraded,
|
|
||||||
"checks": checks,
|
|
||||||
"reasons": reasons,
|
|
||||||
"complete": not downgraded,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def assess_create_issue_mode_isolation(report_text: str) -> dict:
|
def assess_create_issue_mode_isolation(report_text: str) -> dict:
|
||||||
"""#337: reject work-issue/review-merge handoff fields in create-issue runs."""
|
"""#337: reject work-issue/review-merge handoff fields in create-issue runs."""
|
||||||
section = _handoff_section_lines(report_text)
|
section = _handoff_section_lines(report_text)
|
||||||
@@ -5068,13 +4909,6 @@ def assess_final_report_validator(report_text, task_kind, **kwargs):
|
|||||||
return _validate(report_text, task_kind, **kwargs)
|
return _validate(report_text, task_kind, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def assess_review_final_report_schema(report_text, **kwargs):
|
|
||||||
"""#391: reviewer final-report schema verification before session output."""
|
|
||||||
from review_final_report_schema import assess_review_final_report_schema as _assess
|
|
||||||
|
|
||||||
return _assess(report_text, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
_QUEUE_STATUS_REPORT_HINT = re.compile(
|
_QUEUE_STATUS_REPORT_HINT = re.compile(
|
||||||
r"queue[- ]status|selected pr:\s*none|no pr selected|"
|
r"queue[- ]status|selected pr:\s*none|no pr selected|"
|
||||||
r"queue[- ]status[- ]only",
|
r"queue[- ]status[- ]only",
|
||||||
@@ -5098,12 +4932,6 @@ _PR_QUEUE_CLEANUP_REPORT_HINT = re.compile(
|
|||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
|
|
||||||
_PROOF_BACKED_HANDOFF_HINT = re.compile(
|
|
||||||
r"task:\s*review-merge-pr|task mode:\s*review-merge-pr|"
|
|
||||||
r"review-merge-pr|controller handoff",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def assess_pr_queue_cleanup_report(report_text: str | None) -> dict:
|
def assess_pr_queue_cleanup_report(report_text: str | None) -> dict:
|
||||||
"""#390: validate PR-only cleanup final reports (one PR per run)."""
|
"""#390: validate PR-only cleanup final reports (one PR per run)."""
|
||||||
@@ -5503,24 +5331,6 @@ def assess_validation_integrity_report(report_text, **kwargs):
|
|||||||
return _assess(report_text, **kwargs)
|
return _assess(report_text, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def assess_validation_failure_history_report(report_text, **kwargs):
|
|
||||||
"""#396: final reports must account for transient validation failures."""
|
|
||||||
from reviewer_validation_failure_history import (
|
|
||||||
assess_validation_failure_history_report as _assess,
|
|
||||||
)
|
|
||||||
|
|
||||||
return _assess(report_text, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def assess_already_landed_classification_report(report_text, **kwargs):
|
|
||||||
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
|
|
||||||
from reviewer_already_landed_classification import (
|
|
||||||
assess_already_landed_classification_report as _assess,
|
|
||||||
)
|
|
||||||
|
|
||||||
return _assess(report_text, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def assess_prior_blocker_skip_proof(report_text, **kwargs):
|
def assess_prior_blocker_skip_proof(report_text, **kwargs):
|
||||||
"""#318: require live blocker proof before skipping earlier open PRs."""
|
"""#318: require live blocker proof before skipping earlier open PRs."""
|
||||||
from reviewer_blocker_skip import assess_prior_blocker_skip_proof as _assess
|
from reviewer_blocker_skip import assess_prior_blocker_skip_proof as _assess
|
||||||
@@ -5591,10 +5401,3 @@ def assess_worktree_ownership_report(report_text, **kwargs):
|
|||||||
from reviewer_worktree_ownership import assess_worktree_ownership_report as _assess
|
from reviewer_worktree_ownership import assess_worktree_ownership_report as _assess
|
||||||
|
|
||||||
return _assess(report_text, **kwargs)
|
return _assess(report_text, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def assess_proof_backed_handoff_report(report_text, **kwargs):
|
|
||||||
"""#395: proof-sensitive review handoff claims require explicit evidence."""
|
|
||||||
from reviewer_proof_backed_handoff import assess_proof_backed_handoff_report as _assess
|
|
||||||
|
|
||||||
return _assess(report_text, **kwargs)
|
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
"""Already-landed PR classification verifier for reviewer reports (#295)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
ALREADY_LANDED_ELIGIBILITY_CLASS = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
|
||||||
|
|
||||||
_LANDED_CONTEXT_RE = re.compile(
|
|
||||||
r"already[- ]landed|ancestor proof\s*:\s*passed|"
|
|
||||||
r"eligibility class\s*:\s*already_landed",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_ELIGIBILITY_CLASS_LINE_RE = re.compile(
|
|
||||||
r"eligibility class\s*:\s*(.+)$",
|
|
||||||
re.IGNORECASE | re.MULTILINE,
|
|
||||||
)
|
|
||||||
_INELIGIBLE_SELECTION_RE = re.compile(
|
|
||||||
r"\b(?:next|oldest)\s+eligible\s+pr\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_REVIEW_ELIGIBLE_RE = re.compile(
|
|
||||||
r"\beligible for (?:review|merge)\b|\bready for (?:review|merge)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_PINNED_REVIEWED_HEAD_RE = re.compile(
|
|
||||||
r"\bpinned reviewed head\s*:",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_CANDIDATE_HEAD_RE = re.compile(
|
|
||||||
r"\bcandidate head sha\s*:",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_REVIEWED_HEAD_NONE_RE = re.compile(
|
|
||||||
r"\breviewed head sha\s*:\s*none\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_VALIDATION_PROOF_RE = re.compile(
|
|
||||||
r"(?:validation passed|pytest.*passed|diff review passed|"
|
|
||||||
r"validated on pinned head)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_QUEUE_BLOCKED_RE = re.compile(
|
|
||||||
r"queue blocked by already[- ]landed|"
|
|
||||||
r"reconciliation(?:-only)?\s+(?:next step|required)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _landed_context(report_text: str, eligibility_class: str | None) -> bool:
|
|
||||||
if (eligibility_class or "").strip().upper() == ALREADY_LANDED_ELIGIBILITY_CLASS:
|
|
||||||
return True
|
|
||||||
return bool(_LANDED_CONTEXT_RE.search(report_text or ""))
|
|
||||||
|
|
||||||
|
|
||||||
def assess_already_landed_classification_report(
|
|
||||||
report_text: str,
|
|
||||||
*,
|
|
||||||
eligibility_class: str | None = None,
|
|
||||||
selected_pr_already_landed: bool | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
|
|
||||||
text = report_text or ""
|
|
||||||
reasons: list[str] = []
|
|
||||||
|
|
||||||
landed = _landed_context(text, eligibility_class)
|
|
||||||
if selected_pr_already_landed is True:
|
|
||||||
landed = True
|
|
||||||
if not landed:
|
|
||||||
return {
|
|
||||||
"proven": True,
|
|
||||||
"block": False,
|
|
||||||
"landed_context": False,
|
|
||||||
"reasons": [],
|
|
||||||
"safe_next_action": "proceed",
|
|
||||||
}
|
|
||||||
|
|
||||||
if _INELIGIBLE_SELECTION_RE.search(text):
|
|
||||||
reasons.append(
|
|
||||||
"already-landed PR must not be described as oldest/next eligible PR; "
|
|
||||||
"use 'Oldest open PR requiring action' and "
|
|
||||||
f"'Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
if _REVIEW_ELIGIBLE_RE.search(text):
|
|
||||||
reasons.append(
|
|
||||||
"already-landed PR must not be described as eligible for review or merge"
|
|
||||||
)
|
|
||||||
|
|
||||||
class_match = _ELIGIBILITY_CLASS_LINE_RE.search(text)
|
|
||||||
if class_match:
|
|
||||||
declared = class_match.group(1).strip().upper()
|
|
||||||
if declared != ALREADY_LANDED_ELIGIBILITY_CLASS:
|
|
||||||
reasons.append(
|
|
||||||
"already-landed PR eligibility class must be "
|
|
||||||
f"{ALREADY_LANDED_ELIGIBILITY_CLASS}"
|
|
||||||
)
|
|
||||||
elif selected_pr_already_landed is True:
|
|
||||||
reasons.append(
|
|
||||||
f"already-landed selected PR must declare Eligibility class: "
|
|
||||||
f"{ALREADY_LANDED_ELIGIBILITY_CLASS}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if _PINNED_REVIEWED_HEAD_RE.search(text):
|
|
||||||
if not _VALIDATION_PROOF_RE.search(text):
|
|
||||||
reasons.append(
|
|
||||||
"already-landed pre-review report must use Candidate head SHA, "
|
|
||||||
"not Pinned reviewed head, unless validation and diff review passed"
|
|
||||||
)
|
|
||||||
|
|
||||||
if selected_pr_already_landed is True and not _CANDIDATE_HEAD_RE.search(text):
|
|
||||||
if _PINNED_REVIEWED_HEAD_RE.search(text) or "head sha" in text.lower():
|
|
||||||
reasons.append(
|
|
||||||
"already-landed ancestry check must report Candidate head SHA"
|
|
||||||
)
|
|
||||||
|
|
||||||
if selected_pr_already_landed is True and not _REVIEWED_HEAD_NONE_RE.search(text):
|
|
||||||
if _PINNED_REVIEWED_HEAD_RE.search(text) and not _VALIDATION_PROOF_RE.search(text):
|
|
||||||
reasons.append(
|
|
||||||
"already-landed report must state 'Reviewed head SHA: none' "
|
|
||||||
"when validation did not run"
|
|
||||||
)
|
|
||||||
|
|
||||||
if selected_pr_already_landed is True and not _QUEUE_BLOCKED_RE.search(text):
|
|
||||||
reasons.append(
|
|
||||||
"already-landed queue blocker must state reconciliation requirement "
|
|
||||||
"or queue blocked wording"
|
|
||||||
)
|
|
||||||
|
|
||||||
proven = not reasons
|
|
||||||
return {
|
|
||||||
"proven": proven,
|
|
||||||
"block": not proven,
|
|
||||||
"landed_context": True,
|
|
||||||
"reasons": reasons,
|
|
||||||
"safe_next_action": (
|
|
||||||
"classify as ALREADY_LANDED_RECONCILE_REQUIRED, use Candidate head SHA, "
|
|
||||||
"and stop normal review"
|
|
||||||
if not proven
|
|
||||||
else "proceed"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
"""Proof-backed reviewer handoff claim verifier (#395)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
_PAGINATION_FINALITY = re.compile(
|
|
||||||
r"has_more\s*:\s*false|is_final_page\s*:\s*true|"
|
|
||||||
r"inventory_complete\s*:\s*true|pages_fetched|total_count\s*:",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
_PAGE_SIZE_ONLY = re.compile(
|
|
||||||
r"(?:less|fewer) than (?:the )?(?:default )?page[- ]?size|"
|
|
||||||
r"under (?:the )?50[- ]?(?:item )?limit|"
|
|
||||||
r"returned \d+ (?:open )?prs?.*less than 50",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
_INVENTORY_COMPLETE_CLAIM = re.compile(
|
|
||||||
r"\b(?:inventory (?:is )?complete|inventory exhaustive|"
|
|
||||||
r"complete (?:pr )?inventory)\b",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
_SKIP_CLAIM = re.compile(
|
|
||||||
r"\b(?:earlier prs? skipped|skipped (?:earlier )?pr|"
|
|
||||||
r"skip(?:ped)? pr #?\d+|non-mergeable|prior request.changes)\b",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
_CONFLICT_PROOF = re.compile(
|
|
||||||
r"merge simulation|git merge --no-commit|conflicting files|"
|
|
||||||
r"conflict proof|merge_exit|non-mergeable",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
_BASELINE_CLAIM = re.compile(
|
|
||||||
r"\b(?:baseline (?:validation|worktree|comparison)|"
|
|
||||||
r"same as master|pre-existing (?:on )?master|"
|
|
||||||
r"failure signatures match)\b",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
_BASELINE_PATH = re.compile(r"baseline worktree path\s*:\s*(\S+)", re.I)
|
|
||||||
_BASELINE_SHA = re.compile(r"baseline (?:target )?sha\s*:\s*([0-9a-f]{7,40})", re.I)
|
|
||||||
_BASELINE_DIRTY_BEFORE = re.compile(
|
|
||||||
r"baseline.*dirty before|dirty before.*baseline", re.I
|
|
||||||
)
|
|
||||||
_BASELINE_DIRTY_AFTER = re.compile(
|
|
||||||
r"baseline.*dirty after|dirty after.*baseline", re.I
|
|
||||||
)
|
|
||||||
_BASELINE_COMMAND = re.compile(
|
|
||||||
r"baseline.*(?:validation )?command|pytest.*baseline", re.I
|
|
||||||
)
|
|
||||||
_BASELINE_RESULT = re.compile(
|
|
||||||
r"baseline.*(?:validation )?result|baseline_exit|baseline failures", re.I
|
|
||||||
)
|
|
||||||
_MASTER_INTEGRATION = re.compile(
|
|
||||||
r"\b(?:merged master into|merge(?:d)? (?:remote[- ]tracking )?branch.*master|"
|
|
||||||
r"master integration|integrated master|rebase.*master)\b",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
_CLEANUP_CLAIM = re.compile(
|
|
||||||
r"\b(?:worktree(?:s)? (?:were )?cleaned|cleanup (?:result|mutations)|"
|
|
||||||
r"removed (?:session[- ]owned )?worktree|git worktree remove)\b",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
_WORKTREE_LIST = re.compile(r"git worktree list|worktree list proof", re.I)
|
|
||||||
_PROOF_SOURCE = re.compile(
|
|
||||||
r"proof source\s*:\s*(command|mcp metadata|prior blocker|not checked)",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
_HANDOFF_SECTION = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
|
|
||||||
|
|
||||||
|
|
||||||
def _handoff_fields(report_text: str) -> dict[str, str]:
|
|
||||||
text = report_text or ""
|
|
||||||
match = _HANDOFF_SECTION.search(text)
|
|
||||||
if not match:
|
|
||||||
return {}
|
|
||||||
fields: dict[str, str] = {}
|
|
||||||
for line in text[match.end() :].splitlines():
|
|
||||||
stripped = line.strip().lstrip("-*").strip()
|
|
||||||
if ":" not in stripped:
|
|
||||||
continue
|
|
||||||
key, value = stripped.split(":", 1)
|
|
||||||
fields[key.strip().lower()] = value.strip()
|
|
||||||
return fields
|
|
||||||
|
|
||||||
|
|
||||||
def _command_text(entry: Any) -> str:
|
|
||||||
if isinstance(entry, dict):
|
|
||||||
return str(entry.get("command") or entry.get("tool") or "").strip()
|
|
||||||
return str(entry or "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _action_log_has(action_log: list | None, pattern: re.Pattern[str]) -> bool:
|
|
||||||
for entry in action_log or []:
|
|
||||||
blob = " ".join(
|
|
||||||
filter(
|
|
||||||
None,
|
|
||||||
[
|
|
||||||
_command_text(entry),
|
|
||||||
str(entry.get("result") or "") if isinstance(entry, dict) else "",
|
|
||||||
str(entry.get("reason") or "") if isinstance(entry, dict) else "",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if pattern.search(blob):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def assess_proof_backed_handoff_report(
|
|
||||||
report_text: str,
|
|
||||||
*,
|
|
||||||
action_log: list | None = None,
|
|
||||||
inventory_session: dict | None = None,
|
|
||||||
baseline_session: dict | None = None,
|
|
||||||
skip_session: list[dict] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Require explicit command/tool evidence for proof-sensitive review claims (#395)."""
|
|
||||||
text = report_text or ""
|
|
||||||
fields = _handoff_fields(text)
|
|
||||||
reasons: list[str] = []
|
|
||||||
inventory = dict(inventory_session or {})
|
|
||||||
baseline = dict(baseline_session or {})
|
|
||||||
skips = list(skip_session or [])
|
|
||||||
|
|
||||||
pagination_field = fields.get("inventory pagination proof", "")
|
|
||||||
if _INVENTORY_COMPLETE_CLAIM.search(text) or _PAGE_SIZE_ONLY.search(text):
|
|
||||||
has_meta = bool(
|
|
||||||
inventory.get("inventory_complete")
|
|
||||||
or inventory.get("pagination_complete")
|
|
||||||
or _PAGINATION_FINALITY.search(pagination_field)
|
|
||||||
or _PAGINATION_FINALITY.search(text)
|
|
||||||
or _action_log_has(action_log, re.compile(r"gitea_list_prs", re.I))
|
|
||||||
)
|
|
||||||
if _PAGE_SIZE_ONLY.search(text) and not has_meta:
|
|
||||||
reasons.append(
|
|
||||||
"inventory completeness claimed from page-size assumption only; "
|
|
||||||
"require has_more=false, is_final_page=true, or inventory_complete=true"
|
|
||||||
)
|
|
||||||
elif _INVENTORY_COMPLETE_CLAIM.search(text) and not has_meta:
|
|
||||||
reasons.append(
|
|
||||||
"inventory complete claim lacks explicit pagination metadata proof"
|
|
||||||
)
|
|
||||||
|
|
||||||
if _SKIP_CLAIM.search(text) or fields.get("earlier prs skipped", "").lower() not in {
|
|
||||||
"",
|
|
||||||
"none",
|
|
||||||
"n/a",
|
|
||||||
}:
|
|
||||||
skip_proof = bool(
|
|
||||||
skips
|
|
||||||
or _CONFLICT_PROOF.search(text)
|
|
||||||
or _action_log_has(
|
|
||||||
action_log, re.compile(r"git merge --no-commit|gitea_get_pr_review_feedback", re.I)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not skip_proof:
|
|
||||||
reasons.append(
|
|
||||||
"earlier PR skip claim lacks command/tool conflict or blocker proof"
|
|
||||||
)
|
|
||||||
|
|
||||||
if _BASELINE_CLAIM.search(text) or fields.get("baseline worktree used", "").lower() == "true":
|
|
||||||
baseline_ok = bool(
|
|
||||||
baseline.get("complete")
|
|
||||||
or (
|
|
||||||
_BASELINE_PATH.search(text)
|
|
||||||
and _BASELINE_SHA.search(text)
|
|
||||||
and (_BASELINE_DIRTY_BEFORE.search(text) or baseline.get("clean_before"))
|
|
||||||
and (_BASELINE_COMMAND.search(text) or baseline.get("command"))
|
|
||||||
and (_BASELINE_RESULT.search(text) or baseline.get("result"))
|
|
||||||
and (_BASELINE_DIRTY_AFTER.search(text) or baseline.get("clean_after"))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not baseline_ok:
|
|
||||||
reasons.append(
|
|
||||||
"baseline validation claim missing worktree path, target SHA, "
|
|
||||||
"dirty-before/after status, command, or result proof"
|
|
||||||
)
|
|
||||||
|
|
||||||
if _MASTER_INTEGRATION.search(text):
|
|
||||||
if not _action_log_has(
|
|
||||||
action_log,
|
|
||||||
re.compile(r"git merge.*master|git rebase.*master", re.I),
|
|
||||||
) and not re.search(r"merge_exit\s*=\s*\d+|merge simulation", text, re.I):
|
|
||||||
reasons.append(
|
|
||||||
"master integration claim lacks exact merge/rebase command and result"
|
|
||||||
)
|
|
||||||
|
|
||||||
if _CLEANUP_CLAIM.search(text) or fields.get("cleanup mutations", "").lower() not in {
|
|
||||||
"",
|
|
||||||
"none",
|
|
||||||
"n/a",
|
|
||||||
}:
|
|
||||||
if not (_WORKTREE_LIST.search(text) or _action_log_has(action_log, _WORKTREE_LIST)):
|
|
||||||
reasons.append(
|
|
||||||
"cleanup claim lacks final git worktree list or equivalent proof"
|
|
||||||
)
|
|
||||||
|
|
||||||
if re.search(r"\blive proof\b", text, re.I) and not _PROOF_SOURCE.search(text):
|
|
||||||
if not action_log:
|
|
||||||
reasons.append(
|
|
||||||
"live proof wording requires proof source classification "
|
|
||||||
"(command, MCP metadata, prior blocker, or not checked)"
|
|
||||||
)
|
|
||||||
|
|
||||||
proven = not reasons
|
|
||||||
return {
|
|
||||||
"proven": proven,
|
|
||||||
"block": not proven,
|
|
||||||
"reasons": reasons,
|
|
||||||
"safe_next_action": (
|
|
||||||
"cite explicit command/tool evidence or structured MCP pagination metadata "
|
|
||||||
"for each proof-sensitive claim"
|
|
||||||
if not proven
|
|
||||||
else "proceed"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
"""Transient validation failure history verifier (#396).
|
|
||||||
|
|
||||||
Reviewer sessions may observe validation failures that later pass on rerun.
|
|
||||||
Final reports must document every failure observed during the session, not
|
|
||||||
only the last passing result.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
_SECTION_RE = re.compile(
|
|
||||||
r"validation failure history",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_FAILURE_ENTRY_RE = re.compile(
|
|
||||||
r"(?:failure\s*(?:#|entry)?\s*\d+|validation failure)\s*:",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_COMMAND_RE = re.compile(
|
|
||||||
r"(?:command|validation command)\s*:\s*(.+)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_FAILING_TEST_RE = re.compile(
|
|
||||||
r"(?:failing test|failure|error)\s*:\s*(.+)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_CAUSE_RE = re.compile(
|
|
||||||
r"(?:suspected cause|cause)\s*:\s*(.+)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_REPRODUCED_RE = re.compile(
|
|
||||||
r"(?:reproduced|reproduces)\s*:\s*(yes|no|unknown)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_BASELINE_RE = re.compile(
|
|
||||||
r"(?:on baseline master|baseline master|exists on baseline)\s*:\s*(yes|no|unknown|not checked)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_PR_CAUSED_RE = re.compile(
|
|
||||||
r"(?:pr[- ]caused|pr caused)\s*:\s*(yes|no|unknown|not proven)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_TRANSIENT_STATUS_RE = re.compile(
|
|
||||||
r"(?:passed after transient failure investigation|transient failure investigation)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_PLAIN_PASS_RE = re.compile(
|
|
||||||
r"validation\s*:\s*(?:pass|passed|strong|ok|green)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_ENV_CLEANUP_RE = re.compile(
|
|
||||||
r"(?:environmental cleanup|state cleaned|what changed between runs)\s*:",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_UNKNOWN_CAUSE_RE = re.compile(
|
|
||||||
r"(?:suspected cause|cause)\s*:\s*(?:unknown|unexplained|not determined)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _failure_documented_in_text(text: str, failure: dict[str, Any]) -> bool:
|
|
||||||
"""Return True when *failure* appears documented in free-form report text."""
|
|
||||||
command = (failure.get("command") or "").strip()
|
|
||||||
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
|
|
||||||
if command and command not in text:
|
|
||||||
return False
|
|
||||||
if failing and failing not in text:
|
|
||||||
return False
|
|
||||||
return bool(command or failing)
|
|
||||||
|
|
||||||
|
|
||||||
def _section_has_structured_fields(text: str) -> bool:
|
|
||||||
if not _SECTION_RE.search(text):
|
|
||||||
return False
|
|
||||||
section_start = _SECTION_RE.search(text).start()
|
|
||||||
section = text[section_start:]
|
|
||||||
has_command = bool(_COMMAND_RE.search(section))
|
|
||||||
has_failure = bool(_FAILING_TEST_RE.search(section))
|
|
||||||
return has_command and has_failure
|
|
||||||
|
|
||||||
|
|
||||||
def assess_validation_failure_history_report(
|
|
||||||
report_text: str,
|
|
||||||
*,
|
|
||||||
validation_session: dict | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Require final reports to account for transient validation failures (#396)."""
|
|
||||||
text = report_text or ""
|
|
||||||
session = dict(validation_session or {})
|
|
||||||
reasons: list[str] = []
|
|
||||||
violations: list[str] = []
|
|
||||||
|
|
||||||
observed = list(session.get("observed_failures") or [])
|
|
||||||
final_status = (session.get("final_validation_status") or "").strip().lower()
|
|
||||||
cause_unknown = any(
|
|
||||||
(f.get("suspected_cause") or "").strip().lower() in {
|
|
||||||
"unknown", "unexplained", "not determined", ""
|
|
||||||
}
|
|
||||||
and not (f.get("pr_caused") or "").strip().lower() in {"yes", "no"}
|
|
||||||
for f in observed
|
|
||||||
) or bool(session.get("cause_unknown"))
|
|
||||||
|
|
||||||
if not observed:
|
|
||||||
return {
|
|
||||||
"proven": True,
|
|
||||||
"block": False,
|
|
||||||
"reasons": [],
|
|
||||||
"violations": [],
|
|
||||||
"observed_failure_count": 0,
|
|
||||||
"safe_next_action": "proceed",
|
|
||||||
}
|
|
||||||
|
|
||||||
documented = _section_has_structured_fields(text)
|
|
||||||
if not documented:
|
|
||||||
for failure in observed:
|
|
||||||
if _failure_documented_in_text(text, failure):
|
|
||||||
documented = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not documented:
|
|
||||||
violations.append(
|
|
||||||
"session observed validation failure(s) but final report omits "
|
|
||||||
"Validation failure history"
|
|
||||||
)
|
|
||||||
reasons.append(
|
|
||||||
"every validation failure observed during the session must appear "
|
|
||||||
"in a Validation failure history section"
|
|
||||||
)
|
|
||||||
|
|
||||||
if documented and not _SECTION_RE.search(text):
|
|
||||||
reasons.append(
|
|
||||||
"failure details must appear under an explicit "
|
|
||||||
"'Validation failure history' heading"
|
|
||||||
)
|
|
||||||
|
|
||||||
for idx, failure in enumerate(observed, start=1):
|
|
||||||
prefix = f"failure #{idx}"
|
|
||||||
if not _failure_documented_in_text(text, failure) and documented:
|
|
||||||
reasons.append(
|
|
||||||
f"{prefix}: command and failing test/error not documented in report"
|
|
||||||
)
|
|
||||||
command = (failure.get("command") or "").strip()
|
|
||||||
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
|
|
||||||
if not command:
|
|
||||||
reasons.append(f"{prefix}: missing command in session failure record")
|
|
||||||
if not failing:
|
|
||||||
reasons.append(
|
|
||||||
f"{prefix}: missing failing test or error in session failure record"
|
|
||||||
)
|
|
||||||
|
|
||||||
plain_pass = bool(_PLAIN_PASS_RE.search(text))
|
|
||||||
transient_wording = bool(_TRANSIENT_STATUS_RE.search(text))
|
|
||||||
final_passed = final_status in {
|
|
||||||
"passed",
|
|
||||||
"pass",
|
|
||||||
"passed_after_transient_failure_investigation",
|
|
||||||
}
|
|
||||||
|
|
||||||
if (final_passed or plain_pass) and observed:
|
|
||||||
if cause_unknown and not transient_wording:
|
|
||||||
violations.append(
|
|
||||||
"unknown transient failure cause cannot be erased as plain 'passed'"
|
|
||||||
)
|
|
||||||
reasons.append(
|
|
||||||
"when cause is unknown, use status "
|
|
||||||
"'passed after transient failure investigation'"
|
|
||||||
)
|
|
||||||
elif not transient_wording and final_status != "passed_after_transient_failure_investigation":
|
|
||||||
if not _ENV_CLEANUP_RE.search(text):
|
|
||||||
reasons.append(
|
|
||||||
"later passing rerun must document what changed between runs "
|
|
||||||
"or environmental cleanup performed"
|
|
||||||
)
|
|
||||||
|
|
||||||
if session.get("environmental_contamination") and not _ENV_CLEANUP_RE.search(text):
|
|
||||||
reasons.append(
|
|
||||||
"environmental /tmp state contamination must document cleanup or "
|
|
||||||
"what changed before the passing rerun"
|
|
||||||
)
|
|
||||||
|
|
||||||
proven = not reasons and not violations
|
|
||||||
return {
|
|
||||||
"proven": proven,
|
|
||||||
"block": bool(violations) or not proven,
|
|
||||||
"reasons": reasons,
|
|
||||||
"violations": violations,
|
|
||||||
"observed_failure_count": len(observed),
|
|
||||||
"documented": documented,
|
|
||||||
"safe_next_action": (
|
|
||||||
"add Validation failure history with command, failing test, cause, "
|
|
||||||
"reproduction, baseline comparison, and PR-caused evidence; use "
|
|
||||||
"'passed after transient failure investigation' when appropriate"
|
|
||||||
if not proven
|
|
||||||
else "proceed"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -76,7 +76,6 @@ AUTHOR_TASKS = frozenset({
|
|||||||
"delete_branch",
|
"delete_branch",
|
||||||
"work_issue",
|
"work_issue",
|
||||||
"work-issue",
|
"work-issue",
|
||||||
"reconcile_landed_pr",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
RECONCILER_TASKS = frozenset({
|
RECONCILER_TASKS = frozenset({
|
||||||
@@ -105,7 +104,6 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"approve_pr": "reviewer",
|
"approve_pr": "reviewer",
|
||||||
"work_issue": "author",
|
"work_issue": "author",
|
||||||
"work-issue": "author",
|
"work-issue": "author",
|
||||||
"reconcile_landed_pr": "author",
|
|
||||||
"reconcile_already_landed_pr": "reconciler",
|
"reconcile_already_landed_pr": "reconciler",
|
||||||
"reconcile_already_landed": "reconciler",
|
"reconcile_already_landed": "reconciler",
|
||||||
"reconcile-landed-pr": "reconciler",
|
"reconcile-landed-pr": "reconciler",
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
|
||||||
cd "$repo_root"
|
|
||||||
exec python3 -m webui "$@"
|
|
||||||
@@ -91,23 +91,4 @@ Verifier: `review_proofs.assess_queue_status_report()`.
|
|||||||
|
|
||||||
Narrative final report and controller handoff must agree on eligibility class,
|
Narrative final report and controller handoff must agree on eligibility class,
|
||||||
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
|
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
|
||||||
terminal review mutation, merge result, and linked issue status.
|
terminal review mutation, merge result, and linked issue status.
|
||||||
|
|
||||||
### Proof-backed claims (#395)
|
|
||||||
|
|
||||||
Proof-sensitive claims must cite explicit command/tool evidence in the report
|
|
||||||
or structured MCP metadata — not narrative alone:
|
|
||||||
|
|
||||||
- **Inventory complete:** `has_more=false`, `is_final_page=true`,
|
|
||||||
`inventory_complete=true`, and/or `total_count` from `gitea_list_prs`.
|
|
||||||
- **Skipped earlier PRs:** merge-simulation command output, conflict file list,
|
|
||||||
or live `gitea_get_pr_review_feedback` / mergeability fields.
|
|
||||||
- **Baseline validation:** baseline worktree path, baseline target SHA,
|
|
||||||
dirty-before/after, exact command, exact result.
|
|
||||||
- **Master integration:** exact `git merge` / `git rebase` command and exit status.
|
|
||||||
- **Cleanup:** final `git worktree list` (or remove commands) proving session
|
|
||||||
worktrees were removed.
|
|
||||||
|
|
||||||
When a claim relies on prior-session blocker state or MCP metadata only, label
|
|
||||||
the proof source explicitly (`command`, `MCP metadata`, `prior blocker`,
|
|
||||||
`not checked`). Do not use `live proof` without that classification.
|
|
||||||
@@ -538,36 +538,6 @@ Official validation integrity status must be one of:
|
|||||||
|
|
||||||
Do not invent a softer status.
|
Do not invent a softer status.
|
||||||
|
|
||||||
## 21A. Validation failure history rule
|
|
||||||
|
|
||||||
Every validation failure observed during the review session must appear in the
|
|
||||||
final report, even if a later rerun passes.
|
|
||||||
|
|
||||||
Before claiming `Validation: passed`, list every failed validation command from
|
|
||||||
the session under `Validation failure history`.
|
|
||||||
|
|
||||||
For each failure, report:
|
|
||||||
|
|
||||||
* command
|
|
||||||
* failing test or error
|
|
||||||
* suspected cause
|
|
||||||
* whether it reproduced
|
|
||||||
* whether it exists on baseline master
|
|
||||||
* evidence for whether it is or is not PR-caused
|
|
||||||
|
|
||||||
If a later rerun passes, also report:
|
|
||||||
|
|
||||||
* what changed between runs
|
|
||||||
* whether environmental state was cleaned (for example `/tmp` lock files)
|
|
||||||
* why the pass is trustworthy
|
|
||||||
|
|
||||||
If the cause is unknown, do not erase the earlier failure with plain
|
|
||||||
`Validation: passed`. Use a status such as
|
|
||||||
`passed after transient failure investigation`.
|
|
||||||
|
|
||||||
`gitea_validate_review_final_report` rejects reports that omit known earlier
|
|
||||||
validation failures when `validation_session.observed_failures` is supplied.
|
|
||||||
|
|
||||||
## 22. Baseline validation rule
|
## 22. Baseline validation rule
|
||||||
|
|
||||||
Do not run tests in the main checkout.
|
Do not run tests in the main checkout.
|
||||||
@@ -1126,7 +1096,6 @@ Controller Handoff:
|
|||||||
* Baseline worktree path:
|
* Baseline worktree path:
|
||||||
* Files reviewed:
|
* Files reviewed:
|
||||||
* Validation:
|
* Validation:
|
||||||
* Validation failure history:
|
|
||||||
* Official validation integrity status:
|
* Official validation integrity status:
|
||||||
* Terminal review mutation:
|
* Terminal review mutation:
|
||||||
* Review decision:
|
* Review decision:
|
||||||
|
|||||||
@@ -83,20 +83,6 @@ Examples:
|
|||||||
|
|
||||||
If capability cannot be proven, stop and produce a recovery handoff only.
|
If capability cannot be proven, stop and produce a recovery handoff only.
|
||||||
|
|
||||||
### 2A. Pre-task role routing (#139)
|
|
||||||
|
|
||||||
Before issue selection or any mutation, resolve the composite author task:
|
|
||||||
|
|
||||||
* `gitea_route_task_session(task_type="work-issue", remote=…)` — must return
|
|
||||||
`route_result: allowed_current_session` and `downstream_allowed: true`
|
|
||||||
* `gitea_resolve_task_capability(task="work_issue", remote=…)` — must show
|
|
||||||
`required_role_kind: author` and `allowed_in_current_session: true`
|
|
||||||
|
|
||||||
Hyphen (`work-issue`) and underscore (`work_issue`) aliases are equivalent.
|
|
||||||
If routing returns `ambiguous_task_stop`, `wrong_role_stop`, or
|
|
||||||
`route_to_author_session`, stop and produce a recovery handoff only — do not
|
|
||||||
fall back to reviewer tools or guess a different profile.
|
|
||||||
|
|
||||||
## 3. Stop immediately on blocked infrastructure
|
## 3. Stop immediately on blocked infrastructure
|
||||||
|
|
||||||
If any of the following appears, stop immediately:
|
If any of the following appears, stop immediately:
|
||||||
|
|||||||
@@ -104,22 +104,6 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.read",
|
"permission": "gitea.read",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
"work_issue": {
|
|
||||||
"permission": "gitea.pr.create",
|
|
||||||
"role": "author",
|
|
||||||
},
|
|
||||||
"work-issue": {
|
|
||||||
"permission": "gitea.pr.create",
|
|
||||||
"role": "author",
|
|
||||||
},
|
|
||||||
"reconcile_landed_pr": {
|
|
||||||
"permission": "gitea.read",
|
|
||||||
"role": "author",
|
|
||||||
},
|
|
||||||
"reconcile-landed-pr": {
|
|
||||||
"permission": "gitea.read",
|
|
||||||
"role": "author",
|
|
||||||
},
|
|
||||||
"reconcile_already_landed_pr": {
|
"reconcile_already_landed_pr": {
|
||||||
"permission": "gitea.pr.close",
|
"permission": "gitea.pr.close",
|
||||||
"role": "reconciler",
|
"role": "reconciler",
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
"""Tests for branches-only author mutation worktree guard (#274)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import author_mutation_worktree as amw # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
class TestPathUnderBranches(unittest.TestCase):
|
|
||||||
ROOT = "/repo/Gitea-Tools"
|
|
||||||
|
|
||||||
def test_branches_subdirectory_passes(self):
|
|
||||||
self.assertTrue(
|
|
||||||
amw.is_path_under_branches(f"{self.ROOT}/branches/issue-274", self.ROOT)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_control_checkout_fails(self):
|
|
||||||
self.assertFalse(amw.is_path_under_branches(self.ROOT, self.ROOT))
|
|
||||||
|
|
||||||
def test_sibling_directory_fails(self):
|
|
||||||
self.assertFalse(
|
|
||||||
amw.is_path_under_branches("/repo/other-checkout", self.ROOT)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAssessAuthorMutationWorktree(unittest.TestCase):
|
|
||||||
ROOT = "/repo/Gitea-Tools"
|
|
||||||
|
|
||||||
def test_branches_worktree_passes(self):
|
|
||||||
result = amw.assess_author_mutation_worktree(
|
|
||||||
workspace_path=f"{self.ROOT}/branches/issue-274",
|
|
||||||
project_root=self.ROOT,
|
|
||||||
current_branch="feat/issue-274-branches-only-worktrees",
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"])
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
|
|
||||||
def test_control_checkout_blocks(self):
|
|
||||||
result = amw.assess_author_mutation_worktree(
|
|
||||||
workspace_path=self.ROOT,
|
|
||||||
project_root=self.ROOT,
|
|
||||||
current_branch="master",
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertIn("control checkout", result["reasons"][0])
|
|
||||||
|
|
||||||
def test_drifted_control_branch_blocks(self):
|
|
||||||
result = amw.assess_author_mutation_worktree(
|
|
||||||
workspace_path=self.ROOT,
|
|
||||||
project_root=self.ROOT,
|
|
||||||
current_branch="feat/some-task",
|
|
||||||
)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertTrue(any("drift" in r for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_branches_worktree_as_project_root_passes(self):
|
|
||||||
"""MCP launched from a branches/ worktree uses that path as PROJECT_ROOT."""
|
|
||||||
worktree_root = f"{self.ROOT}/branches/issue-274"
|
|
||||||
result = amw.assess_author_mutation_worktree(
|
|
||||||
workspace_path=worktree_root,
|
|
||||||
project_root=worktree_root,
|
|
||||||
current_branch="feat/issue-274-branches-only-worktrees",
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"])
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestPreflightIntegration(unittest.TestCase):
|
|
||||||
def test_verify_preflight_blocks_control_checkout_with_test_porcelain(self):
|
|
||||||
import mcp_server
|
|
||||||
|
|
||||||
mcp_server._preflight_whoami_called = True
|
|
||||||
mcp_server._preflight_capability_called = True
|
|
||||||
mcp_server._preflight_resolved_role = "author"
|
|
||||||
control_root = "/repo/Gitea-Tools"
|
|
||||||
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
|
||||||
with mock.patch.dict(
|
|
||||||
"os.environ",
|
|
||||||
{"GITEA_TEST_PORCELAIN": ""},
|
|
||||||
clear=False,
|
|
||||||
):
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
mcp_server.verify_preflight_purity()
|
|
||||||
self.assertIn("Branches-only mutation guard", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_verify_preflight_allows_branches_worktree(self):
|
|
||||||
import mcp_server
|
|
||||||
|
|
||||||
mcp_server._preflight_whoami_called = True
|
|
||||||
mcp_server._preflight_capability_called = True
|
|
||||||
mcp_server._preflight_resolved_role = "author"
|
|
||||||
worktree = "/repo/Gitea-Tools/branches/issue-274"
|
|
||||||
with mock.patch.dict(
|
|
||||||
"os.environ",
|
|
||||||
{"GITEA_TEST_PORCELAIN": ""},
|
|
||||||
clear=False,
|
|
||||||
):
|
|
||||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -55,15 +55,7 @@ class TestCommitPayloads(unittest.TestCase):
|
|||||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||||
fh.write(json.dumps(CONFIG))
|
fh.write(json.dumps(CONFIG))
|
||||||
|
|
||||||
repo_root = os.path.realpath(
|
self.locked_worktree_dir = tempfile.TemporaryDirectory()
|
||||||
os.path.join(os.path.dirname(__file__), os.pardir)
|
|
||||||
)
|
|
||||||
branches_root = os.path.join(repo_root, "branches")
|
|
||||||
os.makedirs(branches_root, exist_ok=True)
|
|
||||||
self.locked_worktree_dir = tempfile.TemporaryDirectory(
|
|
||||||
dir=branches_root,
|
|
||||||
prefix="test-commit-payloads-",
|
|
||||||
)
|
|
||||||
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
|
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
|
||||||
|
|
||||||
self.lock_file_path = "/tmp/gitea_issue_lock.json"
|
self.lock_file_path = "/tmp/gitea_issue_lock.json"
|
||||||
@@ -102,7 +94,6 @@ class TestCommitPayloads(unittest.TestCase):
|
|||||||
"GITEA_MCP_PROFILE": profile,
|
"GITEA_MCP_PROFILE": profile,
|
||||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||||
"GITEA_TEST_PORCELAIN": "",
|
"GITEA_TEST_PORCELAIN": "",
|
||||||
"GITEA_AUTHOR_WORKTREE": self.locked_worktree_path,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
"""Tests for gitea_delete_branch capability gate (Issue #408).
|
|
||||||
|
|
||||||
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
|
|
||||||
without it the delete fails closed (no preflight, no auth lookup, no API call,
|
|
||||||
structured permission report). With it, deletion proceeds through existing
|
|
||||||
preflight and audit unchanged.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import mcp_server
|
|
||||||
from mcp_server import gitea_delete_branch
|
|
||||||
|
|
||||||
FAKE_AUTH = "token fake"
|
|
||||||
|
|
||||||
AUTHOR_NO_DELETE = {
|
|
||||||
"profile_name": "prgs-author",
|
|
||||||
"allowed_operations": [
|
|
||||||
"gitea.read", "gitea.pr.create", "gitea.pr.comment",
|
|
||||||
"gitea.branch.push", "gitea.issue.comment",
|
|
||||||
],
|
|
||||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
|
||||||
"audit_label": "prgs-author",
|
|
||||||
}
|
|
||||||
|
|
||||||
AUTHOR_WITH_DELETE = {
|
|
||||||
"profile_name": "prgs-author-deleter",
|
|
||||||
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"] + [
|
|
||||||
"gitea.branch.delete",
|
|
||||||
],
|
|
||||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
|
||||||
"audit_label": "prgs-author-deleter",
|
|
||||||
}
|
|
||||||
|
|
||||||
CONFIG = {
|
|
||||||
"version": 2,
|
|
||||||
"contexts": {
|
|
||||||
"ctx": {
|
|
||||||
"enabled": True,
|
|
||||||
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"profiles": {
|
|
||||||
"author-no-delete": {
|
|
||||||
"enabled": True,
|
|
||||||
"context": "ctx",
|
|
||||||
"role": "author",
|
|
||||||
"username": "author-user",
|
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
|
||||||
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"],
|
|
||||||
"forbidden_operations": [],
|
|
||||||
"execution_profile": "author-no-delete",
|
|
||||||
},
|
|
||||||
"author-with-delete": {
|
|
||||||
"enabled": True,
|
|
||||||
"context": "ctx",
|
|
||||||
"role": "author",
|
|
||||||
"username": "deleter-user",
|
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
|
||||||
"allowed_operations": AUTHOR_WITH_DELETE["allowed_operations"],
|
|
||||||
"forbidden_operations": [],
|
|
||||||
"execution_profile": "author-with-delete",
|
|
||||||
},
|
|
||||||
"reviewer-profile": {
|
|
||||||
"enabled": True,
|
|
||||||
"context": "ctx",
|
|
||||||
"role": "reviewer",
|
|
||||||
"username": "reviewer-user",
|
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
|
||||||
"allowed_operations": [
|
|
||||||
"gitea.read", "gitea.pr.review", "gitea.pr.merge",
|
|
||||||
],
|
|
||||||
"forbidden_operations": [
|
|
||||||
"gitea.branch.delete", "gitea.branch.push", "gitea.pr.create",
|
|
||||||
],
|
|
||||||
"execution_profile": "reviewer-profile",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"rules": {"allow_runtime_switching": False},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteBranchToolGate(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._preflight_snapshot = (
|
|
||||||
mcp_server._preflight_whoami_called,
|
|
||||||
mcp_server._preflight_capability_called,
|
|
||||||
)
|
|
||||||
mcp_server._preflight_whoami_called = False
|
|
||||||
mcp_server._preflight_capability_called = False
|
|
||||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
|
||||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
|
||||||
"repo": "Example-Repo"},
|
|
||||||
})
|
|
||||||
self._remotes.start()
|
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
|
||||||
patch("gitea_config.load_config", return_value={}).start()
|
|
||||||
patch(
|
|
||||||
"gitea_config.is_runtime_switching_enabled", return_value=False
|
|
||||||
).start()
|
|
||||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
|
||||||
self.mock_api = patch("mcp_server.api_request").start()
|
|
||||||
self.mock_auth = patch(
|
|
||||||
"mcp_server.get_auth_header", return_value=FAKE_AUTH
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
patch.stopall()
|
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
|
||||||
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
|
|
||||||
self._preflight_snapshot
|
|
||||||
)
|
|
||||||
|
|
||||||
def _set_profile(self, profile):
|
|
||||||
patch("mcp_server.get_profile", return_value=profile).start()
|
|
||||||
|
|
||||||
def test_blocked_without_delete_capability(self):
|
|
||||||
self._set_profile(AUTHOR_NO_DELETE)
|
|
||||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
|
||||||
self.assertFalse(res["success"])
|
|
||||||
self.assertFalse(res["performed"])
|
|
||||||
self.assertEqual(res["required_permission"], "gitea.branch.delete")
|
|
||||||
self.assertTrue(res["reasons"])
|
|
||||||
self.assertEqual(
|
|
||||||
res["permission_report"]["missing_permission"],
|
|
||||||
"gitea.branch.delete",
|
|
||||||
)
|
|
||||||
self.mock_api.assert_not_called()
|
|
||||||
self.mock_auth.assert_not_called()
|
|
||||||
|
|
||||||
def test_allowed_delete_proceeds(self):
|
|
||||||
self._set_profile(AUTHOR_WITH_DELETE)
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
|
||||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
|
||||||
self.assertTrue(res["success"])
|
|
||||||
self.assertIn("deleted", res["message"])
|
|
||||||
delete_calls = [
|
|
||||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
|
||||||
]
|
|
||||||
self.assertTrue(delete_calls)
|
|
||||||
|
|
||||||
def test_allowed_delete_audited_with_capability_proof(self):
|
|
||||||
self._set_profile(AUTHOR_WITH_DELETE)
|
|
||||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
|
||||||
mock_write = patch("gitea_audit.write_event").start()
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
|
||||||
self.mock_api.return_value = {}
|
|
||||||
gitea_delete_branch(branch="feat/branch", remote="prgs")
|
|
||||||
mock_write.assert_called()
|
|
||||||
event = mock_write.call_args[0][0]
|
|
||||||
self.assertEqual(event["action"], "delete_branch")
|
|
||||||
self.assertEqual(
|
|
||||||
event["request_metadata"]["required_permission"],
|
|
||||||
"gitea.branch.delete",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteBranchResolverParity(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
|
||||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
|
||||||
"repo": "Example-Repo"},
|
|
||||||
})
|
|
||||||
self._remotes.start()
|
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
|
||||||
self._dir = tempfile.TemporaryDirectory()
|
|
||||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
|
||||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(json.dumps(CONFIG))
|
|
||||||
patch(
|
|
||||||
"gitea_config.is_runtime_switching_enabled", return_value=False
|
|
||||||
).start()
|
|
||||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
|
||||||
self.mock_api = patch("mcp_server.api_request").start()
|
|
||||||
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
patch.stopall()
|
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
|
||||||
self._dir.cleanup()
|
|
||||||
|
|
||||||
def _env(self, profile: str) -> dict:
|
|
||||||
return {
|
|
||||||
"GITEA_MCP_CONFIG": self.config_path,
|
|
||||||
"GITEA_MCP_PROFILE": profile,
|
|
||||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
|
||||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_reviewer_resolver_denial_blocks_raw_tool(self):
|
|
||||||
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
|
|
||||||
resolve = mcp_server.gitea_resolve_task_capability(
|
|
||||||
task="delete_branch", remote="prgs")
|
|
||||||
self.assertFalse(resolve["allowed_in_current_session"])
|
|
||||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
|
||||||
self.assertFalse(res["success"])
|
|
||||||
self.assertEqual(
|
|
||||||
res["permission_report"]["missing_permission"],
|
|
||||||
resolve["required_operation_permission"],
|
|
||||||
)
|
|
||||||
delete_calls = [
|
|
||||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
|
||||||
]
|
|
||||||
self.assertFalse(delete_calls)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -57,47 +57,26 @@ def _review_handoff(**overrides):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _reconcile_handoff(drop=(), **overrides):
|
def _reconcile_handoff(**overrides):
|
||||||
lines = [
|
lines = [
|
||||||
"## Controller Handoff",
|
"## Controller Handoff",
|
||||||
"",
|
"",
|
||||||
"- Task: reconcile already-landed PR #99",
|
"- Task: reconcile already-landed PR #99",
|
||||||
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
"- Role/profile: reconciler / prgs-reconciler",
|
"- Role: reconciler",
|
||||||
"- Identity: sysadmin",
|
"- Identity: sysadmin / prgs-author",
|
||||||
"- Selected PR: #99",
|
"- Selected PR: #99",
|
||||||
"- PR live state: open",
|
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||||
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
|
||||||
"- Target branch: master",
|
|
||||||
"- Target branch SHA: 5e023dc71b0e2b813a0b1eee6b0f42630c47a95c",
|
|
||||||
"- Ancestor proof: candidate head is ancestor of target branch SHA",
|
|
||||||
"- Linked issue: #98",
|
"- Linked issue: #98",
|
||||||
"- Linked issue live status: not verified in this session",
|
"- Linked issue live status: not verified in this session",
|
||||||
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
|
|
||||||
"- Capabilities proven: gitea.read, gitea.pr.comment",
|
|
||||||
"- Missing capabilities: gitea.pr.close",
|
|
||||||
"- PR comments posted: 1 reconciliation comment on PR #99",
|
|
||||||
"- Issue comments posted: none",
|
|
||||||
"- PRs closed: none",
|
|
||||||
"- Issues closed: none",
|
|
||||||
"- File edits by reconciler: none",
|
"- File edits by reconciler: none",
|
||||||
"- Git ref mutations: git fetch prgs master",
|
"- Git ref mutations: git fetch prgs master",
|
||||||
"- Worktree mutations: none",
|
|
||||||
"- MCP/Gitea mutations: PR comment posted",
|
|
||||||
"- External-state mutations: none",
|
|
||||||
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
|
|
||||||
"- Reconciliation mutations: PR comment posted",
|
"- Reconciliation mutations: PR comment posted",
|
||||||
"- Current status: reconciliation complete",
|
"- Current status: reconciliation complete",
|
||||||
"- Blocker: missing gitea.pr.close capability",
|
"- Safe next action: none",
|
||||||
"- Safe next action: hand off to a profile with gitea.pr.close",
|
|
||||||
"- No review/merge confirmation: confirmed",
|
"- No review/merge confirmation: confirmed",
|
||||||
]
|
]
|
||||||
kept = [
|
text = "\n".join(lines)
|
||||||
line
|
|
||||||
for line in lines
|
|
||||||
if not any(line.startswith(f"- {name}:") for name in drop)
|
|
||||||
]
|
|
||||||
text = "\n".join(kept)
|
|
||||||
for key, value in overrides.items():
|
for key, value in overrides.items():
|
||||||
if f"- {key}:" in text:
|
if f"- {key}:" in text:
|
||||||
text = text.replace(f"- {key}:", f"- {key}: {value}")
|
text = text.replace(f"- {key}:", f"- {key}: {value}")
|
||||||
@@ -215,20 +194,6 @@ class TestReviewPrRules(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_already_landed_oldest_eligible_pr_blocks(self):
|
|
||||||
report = (
|
|
||||||
_review_handoff()
|
|
||||||
+ "\nAlready landed.\nOldest eligible PR: PR #278."
|
|
||||||
)
|
|
||||||
result = assess_final_report_validator(report, "review_pr")
|
|
||||||
self.assertTrue(result["blocked"])
|
|
||||||
self.assertTrue(
|
|
||||||
any(
|
|
||||||
f["rule_id"] == "reviewer.already_landed_eligible_wording"
|
|
||||||
for f in result["findings"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_already_landed_gate_blocks_review_terminal_states(self):
|
def test_already_landed_gate_blocks_review_terminal_states(self):
|
||||||
cases = {
|
cases = {
|
||||||
"APPROVED": ("- Review decision: request_changes", "- Review decision: APPROVED"),
|
"APPROVED": ("- Review decision: request_changes", "- Review decision: APPROVED"),
|
||||||
@@ -277,6 +242,7 @@ class TestReviewPrRules(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
result = assess_final_report_validator(report, "review_pr")
|
result = assess_final_report_validator(report, "review_pr")
|
||||||
self.assertEqual(result["grade"], "A")
|
self.assertEqual(result["grade"], "A")
|
||||||
|
|
||||||
def test_git_fetch_must_not_be_readonly_only(self):
|
def test_git_fetch_must_not_be_readonly_only(self):
|
||||||
report = (
|
report = (
|
||||||
_review_handoff()
|
_review_handoff()
|
||||||
@@ -341,159 +307,6 @@ class TestReconciliationRules(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestCanonicalReconcileSchema(unittest.TestCase):
|
|
||||||
"""Issue #307: reconciliation workflows emit only the canonical schema."""
|
|
||||||
|
|
||||||
def test_comment_only_reconciliation_passes(self):
|
|
||||||
result = assess_final_report_validator(
|
|
||||||
_reconcile_handoff(),
|
|
||||||
"reconcile_already_landed",
|
|
||||||
)
|
|
||||||
self.assertEqual(result["grade"], "A")
|
|
||||||
self.assertEqual(result["findings"], [])
|
|
||||||
|
|
||||||
def test_successful_close_reconciliation_passes(self):
|
|
||||||
report = _reconcile_handoff().replace(
|
|
||||||
"- Capabilities proven: gitea.read, gitea.pr.comment",
|
|
||||||
"- Capabilities proven: gitea.read, gitea.pr.comment, gitea.pr.close",
|
|
||||||
).replace(
|
|
||||||
"- Missing capabilities: gitea.pr.close",
|
|
||||||
"- Missing capabilities: none",
|
|
||||||
).replace(
|
|
||||||
"- PRs closed: none",
|
|
||||||
"- PRs closed: #99",
|
|
||||||
).replace(
|
|
||||||
"- Blocker: missing gitea.pr.close capability",
|
|
||||||
"- Blocker: none",
|
|
||||||
)
|
|
||||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
|
||||||
self.assertEqual(result["grade"], "A")
|
|
||||||
|
|
||||||
def test_blocked_reconciliation_passes(self):
|
|
||||||
report = _reconcile_handoff().replace(
|
|
||||||
"- Capabilities proven: gitea.read, gitea.pr.comment",
|
|
||||||
"- Capabilities proven: gitea.read",
|
|
||||||
).replace(
|
|
||||||
"- Missing capabilities: gitea.pr.close",
|
|
||||||
"- Missing capabilities: gitea.pr.close, gitea.pr.comment",
|
|
||||||
).replace(
|
|
||||||
"- PR comments posted: 1 reconciliation comment on PR #99",
|
|
||||||
"- PR comments posted: none",
|
|
||||||
).replace(
|
|
||||||
"- MCP/Gitea mutations: PR comment posted",
|
|
||||||
"- MCP/Gitea mutations: none",
|
|
||||||
).replace(
|
|
||||||
"- Reconciliation mutations: PR comment posted",
|
|
||||||
"- Reconciliation mutations: none",
|
|
||||||
)
|
|
||||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
|
||||||
self.assertEqual(result["grade"], "A")
|
|
||||||
|
|
||||||
def test_missing_capabilities_field_required(self):
|
|
||||||
report = _reconcile_handoff(drop=("Missing capabilities",))
|
|
||||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
|
||||||
self.assertEqual(result["grade"], "downgraded")
|
|
||||||
self.assertTrue(
|
|
||||||
any(
|
|
||||||
f["rule_id"] == "reconcile.controller_handoff"
|
|
||||||
and "Missing capabilities" in f["reason"]
|
|
||||||
for f in result["findings"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_missing_ancestor_proof_and_candidate_sha_downgrade(self):
|
|
||||||
report = _reconcile_handoff(drop=("Ancestor proof", "Candidate head SHA"))
|
|
||||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
|
||||||
self.assertEqual(result["grade"], "downgraded")
|
|
||||||
reasons = " ".join(f["reason"] for f in result["findings"])
|
|
||||||
self.assertIn("Ancestor proof", reasons)
|
|
||||||
self.assertIn("Candidate head SHA", reasons)
|
|
||||||
|
|
||||||
def test_stale_issue_lock_proof_blocks(self):
|
|
||||||
report = _reconcile_handoff() + "\n- Issue lock proof: locked before diff"
|
|
||||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
|
||||||
self.assertTrue(result["blocked"])
|
|
||||||
self.assertTrue(
|
|
||||||
any(
|
|
||||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
|
||||||
and f["field"] == "issue lock proof"
|
|
||||||
for f in result["findings"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stale_claim_comment_status_blocks(self):
|
|
||||||
report = _reconcile_handoff() + "\n- Claim/comment status: claimed"
|
|
||||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
|
||||||
self.assertTrue(result["blocked"])
|
|
||||||
self.assertTrue(
|
|
||||||
any(
|
|
||||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
|
||||||
and f["field"] == "claim/comment status"
|
|
||||||
for f in result["findings"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stale_workspace_mutations_blocks_without_observed_mutations(self):
|
|
||||||
report = _reconcile_handoff() + "\n- Workspace mutations: None"
|
|
||||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
|
||||||
self.assertTrue(result["blocked"])
|
|
||||||
self.assertTrue(
|
|
||||||
any(
|
|
||||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
|
||||||
and f["field"] == "workspace mutations"
|
|
||||||
for f in result["findings"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_pr_number_opened_allowed_when_session_opened_pr(self):
|
|
||||||
report = _reconcile_handoff() + "\n- PR number opened: #12"
|
|
||||||
result = assess_final_report_validator(
|
|
||||||
report,
|
|
||||||
"reconcile_already_landed",
|
|
||||||
session_pr_opened=True,
|
|
||||||
)
|
|
||||||
self.assertFalse(
|
|
||||||
any(
|
|
||||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
|
||||||
and f["field"] == "pr number opened"
|
|
||||||
for f in result["findings"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_pr_number_opened_still_blocked_without_session_proof(self):
|
|
||||||
report = _reconcile_handoff() + "\n- PR number opened: #12"
|
|
||||||
result = assess_final_report_validator(
|
|
||||||
report,
|
|
||||||
"reconcile_already_landed",
|
|
||||||
session_pr_opened=False,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["blocked"])
|
|
||||||
self.assertTrue(
|
|
||||||
any(
|
|
||||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
|
||||||
and f["field"] == "pr number opened"
|
|
||||||
for f in result["findings"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_action_log_fetch_requires_git_ref_mutation_entry(self):
|
|
||||||
report = _reconcile_handoff().replace(
|
|
||||||
"- Git ref mutations: git fetch prgs master",
|
|
||||||
"- Git ref mutations: none",
|
|
||||||
)
|
|
||||||
result = assess_final_report_validator(
|
|
||||||
report,
|
|
||||||
"reconcile_already_landed",
|
|
||||||
action_log=[{"command": "git fetch prgs master", "performed": True}],
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
any(
|
|
||||||
f["rule_id"] == "reviewer.git_fetch_readonly"
|
|
||||||
for f in result["findings"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEntryPoint(unittest.TestCase):
|
class TestEntryPoint(unittest.TestCase):
|
||||||
def test_unknown_task_kind_blocks(self):
|
def test_unknown_task_kind_blocks(self):
|
||||||
result = assess_final_report_validator("report", "unknown_mode")
|
result = assess_final_report_validator("report", "unknown_mode")
|
||||||
|
|||||||
@@ -100,8 +100,6 @@ def test_work_issue_workflow_contract():
|
|||||||
assert "canonical: true" in text
|
assert "canonical: true" in text
|
||||||
assert "Do not merge your own PR" in text
|
assert "Do not merge your own PR" in text
|
||||||
assert "## 21. PR creation rules" in text
|
assert "## 21. PR creation rules" in text
|
||||||
assert "gitea_route_task_session" in text
|
|
||||||
assert "work-issue" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_pr_queue_cleanup_workflow_contract():
|
def test_pr_queue_cleanup_workflow_contract():
|
||||||
@@ -141,16 +139,6 @@ def test_start_issue_template_references_work_issue_workflow():
|
|||||||
assert "workflows/work-issue.md" in text
|
assert "workflows/work-issue.md" in text
|
||||||
|
|
||||||
|
|
||||||
def test_reconcile_skill_registered_in_mcp_server():
|
|
||||||
from mcp_server import _PROJECT_SKILLS
|
|
||||||
|
|
||||||
assert "gitea-reconcile-landed-pr" in _PROJECT_SKILLS
|
|
||||||
steps = _PROJECT_SKILLS["gitea-reconcile-landed-pr"]["steps"]
|
|
||||||
joined = " ".join(steps).lower()
|
|
||||||
assert "gitea_scan_already_landed_open_prs" in joined
|
|
||||||
assert "gitea_assess_already_landed_reconciliation" in joined
|
|
||||||
|
|
||||||
|
|
||||||
def test_pr_queue_cleanup_template_references_workflow():
|
def test_pr_queue_cleanup_template_references_workflow():
|
||||||
text = (SKILL_DIR / "templates" / "pr-queue-cleanup.md").read_text(
|
text = (SKILL_DIR / "templates" / "pr-queue-cleanup.md").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
@@ -177,24 +165,12 @@ def test_final_report_validator_exported():
|
|||||||
assert callable(assess_final_report_validator)
|
assert callable(assess_final_report_validator)
|
||||||
|
|
||||||
|
|
||||||
def test_review_final_report_schema_verifier_exported():
|
|
||||||
from review_proofs import assess_review_final_report_schema
|
|
||||||
|
|
||||||
assert callable(assess_review_final_report_schema)
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_issue_final_report_verifier_exported():
|
def test_create_issue_final_report_verifier_exported():
|
||||||
from review_proofs import assess_create_issue_final_report
|
from review_proofs import assess_create_issue_final_report
|
||||||
|
|
||||||
assert callable(assess_create_issue_final_report)
|
assert callable(assess_create_issue_final_report)
|
||||||
|
|
||||||
|
|
||||||
def test_work_issue_final_report_verifier_exported():
|
|
||||||
from review_proofs import assess_work_issue_final_report
|
|
||||||
|
|
||||||
assert callable(assess_work_issue_final_report)
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_simulation_verifier_exported():
|
def test_merge_simulation_verifier_exported():
|
||||||
from review_proofs import assess_merge_simulation_report
|
from review_proofs import assess_merge_simulation_report
|
||||||
|
|
||||||
@@ -207,12 +183,6 @@ def test_validation_integrity_verifier_exported():
|
|||||||
assert callable(assess_validation_integrity_report)
|
assert callable(assess_validation_integrity_report)
|
||||||
|
|
||||||
|
|
||||||
def test_validation_failure_history_verifier_exported():
|
|
||||||
from review_proofs import assess_validation_failure_history_report
|
|
||||||
|
|
||||||
assert callable(assess_validation_failure_history_report)
|
|
||||||
|
|
||||||
|
|
||||||
def test_prior_blocker_skip_verifier_exported():
|
def test_prior_blocker_skip_verifier_exported():
|
||||||
from review_proofs import assess_prior_blocker_skip_proof
|
from review_proofs import assess_prior_blocker_skip_proof
|
||||||
|
|
||||||
@@ -255,12 +225,6 @@ def test_inventory_worktree_verifier_exported():
|
|||||||
assert callable(assess_inventory_worktree_report)
|
assert callable(assess_inventory_worktree_report)
|
||||||
|
|
||||||
|
|
||||||
def test_proof_backed_handoff_verifier_exported():
|
|
||||||
from review_proofs import assess_proof_backed_handoff_report
|
|
||||||
|
|
||||||
assert callable(assess_proof_backed_handoff_report)
|
|
||||||
|
|
||||||
|
|
||||||
def test_infra_stop_handoff_verifier_exported():
|
def test_infra_stop_handoff_verifier_exported():
|
||||||
from review_proofs import assess_infra_stop_handoff_report
|
from review_proofs import assess_infra_stop_handoff_report
|
||||||
|
|
||||||
@@ -279,12 +243,6 @@ def test_worktree_ownership_verifier_exported():
|
|||||||
assert callable(assess_worktree_ownership_report)
|
assert callable(assess_worktree_ownership_report)
|
||||||
|
|
||||||
|
|
||||||
def test_already_landed_classification_verifier_exported():
|
|
||||||
from review_proofs import assess_already_landed_classification_report
|
|
||||||
|
|
||||||
assert callable(assess_already_landed_classification_report)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pr_queue_cleanup_verifier_exported():
|
def test_pr_queue_cleanup_verifier_exported():
|
||||||
from review_proofs import assess_pr_queue_cleanup_report
|
from review_proofs import assess_pr_queue_cleanup_report
|
||||||
|
|
||||||
|
|||||||
@@ -1005,19 +1005,9 @@ class TestReviewPR(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestDeleteBranch(unittest.TestCase):
|
class TestDeleteBranch(unittest.TestCase):
|
||||||
|
|
||||||
DELETE_PROFILE = {
|
|
||||||
"profile_name": "test-deleter",
|
|
||||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
|
||||||
"forbidden_operations": [],
|
|
||||||
"audit_label": "test-deleter",
|
|
||||||
}
|
|
||||||
|
|
||||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
|
||||||
@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_delete_branch(self, _auth, mock_api, _profile):
|
def test_delete_branch(self, _auth, mock_api):
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
|
||||||
mock_api.return_value = {}
|
mock_api.return_value = {}
|
||||||
result = gitea_delete_branch(branch="feat/branch")
|
result = gitea_delete_branch(branch="feat/branch")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
@@ -3060,18 +3050,10 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
mock_api.return_value = [] # no open PRs
|
mock_api.return_value = [] # no open PRs
|
||||||
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||||
self.assertTrue(res["success"])
|
self.assertTrue(res["success"])
|
||||||
self.assertEqual(res["work_lease"]["operation_type"], "author_issue_work")
|
|
||||||
self.assertEqual(res["work_lease"]["issue_number"], 196)
|
|
||||||
self.assertEqual(res["work_lease"]["branch"], "feat/issue-196-mutations")
|
|
||||||
self.assertIn("created_at", res["work_lease"])
|
|
||||||
self.assertIn("expires_at", res["work_lease"])
|
|
||||||
self.assertIn("last_heartbeat_at", res["work_lease"])
|
|
||||||
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default")
|
|
||||||
self.assertTrue(os.path.exists(ISSUE_LOCK_FILE))
|
self.assertTrue(os.path.exists(ISSUE_LOCK_FILE))
|
||||||
with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
|
with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
|
||||||
lock = json.load(f)
|
lock = json.load(f)
|
||||||
self.assertIn("worktree_path", lock)
|
self.assertIn("worktree_path", lock)
|
||||||
self.assertIn("work_lease", lock)
|
|
||||||
|
|
||||||
def test_lock_issue_mismatch_branch_fails(self):
|
def test_lock_issue_mismatch_branch_fails(self):
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
@@ -3112,51 +3094,6 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||||
self.assertIn("already tied to an open PR", str(ctx.exception))
|
self.assertIn("already tied to an open PR", str(ctx.exception))
|
||||||
|
|
||||||
@patch(
|
|
||||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
|
||||||
return_value=_clean_master_git_state_for_lock(),
|
|
||||||
)
|
|
||||||
@patch("mcp_server.api_get_all")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_lock_issue_reused_by_remote_branch(self, _auth, mock_api, _git_state):
|
|
||||||
mock_api.side_effect = [
|
|
||||||
[],
|
|
||||||
[{"name": "feat/issue-196-existing-work"}],
|
|
||||||
]
|
|
||||||
with self.assertRaises(ValueError) as ctx:
|
|
||||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
|
||||||
self.assertIn("already has matching branch", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_lock_issue_blocks_active_same_operation_lease(self):
|
|
||||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
|
||||||
json.dump({
|
|
||||||
"issue_number": 196,
|
|
||||||
"branch_name": "feat/issue-196-other-work",
|
|
||||||
"worktree_path": "/tmp/other-worktree",
|
|
||||||
"work_lease": {
|
|
||||||
"operation_type": "author_issue_work",
|
|
||||||
"expires_at": "2999-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
}, f)
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
|
||||||
self.assertIn("already has an active author_issue_work lease", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_lock_issue_blocks_expired_same_operation_lease_for_recovery(self):
|
|
||||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
|
||||||
json.dump({
|
|
||||||
"issue_number": 196,
|
|
||||||
"branch_name": "feat/issue-196-other-work",
|
|
||||||
"worktree_path": "/tmp/other-worktree",
|
|
||||||
"work_lease": {
|
|
||||||
"operation_type": "author_issue_work",
|
|
||||||
"expires_at": "2000-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
}, f)
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
|
||||||
self.assertIn("Recovery review is required before takeover", str(ctx.exception))
|
|
||||||
|
|
||||||
@patch("mcp_server.api_get_all", return_value=[])
|
@patch("mcp_server.api_get_all", return_value=[])
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_lock_from_clean_scratch_worktree(self, _auth, _api):
|
def test_lock_from_clean_scratch_worktree(self, _auth, _api):
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
"""Tests for reconciler profile model (#304)."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
import gitea_mcp_server as mcp_server
|
|
||||||
import migrate_profiles
|
|
||||||
import reconciler_profile
|
|
||||||
|
|
||||||
|
|
||||||
PRGS_RECONCILER_ALLOWED = [
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.pr.comment",
|
|
||||||
"gitea.issue.comment",
|
|
||||||
"gitea.issue.close",
|
|
||||||
"gitea.pr.close",
|
|
||||||
]
|
|
||||||
PRGS_RECONCILER_FORBIDDEN = [
|
|
||||||
"gitea.pr.approve",
|
|
||||||
"gitea.pr.merge",
|
|
||||||
"gitea.pr.create",
|
|
||||||
"gitea.branch.push",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TestReconcilerProfileModel(unittest.TestCase):
|
|
||||||
def test_prgs_reconciler_shape_valid(self):
|
|
||||||
result = reconciler_profile.assess_reconciler_profile(
|
|
||||||
PRGS_RECONCILER_ALLOWED,
|
|
||||||
PRGS_RECONCILER_FORBIDDEN,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["is_reconciler_profile"])
|
|
||||||
self.assertTrue(result["valid"])
|
|
||||||
self.assertEqual(result["reasons"], [])
|
|
||||||
|
|
||||||
def test_author_profile_not_reconciler(self):
|
|
||||||
allowed = [
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.pr.create",
|
|
||||||
"gitea.branch.push",
|
|
||||||
"gitea.issue.comment",
|
|
||||||
]
|
|
||||||
forbidden = ["gitea.pr.approve", "gitea.pr.merge"]
|
|
||||||
self.assertFalse(
|
|
||||||
reconciler_profile.is_reconciler_profile(allowed, forbidden)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_reviewer_profile_not_reconciler(self):
|
|
||||||
allowed = [
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.pr.review",
|
|
||||||
"gitea.pr.approve",
|
|
||||||
"gitea.pr.merge",
|
|
||||||
]
|
|
||||||
forbidden = ["gitea.pr.create", "gitea.branch.push"]
|
|
||||||
self.assertFalse(
|
|
||||||
reconciler_profile.is_reconciler_profile(allowed, forbidden)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_broad_close_on_author_invalid(self):
|
|
||||||
allowed = PRGS_RECONCILER_ALLOWED + ["gitea.pr.create"]
|
|
||||||
result = reconciler_profile.assess_reconciler_profile(
|
|
||||||
allowed,
|
|
||||||
PRGS_RECONCILER_FORBIDDEN,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["valid"])
|
|
||||||
self.assertFalse(result["is_reconciler_profile"])
|
|
||||||
|
|
||||||
def test_role_kind_detects_reconciler(self):
|
|
||||||
role = mcp_server._role_kind(
|
|
||||||
PRGS_RECONCILER_ALLOWED,
|
|
||||||
PRGS_RECONCILER_FORBIDDEN,
|
|
||||||
)
|
|
||||||
self.assertEqual(role, "reconciler")
|
|
||||||
|
|
||||||
def test_migrate_infer_role_reconciler(self):
|
|
||||||
self.assertEqual(
|
|
||||||
migrate_profiles.infer_role("prgs-reconciler", "prgs-reconciler"),
|
|
||||||
"reconciler",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""Tests for reconciliation MCP assessment tools (#301)."""
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import mcp_server
|
|
||||||
from mcp_server import (
|
|
||||||
gitea_assess_already_landed_reconciliation,
|
|
||||||
gitea_scan_already_landed_open_prs,
|
|
||||||
)
|
|
||||||
|
|
||||||
AUTHOR_PROFILE = {
|
|
||||||
"profile_name": "prgs-author",
|
|
||||||
"allowed_operations": [
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.pr.comment",
|
|
||||||
"gitea.issue.comment",
|
|
||||||
"gitea.issue.close",
|
|
||||||
],
|
|
||||||
"forbidden_operations": ["gitea.pr.close"],
|
|
||||||
"audit_label": "prgs-author",
|
|
||||||
}
|
|
||||||
|
|
||||||
OPEN_PR = {
|
|
||||||
"number": 278,
|
|
||||||
"title": "Landed (Closes #263)",
|
|
||||||
"body": "",
|
|
||||||
"state": "open",
|
|
||||||
"head": {"ref": "feat/x", "sha": "a" * 40},
|
|
||||||
"base": {"ref": "master"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestReconciliationMcpTools(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.mock_api = patch("mcp_server.api_request").start()
|
|
||||||
self.mock_auth = patch(
|
|
||||||
"mcp_server.get_auth_header", return_value="token test"
|
|
||||||
).start()
|
|
||||||
patch("mcp_server.get_profile", return_value=AUTHOR_PROFILE).start()
|
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
patch.stopall()
|
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
|
||||||
|
|
||||||
def test_assess_returns_plan_without_mutations(self):
|
|
||||||
self.mock_api.return_value = OPEN_PR
|
|
||||||
with patch(
|
|
||||||
"mcp_server.reconciliation_workflow.assess_open_pr_reconciliation",
|
|
||||||
return_value={
|
|
||||||
"reconciliation_allowed": True,
|
|
||||||
"eligibility_class": "ALREADY_LANDED_RECONCILE_REQUIRED",
|
|
||||||
"candidate_head_sha": OPEN_PR["head"]["sha"],
|
|
||||||
"target_branch_sha": "deadbeef",
|
|
||||||
"linked_issue": 263,
|
|
||||||
"review_merge_allowed": False,
|
|
||||||
},
|
|
||||||
):
|
|
||||||
res = gitea_assess_already_landed_reconciliation(
|
|
||||||
pr_number=278, remote="prgs"
|
|
||||||
)
|
|
||||||
self.assertTrue(res["success"])
|
|
||||||
self.assertFalse(res["performed"])
|
|
||||||
self.assertEqual(res["plan"]["outcome"], "PARTIAL_RECONCILE_COMMENT_THEN_STOP")
|
|
||||||
self.assertFalse(res["review_merge_allowed"])
|
|
||||||
patch_calls = [
|
|
||||||
c for c in self.mock_api.call_args_list if c.args[0] == "PATCH"
|
|
||||||
]
|
|
||||||
self.assertEqual(patch_calls, [])
|
|
||||||
|
|
||||||
def test_scan_returns_candidates_with_pagination(self):
|
|
||||||
with patch(
|
|
||||||
"mcp_server.api_fetch_page",
|
|
||||||
return_value=([OPEN_PR], {
|
|
||||||
"page": 1,
|
|
||||||
"per_page": 50,
|
|
||||||
"is_final_page": True,
|
|
||||||
"has_more": False,
|
|
||||||
"next_page": None,
|
|
||||||
}),
|
|
||||||
), patch(
|
|
||||||
"mcp_server.reconciliation_workflow.fetch_target_branch",
|
|
||||||
return_value={
|
|
||||||
"success": True,
|
|
||||||
"target_branch_sha": "deadbeef",
|
|
||||||
"git_fetch_command": "git fetch prgs master",
|
|
||||||
},
|
|
||||||
), patch(
|
|
||||||
"mcp_server.reconciliation_workflow.assess_open_pr_reconciliation",
|
|
||||||
return_value={
|
|
||||||
"pr_number": 278,
|
|
||||||
"eligibility_class": "ALREADY_LANDED_RECONCILE_REQUIRED",
|
|
||||||
"reconciliation_allowed": True,
|
|
||||||
},
|
|
||||||
):
|
|
||||||
res = gitea_scan_already_landed_open_prs(remote="prgs", limit=50)
|
|
||||||
self.assertTrue(res["success"])
|
|
||||||
self.assertEqual(res["candidate_count"], 1)
|
|
||||||
self.assertTrue(res["pagination"]["inventory_complete"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"""Tests for already-landed reconciliation workflow helpers (#301)."""
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import reconciliation_workflow
|
|
||||||
from review_proofs import assess_reconcile_workflow_source
|
|
||||||
|
|
||||||
|
|
||||||
class TestReconciliationAssessment(unittest.TestCase):
|
|
||||||
def test_already_landed_open_pr(self):
|
|
||||||
with patch(
|
|
||||||
"reconciliation_workflow.is_head_ancestor_of_ref",
|
|
||||||
return_value=True,
|
|
||||||
):
|
|
||||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
|
||||||
pr={
|
|
||||||
"number": 278,
|
|
||||||
"state": "open",
|
|
||||||
"title": "Fix (Closes #263)",
|
|
||||||
"body": "",
|
|
||||||
"head": {"ref": "feat/x", "sha": "abc" * 13 + "a"},
|
|
||||||
"base": {"ref": "master"},
|
|
||||||
},
|
|
||||||
project_root="/tmp/repo",
|
|
||||||
remote="prgs",
|
|
||||||
target_branch="master",
|
|
||||||
target_fetch={
|
|
||||||
"success": True,
|
|
||||||
"target_ref": "prgs/master",
|
|
||||||
"target_branch_sha": "deadbeef",
|
|
||||||
"git_fetch_command": "git fetch prgs master",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(assessment["reconciliation_allowed"])
|
|
||||||
self.assertEqual(
|
|
||||||
assessment["eligibility_class"],
|
|
||||||
reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_not_landed_pr(self):
|
|
||||||
with patch(
|
|
||||||
"reconciliation_workflow.is_head_ancestor_of_ref",
|
|
||||||
return_value=False,
|
|
||||||
):
|
|
||||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
|
||||||
pr={
|
|
||||||
"number": 1,
|
|
||||||
"state": "open",
|
|
||||||
"title": "WIP",
|
|
||||||
"body": "",
|
|
||||||
"head": {"ref": "feat/y", "sha": "fff" * 13 + "f"},
|
|
||||||
"base": {"ref": "master"},
|
|
||||||
},
|
|
||||||
project_root="/tmp/repo",
|
|
||||||
remote="prgs",
|
|
||||||
target_branch="master",
|
|
||||||
target_fetch={
|
|
||||||
"success": True,
|
|
||||||
"target_ref": "prgs/master",
|
|
||||||
"target_branch_sha": "deadbeef",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertFalse(assessment["reconciliation_allowed"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestReconciliationPlan(unittest.TestCase):
|
|
||||||
def test_full_close_when_close_pr_allowed(self):
|
|
||||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
|
||||||
assessment={
|
|
||||||
"reconciliation_allowed": True,
|
|
||||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
|
||||||
},
|
|
||||||
capabilities={
|
|
||||||
"close_pr": True,
|
|
||||||
"comment_pr": True,
|
|
||||||
"close_issue": True,
|
|
||||||
"comment_issue": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
plan["outcome"],
|
|
||||||
reconciliation_workflow.OUTCOME_FULL_RECONCILE,
|
|
||||||
)
|
|
||||||
self.assertTrue(plan["close_pr_allowed"])
|
|
||||||
self.assertFalse(plan["review_merge_allowed"])
|
|
||||||
|
|
||||||
def test_comment_then_stop_without_close(self):
|
|
||||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
|
||||||
assessment={
|
|
||||||
"reconciliation_allowed": True,
|
|
||||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
|
||||||
},
|
|
||||||
capabilities={
|
|
||||||
"close_pr": False,
|
|
||||||
"comment_pr": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
plan["outcome"],
|
|
||||||
reconciliation_workflow.OUTCOME_PARTIAL_COMMENT,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_recovery_handoff_without_comment_or_close(self):
|
|
||||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
|
||||||
assessment={
|
|
||||||
"reconciliation_allowed": True,
|
|
||||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
|
||||||
},
|
|
||||||
capabilities={"close_pr": False, "comment_pr": False},
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
plan["outcome"],
|
|
||||||
reconciliation_workflow.OUTCOME_RECOVERY_HANDOFF,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_not_landed_no_action(self):
|
|
||||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
|
||||||
assessment={
|
|
||||||
"reconciliation_allowed": False,
|
|
||||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_NOT_LANDED,
|
|
||||||
"reasons": ["not ancestor"],
|
|
||||||
},
|
|
||||||
capabilities={"close_pr": True},
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
plan["outcome"],
|
|
||||||
reconciliation_workflow.OUTCOME_NOT_LANDED,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestReconcileWorkflowSource(unittest.TestCase):
|
|
||||||
def test_valid_report_passes(self):
|
|
||||||
report = (
|
|
||||||
"Task mode: reconcile-landed-pr\n"
|
|
||||||
"Workflow source: workflows/reconcile-landed-pr.md\n"
|
|
||||||
)
|
|
||||||
result = assess_reconcile_workflow_source(report)
|
|
||||||
self.assertTrue(result["complete"])
|
|
||||||
|
|
||||||
def test_missing_workflow_fails(self):
|
|
||||||
result = assess_reconcile_workflow_source("Task mode: reconcile-landed-pr")
|
|
||||||
self.assertFalse(result["complete"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -121,32 +121,6 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
self.assertTrue(res["allowed_in_current_session"])
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
self.assertFalse(res["different_mcp_namespace_required"])
|
self.assertFalse(res["different_mcp_namespace_required"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
|
||||||
def test_resolve_work_issue_author_profile_allowed(self, _auth, _api):
|
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
|
||||||
for task in ("work_issue", "work-issue"):
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
|
||||||
task=task, remote="prgs"
|
|
||||||
)
|
|
||||||
self.assertEqual(res["requested_task"], task)
|
|
||||||
self.assertEqual(
|
|
||||||
res["required_operation_permission"], "gitea.pr.create"
|
|
||||||
)
|
|
||||||
self.assertEqual(res["required_role_kind"], "author")
|
|
||||||
self.assertTrue(res["allowed_in_current_session"])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
|
||||||
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
|
|
||||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
|
||||||
task="work_issue", remote="prgs"
|
|
||||||
)
|
|
||||||
self.assertFalse(res["allowed_in_current_session"])
|
|
||||||
self.assertEqual(res["required_role_kind"], "author")
|
|
||||||
self.assertTrue(res["different_mcp_namespace_required"])
|
|
||||||
|
|
||||||
@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_resolve_issue_comment_task(self, _auth, _api):
|
def test_resolve_issue_comment_task(self, _auth, _api):
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
"""Tests for reviewer final-report schema verification (#391)."""
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from review_final_report_schema import assess_review_final_report_schema # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def _minimal_review_report(**overrides):
|
|
||||||
lines = [
|
|
||||||
"## Controller Handoff",
|
|
||||||
"",
|
|
||||||
"- Task: review PR #203",
|
|
||||||
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
|
||||||
"- Role: reviewer",
|
|
||||||
"- Identity: sysadmin / prgs-reviewer",
|
|
||||||
"- Issue/PR: #182 / PR #203",
|
|
||||||
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
|
||||||
"- Files changed: review_proofs.py",
|
|
||||||
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
|
|
||||||
"- Mutations: review only",
|
|
||||||
"- File edits by reviewer: none",
|
|
||||||
"- Worktree/index mutations: none",
|
|
||||||
"- Git ref mutations: git fetch prgs master",
|
|
||||||
"- MCP/Gitea mutations: review submitted",
|
|
||||||
"- Review mutations: request_changes submitted",
|
|
||||||
"- Merge mutations: none",
|
|
||||||
"- Cleanup mutations: none",
|
|
||||||
"- External-state mutations: none",
|
|
||||||
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
|
|
||||||
"- Current status: PR open",
|
|
||||||
"- Blockers: none",
|
|
||||||
"- Next: await author",
|
|
||||||
"- Safety: no self-review; no self-merge",
|
|
||||||
"- Selected PR: #203",
|
|
||||||
"- Reviewer eligibility: eligible",
|
|
||||||
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
|
||||||
"- Worktree path: branches/review-203",
|
|
||||||
"- Worktree dirty: clean",
|
|
||||||
"- Unrelated local mutations: none",
|
|
||||||
"- Review decision: request_changes",
|
|
||||||
"- Merge result: not attempted",
|
|
||||||
"- Linked issue: #182",
|
|
||||||
"- Linked issue live status: open (gitea_view_issue)",
|
|
||||||
"- Cleanup status: none",
|
|
||||||
]
|
|
||||||
text = "\n".join(lines)
|
|
||||||
for key, value in overrides.items():
|
|
||||||
prefix = f"- {key}:"
|
|
||||||
replaced = False
|
|
||||||
new_lines = []
|
|
||||||
for line in text.splitlines():
|
|
||||||
if line.strip().startswith(prefix):
|
|
||||||
new_lines.append(f"{prefix} {value}")
|
|
||||||
replaced = True
|
|
||||||
else:
|
|
||||||
new_lines.append(line)
|
|
||||||
text = "\n".join(new_lines)
|
|
||||||
if not replaced:
|
|
||||||
text += f"\n{prefix} {value}"
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
class TestReviewFinalReportSchema(unittest.TestCase):
|
|
||||||
def test_clean_report_passes(self):
|
|
||||||
result = assess_review_final_report_schema(
|
|
||||||
_minimal_review_report(),
|
|
||||||
linked_issue_lock={"issue_number": 182, "pr_number": 203},
|
|
||||||
)
|
|
||||||
self.assertFalse(result["blocked"])
|
|
||||||
self.assertIn(result["grade"], ("A", "downgraded"))
|
|
||||||
|
|
||||||
def test_legacy_pinned_reviewed_head_blocks(self):
|
|
||||||
report = _minimal_review_report(**{"Pinned reviewed head": "abc123"})
|
|
||||||
result = assess_review_final_report_schema(report)
|
|
||||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
|
||||||
self.assertIn("reviewer.legacy_stale_field", rule_ids)
|
|
||||||
|
|
||||||
def test_reviewed_head_without_validation_blocks(self):
|
|
||||||
report = _minimal_review_report().replace(
|
|
||||||
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
|
|
||||||
"- Validation: not run",
|
|
||||||
)
|
|
||||||
report += "\n- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
|
||||||
result = assess_review_final_report_schema(report)
|
|
||||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
|
||||||
self.assertIn("reviewer.reviewed_head_without_validation", rule_ids)
|
|
||||||
|
|
||||||
def test_merged_without_proof_blocks(self):
|
|
||||||
report = _minimal_review_report() + "\n\nPR #203 merged successfully."
|
|
||||||
result = assess_review_final_report_schema(report)
|
|
||||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
|
||||||
self.assertIn("reviewer.merged_without_proof", rule_ids)
|
|
||||||
|
|
||||||
def test_issue_closed_without_live_proof_blocks(self):
|
|
||||||
report = _minimal_review_report(
|
|
||||||
**{
|
|
||||||
"Linked issue live status": "closed",
|
|
||||||
"Read-only diagnostics": "gitea_view_pr only",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = assess_review_final_report_schema(report)
|
|
||||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
|
||||||
self.assertIn("reviewer.issue_closed_without_live_proof", rule_ids)
|
|
||||||
|
|
||||||
def test_full_suite_pass_with_ignored_tests_blocks(self):
|
|
||||||
report = (
|
|
||||||
_minimal_review_report()
|
|
||||||
+ "\nFull test suite passed with 0 failed; some tests ignored."
|
|
||||||
)
|
|
||||||
result = assess_review_final_report_schema(report)
|
|
||||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
|
||||||
self.assertIn("reviewer.full_suite_pass_ignored_tests", rule_ids)
|
|
||||||
|
|
||||||
def test_blocked_handoff_replay_blocks(self):
|
|
||||||
report = (
|
|
||||||
_minimal_review_report(**{"Blockers": "capability stop"})
|
|
||||||
+ "\nSafe next action: run gitea_merge_pr to finish."
|
|
||||||
)
|
|
||||||
result = assess_review_final_report_schema(report)
|
|
||||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
|
||||||
self.assertIn("reviewer.blocked_handoff_replay", rule_ids)
|
|
||||||
|
|
||||||
def test_narrative_handoff_drift_blocks(self):
|
|
||||||
report = (
|
|
||||||
_minimal_review_report()
|
|
||||||
+ "\n## Summary\nVerdict: APPROVE this PR."
|
|
||||||
)
|
|
||||||
result = assess_review_final_report_schema(report)
|
|
||||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
|
||||||
self.assertIn("reviewer.narrative_handoff_drift", rule_ids)
|
|
||||||
|
|
||||||
def test_pending_vs_approved_blocks(self):
|
|
||||||
report = _minimal_review_report(
|
|
||||||
**{"Review decision": "PENDING official review submitted approve"}
|
|
||||||
)
|
|
||||||
result = assess_review_final_report_schema(report)
|
|
||||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
|
||||||
self.assertIn("reviewer.pending_vs_approved", rule_ids)
|
|
||||||
|
|
||||||
def test_exported_from_review_proofs(self):
|
|
||||||
from review_proofs import assess_review_final_report_schema as exported
|
|
||||||
|
|
||||||
self.assertTrue(callable(exported))
|
|
||||||
|
|
||||||
|
|
||||||
class TestMcpValidateReviewFinalReport(unittest.TestCase):
|
|
||||||
def test_mcp_tool_callable(self):
|
|
||||||
import gitea_mcp_server
|
|
||||||
|
|
||||||
result = gitea_mcp_server.gitea_validate_review_final_report(
|
|
||||||
_minimal_review_report()
|
|
||||||
)
|
|
||||||
self.assertIn("grade", result)
|
|
||||||
self.assertIn("findings", result)
|
|
||||||
self.assertFalse(result["blocked"])
|
|
||||||
@@ -40,9 +40,6 @@ from review_proofs import ( # noqa: E402
|
|||||||
assess_create_issue_final_report,
|
assess_create_issue_final_report,
|
||||||
assess_create_issue_mode_isolation,
|
assess_create_issue_mode_isolation,
|
||||||
assess_create_issue_workflow_source,
|
assess_create_issue_workflow_source,
|
||||||
assess_work_issue_final_report,
|
|
||||||
assess_work_issue_mode_isolation,
|
|
||||||
assess_work_issue_workflow_source,
|
|
||||||
assess_edited_pr_inventory_coverage,
|
assess_edited_pr_inventory_coverage,
|
||||||
assess_empty_queue_report,
|
assess_empty_queue_report,
|
||||||
assess_fresh_issue_selection,
|
assess_fresh_issue_selection,
|
||||||
@@ -2323,52 +2320,6 @@ class TestCreateIssueFinalReport(unittest.TestCase):
|
|||||||
self.assertTrue(result["downgraded"])
|
self.assertTrue(result["downgraded"])
|
||||||
|
|
||||||
|
|
||||||
class TestWorkIssueFinalReport(unittest.TestCase):
|
|
||||||
"""#139: work-issue final-report verifier coverage."""
|
|
||||||
|
|
||||||
def _good_report(self):
|
|
||||||
return "\n".join([
|
|
||||||
"Task mode: work-issue",
|
|
||||||
"Workflow source: skills/llm-project-workflow/workflows/work-issue.md",
|
|
||||||
"## Controller Handoff",
|
|
||||||
"- Task: work-issue",
|
|
||||||
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
|
||||||
"- Role: author",
|
|
||||||
"- Identity: jcwalker3 / prgs-author",
|
|
||||||
"- Active profile: prgs-author",
|
|
||||||
"- Runtime context: prgs-author on prgs",
|
|
||||||
"- Selected issue: #139",
|
|
||||||
"- Branch name: feat/issue-139-role-aware-work-issue-routing",
|
|
||||||
"- PR number: not created in this session",
|
|
||||||
"- File edits by author: role_session_router.py",
|
|
||||||
"- Blockers: none",
|
|
||||||
"- Current status: implementing routing",
|
|
||||||
"- Safe next action: open PR",
|
|
||||||
"- Next: open PR",
|
|
||||||
"- Safety statement: no review/merge",
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_complete_work_issue_report_earns_a(self):
|
|
||||||
result = assess_work_issue_final_report(self._good_report())
|
|
||||||
self.assertEqual(result["grade"], "A")
|
|
||||||
self.assertFalse(result["downgraded"])
|
|
||||||
|
|
||||||
def test_missing_workflow_source_downgrades(self):
|
|
||||||
report = self._good_report().replace(
|
|
||||||
"workflows/work-issue.md", "SKILL.md only"
|
|
||||||
)
|
|
||||||
result = assess_work_issue_workflow_source(report)
|
|
||||||
self.assertTrue(result["downgraded"])
|
|
||||||
|
|
||||||
def test_review_field_in_handoff_downgrades(self):
|
|
||||||
report = self._good_report().replace(
|
|
||||||
"- Safety statement:",
|
|
||||||
"- Review decision: APPROVE\n- Safety statement:",
|
|
||||||
)
|
|
||||||
result = assess_work_issue_mode_isolation(report)
|
|
||||||
self.assertTrue(result["downgraded"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidationEnvironmentProof(unittest.TestCase):
|
class TestValidationEnvironmentProof(unittest.TestCase):
|
||||||
"""Issue #311: exact validation command and execution environment."""
|
"""Issue #311: exact validation command and execution environment."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import unittest
|
|
||||||
|
|
||||||
from reviewer_already_landed_classification import ( # noqa: E402
|
|
||||||
ALREADY_LANDED_ELIGIBILITY_CLASS,
|
|
||||||
assess_already_landed_classification_report,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAlreadyLandedClassification(unittest.TestCase):
|
|
||||||
def test_non_landed_report_passes(self):
|
|
||||||
result = assess_already_landed_classification_report(
|
|
||||||
"Selected the next eligible PR: PR #286."
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"])
|
|
||||||
self.assertFalse(result["landed_context"])
|
|
||||||
|
|
||||||
def test_oldest_eligible_pr_on_landed_blocks(self):
|
|
||||||
result = assess_already_landed_classification_report(
|
|
||||||
"PR is already landed.\n"
|
|
||||||
"Oldest eligible PR: PR #278."
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_next_eligible_pr_on_landed_blocks(self):
|
|
||||||
result = assess_already_landed_classification_report(
|
|
||||||
"Ancestor proof: passed\n"
|
|
||||||
"Selected the next eligible PR: PR #278."
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
|
|
||||||
def test_eligible_for_review_on_landed_blocks(self):
|
|
||||||
result = assess_already_landed_classification_report(
|
|
||||||
"Already landed on master and eligible for review next."
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
|
|
||||||
def test_proper_landed_wording_passes(self):
|
|
||||||
result = assess_already_landed_classification_report(
|
|
||||||
"Oldest open PR requiring action: PR #278\n"
|
|
||||||
f"Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}\n"
|
|
||||||
"Candidate head SHA: abc123\n"
|
|
||||||
"Reviewed head SHA: none\n"
|
|
||||||
"Queue blocked by already-landed PR requiring reconciliation",
|
|
||||||
selected_pr_already_landed=True,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"])
|
|
||||||
|
|
||||||
def test_pinned_reviewed_head_without_validation_blocks(self):
|
|
||||||
result = assess_already_landed_classification_report(
|
|
||||||
"Already landed.\n"
|
|
||||||
"Pinned reviewed head: abc123def456",
|
|
||||||
selected_pr_already_landed=True,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
|
|
||||||
def test_wrong_eligibility_class_blocks(self):
|
|
||||||
result = assess_already_landed_classification_report(
|
|
||||||
"Already landed.\n"
|
|
||||||
"Eligibility class: REVIEW_ELIGIBLE",
|
|
||||||
selected_pr_already_landed=True,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
|
|
||||||
def test_missing_queue_blocker_blocks(self):
|
|
||||||
result = assess_already_landed_classification_report(
|
|
||||||
f"Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}\n"
|
|
||||||
"Candidate head SHA: abc123\n"
|
|
||||||
"Reviewed head SHA: none",
|
|
||||||
selected_pr_already_landed=True,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
|
|
||||||
def test_exported_from_review_proofs(self):
|
|
||||||
from review_proofs import assess_already_landed_classification_report as exported
|
|
||||||
|
|
||||||
self.assertTrue(callable(exported))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"""Tests for proof-backed reviewer handoff verifier (#395)."""
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from reviewer_proof_backed_handoff import ( # noqa: E402
|
|
||||||
assess_proof_backed_handoff_report,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _good_report() -> str:
|
|
||||||
return "\n".join([
|
|
||||||
"Inventory pagination proof: is_final_page: true, has_more: false, total_count: 11",
|
|
||||||
"Earlier PRs skipped: #372 non-mergeable — merge simulation conflicts in review_proofs.py",
|
|
||||||
"Baseline worktree path: branches/baseline-master-pr383",
|
|
||||||
"Baseline target SHA: 4243b60ca32d79f1ee5e6b0f10d7570e9dea2937",
|
|
||||||
"Baseline dirty before validation: false",
|
|
||||||
"Baseline validation command: venv/bin/python -m pytest tests/ -q",
|
|
||||||
"Baseline validation result: 1 failed (test_clean_reviewer_report_passes)",
|
|
||||||
"Baseline dirty after validation: false",
|
|
||||||
"Cleanup mutations: git worktree list showed session worktrees removed",
|
|
||||||
"## Controller Handoff",
|
|
||||||
"- Inventory pagination proof: inventory_complete: true, total_count: 11",
|
|
||||||
"- Earlier PRs skipped: #372 conflict proof via merge simulation",
|
|
||||||
"- Baseline worktree used: true",
|
|
||||||
"- Baseline worktree path: branches/baseline-master-pr383",
|
|
||||||
"- Cleanup mutations: git worktree remove branches/review-pr383",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
class TestProofBackedHandoff(unittest.TestCase):
|
|
||||||
def test_fully_proof_backed_report_passes(self):
|
|
||||||
result = assess_proof_backed_handoff_report(
|
|
||||||
_good_report(),
|
|
||||||
action_log=[{"command": "gitea_list_prs", "result": "inventory_complete=true"}],
|
|
||||||
baseline_session={
|
|
||||||
"complete": True,
|
|
||||||
"clean_before": True,
|
|
||||||
"clean_after": True,
|
|
||||||
"command": "pytest",
|
|
||||||
"result": "passed",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"], result["reasons"])
|
|
||||||
|
|
||||||
def test_inventory_page_size_assumption_blocks(self):
|
|
||||||
report = (
|
|
||||||
"Inventory is complete because 13 results are less than page size 50."
|
|
||||||
)
|
|
||||||
result = assess_proof_backed_handoff_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(any("page-size" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_skip_without_conflict_proof_blocks(self):
|
|
||||||
report = "\n".join([
|
|
||||||
"Earlier PRs skipped: #372, #374",
|
|
||||||
"## Controller Handoff",
|
|
||||||
"- Earlier PRs skipped: #372, #374",
|
|
||||||
])
|
|
||||||
result = assess_proof_backed_handoff_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(any("skip" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_baseline_without_command_blocks(self):
|
|
||||||
report = "Baseline validation passed; same as master failures."
|
|
||||||
result = assess_proof_backed_handoff_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(any("baseline" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_cleanup_without_worktree_list_blocks(self):
|
|
||||||
report = "Worktrees were cleaned after merge."
|
|
||||||
result = assess_proof_backed_handoff_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(any("cleanup" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_cleanup_with_worktree_list_passes(self):
|
|
||||||
report = "Cleanup: git worktree list confirmed removal."
|
|
||||||
result = assess_proof_backed_handoff_report(
|
|
||||||
report,
|
|
||||||
action_log=[{"command": "git worktree list"}],
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"], result["reasons"])
|
|
||||||
|
|
||||||
def test_master_integration_requires_command(self):
|
|
||||||
report = "Merged master into the review worktree before validation."
|
|
||||||
result = assess_proof_backed_handoff_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(any("master integration" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_master_integration_with_action_log_passes(self):
|
|
||||||
report = "Merged master into review worktree; merge_exit=0"
|
|
||||||
result = assess_proof_backed_handoff_report(
|
|
||||||
report,
|
|
||||||
action_log=[{"command": "git merge --no-commit prgs/master"}],
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"], result["reasons"])
|
|
||||||
|
|
||||||
def test_live_proof_requires_source_classification(self):
|
|
||||||
report = "Live proof shows inventory complete."
|
|
||||||
result = assess_proof_backed_handoff_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(any("proof source" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
|
|
||||||
class TestExport(unittest.TestCase):
|
|
||||||
def test_review_proofs_reexport(self):
|
|
||||||
from review_proofs import assess_proof_backed_handoff_report as exported
|
|
||||||
|
|
||||||
self.assertTrue(callable(exported))
|
|
||||||
result = exported(_good_report(), inventory_session={"inventory_complete": True})
|
|
||||||
self.assertTrue(result["proven"], result["reasons"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"""Tests for transient validation failure history verifier (#396)."""
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
|
||||||
from reviewer_validation_failure_history import ( # noqa: E402
|
|
||||||
assess_validation_failure_history_report,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _full_history_report() -> str:
|
|
||||||
return "\n".join([
|
|
||||||
"Validation failure history",
|
|
||||||
"Failure #1:",
|
|
||||||
"Command: venv/bin/python -m pytest tests/test_worktrees.py -q",
|
|
||||||
"Failing test: tests/test_worktrees.py::TestWorktreeStart::test_rejects_untraceable_branches",
|
|
||||||
"Suspected cause: stale /tmp/gitea_issue_lock.json from prior session",
|
|
||||||
"Reproduced: no",
|
|
||||||
"On baseline master: no",
|
|
||||||
"PR-caused: no",
|
|
||||||
"What changed between runs: removed /tmp/gitea_issue_lock.json",
|
|
||||||
"Environmental cleanup: lock file removed before rerun",
|
|
||||||
"Validation: passed after transient failure investigation",
|
|
||||||
"Final validation command: venv/bin/python -m pytest tests/ -q",
|
|
||||||
"Final result: 1497 passed, 6 skipped",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidationFailureHistory(unittest.TestCase):
|
|
||||||
def test_no_failures_observed_passes(self):
|
|
||||||
result = assess_validation_failure_history_report(
|
|
||||||
"Validation: passed",
|
|
||||||
validation_session={"observed_failures": []},
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"])
|
|
||||||
|
|
||||||
def test_omitted_failure_blocks(self):
|
|
||||||
result = assess_validation_failure_history_report(
|
|
||||||
"Validation: passed\n1497 passed, 6 skipped",
|
|
||||||
validation_session={
|
|
||||||
"observed_failures": [{
|
|
||||||
"command": "pytest tests/test_worktrees.py -q",
|
|
||||||
"failing_test": (
|
|
||||||
"tests/test_worktrees.py::TestWorktreeStart::"
|
|
||||||
"test_rejects_untraceable_branches"
|
|
||||||
),
|
|
||||||
}],
|
|
||||||
"final_validation_status": "passed",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_full_history_with_transient_investigation_passes(self):
|
|
||||||
result = assess_validation_failure_history_report(
|
|
||||||
_full_history_report(),
|
|
||||||
validation_session={
|
|
||||||
"observed_failures": [{
|
|
||||||
"command": (
|
|
||||||
"venv/bin/python -m pytest tests/test_worktrees.py -q"
|
|
||||||
),
|
|
||||||
"failing_test": (
|
|
||||||
"tests/test_worktrees.py::TestWorktreeStart::"
|
|
||||||
"test_rejects_untraceable_branches"
|
|
||||||
),
|
|
||||||
"suspected_cause": "stale /tmp/gitea_issue_lock.json",
|
|
||||||
"reproduced": "no",
|
|
||||||
"on_baseline_master": "no",
|
|
||||||
"pr_caused": "no",
|
|
||||||
}],
|
|
||||||
"final_validation_status": "passed_after_transient_failure_investigation",
|
|
||||||
"environmental_contamination": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"], result["reasons"])
|
|
||||||
|
|
||||||
def test_baseline_equivalent_failure_documented(self):
|
|
||||||
report = "\n".join([
|
|
||||||
"Validation failure history",
|
|
||||||
"Command: pytest tests/test_example.py -q",
|
|
||||||
"Failing test: tests/test_example.py::test_flaky",
|
|
||||||
"Suspected cause: pre-existing master failure",
|
|
||||||
"On baseline master: yes",
|
|
||||||
"PR-caused: no",
|
|
||||||
"What changed between runs: none; failure matches baseline",
|
|
||||||
"Validation: passed after transient failure investigation",
|
|
||||||
])
|
|
||||||
result = assess_validation_failure_history_report(
|
|
||||||
report,
|
|
||||||
validation_session={
|
|
||||||
"observed_failures": [{
|
|
||||||
"command": "pytest tests/test_example.py -q",
|
|
||||||
"failing_test": "tests/test_example.py::test_flaky",
|
|
||||||
"on_baseline_master": "yes",
|
|
||||||
"pr_caused": "no",
|
|
||||||
}],
|
|
||||||
"final_validation_status": "passed_after_transient_failure_investigation",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"], result["reasons"])
|
|
||||||
|
|
||||||
def test_unknown_cause_plain_pass_blocks(self):
|
|
||||||
result = assess_validation_failure_history_report(
|
|
||||||
"Validation: passed",
|
|
||||||
validation_session={
|
|
||||||
"observed_failures": [{
|
|
||||||
"command": "pytest tests/ -q",
|
|
||||||
"failing_test": "tests/test_foo.py::test_bar",
|
|
||||||
"suspected_cause": "unknown",
|
|
||||||
}],
|
|
||||||
"final_validation_status": "passed",
|
|
||||||
"cause_unknown": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(any("transient" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_final_report_validator_rejects_omitted_failure(self):
|
|
||||||
result = assess_final_report_validator(
|
|
||||||
"Validation: passed",
|
|
||||||
"review_pr",
|
|
||||||
validation_session={
|
|
||||||
"observed_failures": [{
|
|
||||||
"command": "pytest tests/ -q",
|
|
||||||
"failing_test": "tests/test_foo.py::test_bar",
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(result["blocked"] or result["downgraded"])
|
|
||||||
self.assertTrue(
|
|
||||||
any(
|
|
||||||
f.get("rule_id") == "reviewer.validation_failure_history"
|
|
||||||
for f in result.get("findings") or []
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_exported_from_review_proofs(self):
|
|
||||||
from review_proofs import assess_validation_failure_history_report as exported
|
|
||||||
|
|
||||||
self.assertTrue(callable(exported))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -192,31 +192,6 @@ class TestRoleSessionRouter(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result.get("number"), 999)
|
self.assertEqual(result.get("number"), 999)
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
|
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
|
||||||
def test_work_issue_task_under_author_profile_allowed(self, _auth, _api):
|
|
||||||
with patch.dict(os.environ, self._env("prgs-author")):
|
|
||||||
for task_type in ("work_issue", "work-issue"):
|
|
||||||
route = mcp_server.gitea_route_task_session(
|
|
||||||
task_type=task_type, remote="prgs"
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
route["route_result"], role_session_router.ROUTE_ALLOWED
|
|
||||||
)
|
|
||||||
self.assertTrue(route["downstream_allowed"])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
|
|
||||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
|
||||||
def test_work_issue_task_under_reviewer_profile_routes_to_author(self, _auth, _api):
|
|
||||||
with patch.dict(os.environ, self._env("prgs-reviewer")):
|
|
||||||
route = mcp_server.gitea_route_task_session(
|
|
||||||
task_type="work-issue", remote="prgs"
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
route["route_result"], role_session_router.ROUTE_TO_AUTHOR
|
|
||||||
)
|
|
||||||
self.assertFalse(route["downstream_allowed"])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
|
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||||
def test_reviewer_task_under_reviewer_profile_allowed(self, _auth, _api):
|
def test_reviewer_task_under_reviewer_profile_allowed(self, _auth, _api):
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
"""Tests for web UI project registry (#427)."""
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
|
|
||||||
from webui.app import create_app
|
|
||||||
from webui.project_registry import (
|
|
||||||
default_registry_path,
|
|
||||||
load_registry,
|
|
||||||
project_to_dict,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestProjectRegistryLoader(unittest.TestCase):
|
|
||||||
def test_default_registry_loads_gitea_tools(self):
|
|
||||||
registry = load_registry()
|
|
||||||
self.assertEqual(registry.version, 1)
|
|
||||||
self.assertEqual(len(registry.projects), 1)
|
|
||||||
project = registry.projects[0]
|
|
||||||
self.assertEqual(project.id, "gitea-tools")
|
|
||||||
self.assertEqual(project.repo_name, "Gitea-Tools")
|
|
||||||
self.assertEqual(project.gitea_owner, "Scaled-Tech-Consulting")
|
|
||||||
self.assertEqual(project.remote_host, "https://gitea.prgs.cc")
|
|
||||||
self.assertEqual(project.profiles["author"], "prgs-author")
|
|
||||||
self.assertEqual(project.profiles["reviewer"], "prgs-reviewer")
|
|
||||||
self.assertEqual(project.profiles["reconciler"], "prgs-reconciler")
|
|
||||||
self.assertIn("skill", project.workflow_paths)
|
|
||||||
self.assertGreaterEqual(len(project.onboarding_checklist), 4)
|
|
||||||
|
|
||||||
def test_registry_rejects_credential_keys(self):
|
|
||||||
payload = {
|
|
||||||
"version": 1,
|
|
||||||
"projects": [
|
|
||||||
{
|
|
||||||
"id": "bad",
|
|
||||||
"repo_name": "Bad",
|
|
||||||
"gitea_owner": "Org",
|
|
||||||
"remote_host": "https://gitea.example.invalid",
|
|
||||||
"default_branch": "main",
|
|
||||||
"local_checkout_path": ".",
|
|
||||||
"profiles": {
|
|
||||||
"author": "a",
|
|
||||||
"reviewer": "r",
|
|
||||||
"reconciler": "c",
|
|
||||||
},
|
|
||||||
"workflow_paths": {"skill": "skills/x.md"},
|
|
||||||
"api_token": "secret",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
|
||||||
json.dump(payload, handle)
|
|
||||||
path = Path(handle.name)
|
|
||||||
try:
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
load_registry(path)
|
|
||||||
finally:
|
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
def test_default_registry_path_points_at_packaged_data(self):
|
|
||||||
path = default_registry_path()
|
|
||||||
self.assertTrue(path.name == "projects.registry.json")
|
|
||||||
self.assertTrue(path.parent.name == "data")
|
|
||||||
|
|
||||||
|
|
||||||
class TestProjectRegistryRoutes(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.client = TestClient(create_app())
|
|
||||||
|
|
||||||
def test_projects_page_lists_gitea_tools(self):
|
|
||||||
response = self.client.get("/projects")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertIn("Gitea-Tools", response.text)
|
|
||||||
self.assertIn("Scaled-Tech-Consulting", response.text)
|
|
||||||
self.assertIn("prgs-author", response.text)
|
|
||||||
self.assertNotIn("child issue", response.text.lower())
|
|
||||||
|
|
||||||
def test_project_detail_renders_checklist(self):
|
|
||||||
response = self.client.get("/projects/gitea-tools")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertIn("Onboarding checklist", response.text)
|
|
||||||
self.assertIn("Configure execution profiles", response.text)
|
|
||||||
self.assertIn("branches/", response.text)
|
|
||||||
|
|
||||||
def test_project_detail_404(self):
|
|
||||||
response = self.client.get("/projects/unknown-repo")
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
||||||
def test_api_projects_json(self):
|
|
||||||
response = self.client.get("/api/projects")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = response.json()
|
|
||||||
self.assertEqual(data["version"], 1)
|
|
||||||
self.assertEqual(len(data["projects"]), 1)
|
|
||||||
self.assertEqual(data["projects"][0]["id"], "gitea-tools")
|
|
||||||
self.assertIn("onboarding_checklist", data["projects"][0])
|
|
||||||
|
|
||||||
def test_project_to_dict_is_json_safe(self):
|
|
||||||
registry = load_registry()
|
|
||||||
encoded = json.dumps(project_to_dict(registry.projects[0]))
|
|
||||||
self.assertIn("gitea-tools", encoded)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""Tests for web UI prompt library (#428)."""
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
|
|
||||||
from webui.app import create_app
|
|
||||||
from webui.prompt_library import find_prompt, library_to_dict, load_prompt_library, prompt_to_dict
|
|
||||||
|
|
||||||
REQUIRED_PROMPT_SLUGS = frozenset({
|
|
||||||
"review-pr",
|
|
||||||
"work-issue",
|
|
||||||
"create-issue",
|
|
||||||
"comment-issue",
|
|
||||||
"cleanup",
|
|
||||||
"audit",
|
|
||||||
"onboarding",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class TestPromptLibraryLoader(unittest.TestCase):
|
|
||||||
def test_library_loads_required_prompts(self):
|
|
||||||
entries = load_prompt_library()
|
|
||||||
slugs = {entry.slug for entry in entries}
|
|
||||||
self.assertEqual(slugs, REQUIRED_PROMPT_SLUGS)
|
|
||||||
|
|
||||||
def test_workflow_hashes_present(self):
|
|
||||||
review = find_prompt("review-pr")
|
|
||||||
self.assertIsNotNone(review)
|
|
||||||
assert review is not None
|
|
||||||
self.assertTrue(review.workflow_hash)
|
|
||||||
self.assertEqual(len(review.workflow_hash), 64)
|
|
||||||
|
|
||||||
def test_prompt_text_is_short(self):
|
|
||||||
for entry in load_prompt_library():
|
|
||||||
self.assertLess(len(entry.prompt_text), 400)
|
|
||||||
self.assertNotIn("Do not improvise around the gates", entry.prompt_text)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPromptLibraryRoutes(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.client = TestClient(create_app())
|
|
||||||
|
|
||||||
def test_prompts_page_lists_all_entries(self):
|
|
||||||
response = self.client.get("/prompts")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
for label in (
|
|
||||||
"Review PR",
|
|
||||||
"Work issue",
|
|
||||||
"Create issue",
|
|
||||||
"Comment on issue",
|
|
||||||
"Post-merge cleanup",
|
|
||||||
"Reconciliation audit",
|
|
||||||
"Project onboarding",
|
|
||||||
):
|
|
||||||
self.assertIn(label, response.text)
|
|
||||||
self.assertIn("Copy prompt", response.text)
|
|
||||||
self.assertIn("sha256:", response.text)
|
|
||||||
self.assertNotIn("child issue", response.text.lower())
|
|
||||||
|
|
||||||
def test_prompt_detail_route(self):
|
|
||||||
response = self.client.get("/prompts/work-issue")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertIn("work-issue.md", response.text)
|
|
||||||
self.assertIn("Copy prompt", response.text)
|
|
||||||
|
|
||||||
def test_prompt_detail_404(self):
|
|
||||||
self.assertEqual(self.client.get("/prompts/missing").status_code, 404)
|
|
||||||
|
|
||||||
def test_api_prompts_json(self):
|
|
||||||
response = self.client.get("/api/prompts")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = response.json()
|
|
||||||
self.assertEqual(data["count"], 7)
|
|
||||||
review = next(item for item in data["prompts"] if item["slug"] == "review-pr")
|
|
||||||
self.assertIn("workflow_hash", review)
|
|
||||||
self.assertIn("review-merge-pr.md", review["workflow_path"])
|
|
||||||
|
|
||||||
def test_prompt_to_dict_roundtrip(self):
|
|
||||||
entry = load_prompt_library()[0]
|
|
||||||
encoded = json.dumps(prompt_to_dict(entry))
|
|
||||||
self.assertIn("workflow_path", encoded)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"""Tests for internal web UI skeleton (#426)."""
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
|
|
||||||
from webui.app import create_app
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebuiSkeleton(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.client = TestClient(create_app())
|
|
||||||
|
|
||||||
def test_health_returns_json(self):
|
|
||||||
response = self.client.get("/health")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = response.json()
|
|
||||||
self.assertEqual(data["status"], "ok")
|
|
||||||
self.assertEqual(data["service"], "mcp-control-plane-webui")
|
|
||||||
self.assertEqual(data["mode"], "read-only-mvp")
|
|
||||||
self.assertIn("timestamp", data)
|
|
||||||
|
|
||||||
def test_home_renders(self):
|
|
||||||
response = self.client.get("/")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertIn("Operator console", response.text)
|
|
||||||
self.assertIn("Read-only MVP", response.text)
|
|
||||||
|
|
||||||
def test_route_stubs_render(self):
|
|
||||||
for path in ("/runtime", "/audit"):
|
|
||||||
with self.subTest(path=path):
|
|
||||||
response = self.client.get(path)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertIn("child issue", response.text.lower())
|
|
||||||
|
|
||||||
def test_prompts_is_implemented(self):
|
|
||||||
response = self.client.get("/prompts")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertIn("Prompt library", response.text)
|
|
||||||
|
|
||||||
def test_projects_is_implemented(self):
|
|
||||||
response = self.client.get("/projects")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertIn("Gitea-Tools", response.text)
|
|
||||||
|
|
||||||
def test_extra_stub_routes(self):
|
|
||||||
for path in ("/worktrees", "/leases"):
|
|
||||||
with self.subTest(path=path):
|
|
||||||
self.assertEqual(self.client.get(path).status_code, 200)
|
|
||||||
|
|
||||||
def test_post_is_rejected(self):
|
|
||||||
response = self.client.post("/health")
|
|
||||||
self.assertEqual(response.status_code, 405)
|
|
||||||
self.assertEqual(response.json()["error"], "read-only-mvp")
|
|
||||||
|
|
||||||
def test_nav_links_on_all_pages(self):
|
|
||||||
for path in ("/", "/projects", "/prompts", "/runtime", "/audit"):
|
|
||||||
with self.subTest(path=path):
|
|
||||||
text = self.client.get(path).text
|
|
||||||
for href in ("/projects", "/prompts", "/runtime", "/audit"):
|
|
||||||
self.assertIn(f'href="{href}"', text)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Internal MCP Control Plane web UI (read-only MVP skeleton, #426)."""
|
|
||||||
|
|
||||||
from webui.app import create_app
|
|
||||||
|
|
||||||
__all__ = ["create_app"]
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Run the internal web UI: ``python -m webui``."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
from webui.app import create_app
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
host = os.environ.get("WEBUI_HOST", "127.0.0.1")
|
|
||||||
port = int(os.environ.get("WEBUI_PORT", "8765"))
|
|
||||||
uvicorn.run(create_app(), host=host, port=port, log_level="info")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
-166
@@ -1,166 +0,0 @@
|
|||||||
"""Starlette application for the internal read-only web UI (#426)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from starlette.applications import Starlette
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
|
||||||
from starlette.routing import Route
|
|
||||||
|
|
||||||
from webui.layout import render_page
|
|
||||||
from webui.project_registry import find_project, load_registry, registry_to_dict
|
|
||||||
from webui.project_views import render_project_detail, render_projects_list
|
|
||||||
from webui.prompt_library import find_prompt, library_to_dict
|
|
||||||
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
|
||||||
|
|
||||||
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
||||||
|
|
||||||
|
|
||||||
def _stub_page(title: str, description: str) -> HTMLResponse:
|
|
||||||
body = (
|
|
||||||
f"<h2>{title}</h2>"
|
|
||||||
f'<div class="stub"><p>{description}</p>'
|
|
||||||
"<p>Implementation tracked in a child issue of #425.</p></div>"
|
|
||||||
)
|
|
||||||
return HTMLResponse(render_page(title=title, body_html=body))
|
|
||||||
|
|
||||||
|
|
||||||
async def home(_request: Request) -> HTMLResponse:
|
|
||||||
body = (
|
|
||||||
"<h2>Operator console</h2>"
|
|
||||||
"<p>Local entry point for MCP Control Plane operational views.</p>"
|
|
||||||
"<ul>"
|
|
||||||
"<li><strong>Projects</strong> — registry and onboarding (#427)</li>"
|
|
||||||
"<li><strong>Prompts</strong> — canonical workflow prompt library (#428)</li>"
|
|
||||||
"<li><strong>Runtime</strong> — MCP health and stale-runtime detection (#430)</li>"
|
|
||||||
"<li><strong>Audit</strong> — final-report paste and validator preview (#431)</li>"
|
|
||||||
"<li><strong>Worktrees</strong> — branch hygiene dashboard (#432)</li>"
|
|
||||||
"<li><strong>Leases</strong> — collision and lease visibility (#433)</li>"
|
|
||||||
"</ul>"
|
|
||||||
)
|
|
||||||
return HTMLResponse(render_page(title="Home", body_html=body))
|
|
||||||
|
|
||||||
|
|
||||||
async def health(_request: Request) -> JSONResponse:
|
|
||||||
return JSONResponse({
|
|
||||||
"status": "ok",
|
|
||||||
"service": "mcp-control-plane-webui",
|
|
||||||
"mode": "read-only-mvp",
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
async def projects(_request: Request) -> HTMLResponse:
|
|
||||||
registry = load_registry()
|
|
||||||
return HTMLResponse(render_projects_list(registry))
|
|
||||||
|
|
||||||
|
|
||||||
async def project_detail(request: Request) -> HTMLResponse:
|
|
||||||
project_id = request.path_params["project_id"]
|
|
||||||
registry = load_registry()
|
|
||||||
project = find_project(registry, project_id)
|
|
||||||
if project is None:
|
|
||||||
return HTMLResponse(
|
|
||||||
render_page(
|
|
||||||
title="Project not found",
|
|
||||||
body_html=(
|
|
||||||
"<h2>Project not found</h2>"
|
|
||||||
f"<p>No registry entry for <code>{project_id}</code>.</p>"
|
|
||||||
'<p><a href="/projects">← All projects</a></p>'
|
|
||||||
),
|
|
||||||
),
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
return HTMLResponse(render_project_detail(project))
|
|
||||||
|
|
||||||
|
|
||||||
async def api_projects(_request: Request) -> JSONResponse:
|
|
||||||
registry = load_registry()
|
|
||||||
return JSONResponse(registry_to_dict(registry))
|
|
||||||
|
|
||||||
|
|
||||||
async def prompts(_request: Request) -> HTMLResponse:
|
|
||||||
return HTMLResponse(render_prompts_page())
|
|
||||||
|
|
||||||
|
|
||||||
async def prompt_detail(request: Request) -> HTMLResponse:
|
|
||||||
prompt_id = request.path_params["prompt_id"]
|
|
||||||
prompt = find_prompt(prompt_id)
|
|
||||||
if prompt is None:
|
|
||||||
return HTMLResponse(
|
|
||||||
render_page(
|
|
||||||
title="Prompt not found",
|
|
||||||
body_html=(
|
|
||||||
"<h2>Prompt not found</h2>"
|
|
||||||
f"<p>No library entry for <code>{prompt_id}</code>.</p>"
|
|
||||||
'<p><a href="/prompts">← All prompts</a></p>'
|
|
||||||
),
|
|
||||||
),
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
return HTMLResponse(render_prompt_detail(prompt))
|
|
||||||
|
|
||||||
|
|
||||||
async def api_prompts(_request: Request) -> JSONResponse:
|
|
||||||
return JSONResponse(library_to_dict())
|
|
||||||
|
|
||||||
|
|
||||||
async def runtime(_request: Request) -> HTMLResponse:
|
|
||||||
return _stub_page(
|
|
||||||
"Runtime",
|
|
||||||
"Runtime health will report MCP profile, preflight, and stale-server signals.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def audit(_request: Request) -> HTMLResponse:
|
|
||||||
return _stub_page(
|
|
||||||
"Audit",
|
|
||||||
"Report audit will accept pasted final reports and run validator previews.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def worktrees(_request: Request) -> HTMLResponse:
|
|
||||||
return _stub_page(
|
|
||||||
"Worktrees",
|
|
||||||
"Worktree hygiene will summarize branches/ session folders and cleanup risk.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def leases(_request: Request) -> HTMLResponse:
|
|
||||||
return _stub_page(
|
|
||||||
"Leases",
|
|
||||||
"Lease visibility will show active issue and reviewer PR leases.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
|
||||||
if request.method not in _READ_ONLY_METHODS:
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "read-only-mvp", "detail": f"{request.method} not permitted"},
|
|
||||||
status_code=405,
|
|
||||||
)
|
|
||||||
return JSONResponse({"error": "not_found"}, status_code=404)
|
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Starlette:
|
|
||||||
"""Build the read-only MVP Starlette app."""
|
|
||||||
return Starlette(
|
|
||||||
debug=False,
|
|
||||||
routes=[
|
|
||||||
Route("/", home, methods=["GET"]),
|
|
||||||
Route("/health", health, methods=["GET"]),
|
|
||||||
Route("/projects", projects, methods=["GET"]),
|
|
||||||
Route("/projects/{project_id}", project_detail, methods=["GET"]),
|
|
||||||
Route("/api/projects", api_projects, methods=["GET"]),
|
|
||||||
Route("/prompts", prompts, methods=["GET"]),
|
|
||||||
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
|
|
||||||
Route("/api/prompts", api_prompts, methods=["GET"]),
|
|
||||||
Route("/runtime", runtime, methods=["GET"]),
|
|
||||||
Route("/audit", audit, methods=["GET"]),
|
|
||||||
Route("/worktrees", worktrees, methods=["GET"]),
|
|
||||||
Route("/leases", leases, methods=["GET"]),
|
|
||||||
],
|
|
||||||
exception_handlers={405: method_not_allowed},
|
|
||||||
)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"projects": [
|
|
||||||
{
|
|
||||||
"id": "gitea-tools",
|
|
||||||
"repo_name": "Gitea-Tools",
|
|
||||||
"gitea_owner": "Scaled-Tech-Consulting",
|
|
||||||
"remote_host": "https://gitea.prgs.cc",
|
|
||||||
"default_branch": "master",
|
|
||||||
"local_checkout_path": ".",
|
|
||||||
"profiles": {
|
|
||||||
"author": "prgs-author",
|
|
||||||
"reviewer": "prgs-reviewer",
|
|
||||||
"reconciler": "prgs-reconciler"
|
|
||||||
},
|
|
||||||
"workflow_paths": {
|
|
||||||
"skill": "skills/llm-project-workflow/SKILL.md",
|
|
||||||
"work_issue": "skills/llm-project-workflow/workflows/work-issue.md",
|
|
||||||
"review_merge": "skills/llm-project-workflow/workflows/review-merge-pr.md"
|
|
||||||
},
|
|
||||||
"schema_paths": {
|
|
||||||
"mcp_config_v2": "gitea-mcp.v2-contexts.example.json",
|
|
||||||
"mcp_config_v1": "gitea-mcp.example.json"
|
|
||||||
},
|
|
||||||
"onboarding_checklist": [
|
|
||||||
{
|
|
||||||
"id": "profiles",
|
|
||||||
"title": "Configure execution profiles",
|
|
||||||
"description": "Install author, reviewer, and reconciler MCP profiles (prgs-author, prgs-reviewer, prgs-reconciler) in separate namespaces. Tokens stay in keychain — never in this registry."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "mcp_config",
|
|
||||||
"title": "Wire MCP v2 contexts",
|
|
||||||
"description": "Copy and customize gitea-mcp.v2-contexts.example.json for your machine. Map this repo path under projects with default_owner Scaled-Tech-Consulting and default_repo Gitea-Tools."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "wiki_gate",
|
|
||||||
"title": "Wiki publication readiness",
|
|
||||||
"description": "For wiki-tracked work, satisfy the live Gitea Wiki proof gate (#224) before closing issues. See docs/wiki/Safety-and-Gates.md."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "branches_layout",
|
|
||||||
"title": "Isolate work under branches/",
|
|
||||||
"description": "All LLM task edits happen in worktrees under branches/. Main checkout stays clean; use skills/llm-project-workflow templates for start-issue and review flows."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
-161
@@ -1,161 +0,0 @@
|
|||||||
"""Shared HTML layout for the internal web UI."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
NAV_ITEMS = (
|
|
||||||
("/", "Home"),
|
|
||||||
("/projects", "Projects"),
|
|
||||||
("/prompts", "Prompts"),
|
|
||||||
("/runtime", "Runtime"),
|
|
||||||
("/audit", "Audit"),
|
|
||||||
("/worktrees", "Worktrees"),
|
|
||||||
("/leases", "Leases"),
|
|
||||||
)
|
|
||||||
|
|
||||||
MVP_NOTICE = (
|
|
||||||
"Read-only MVP — Gitea, MCP tools, and canonical workflows remain the "
|
|
||||||
"source of truth. No mutation endpoints."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
|
|
||||||
nav_links = "".join(
|
|
||||||
f'<a href="{href}">{label}</a>' for href, label in NAV_ITEMS
|
|
||||||
)
|
|
||||||
return f"""<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>{title} · MCP Control Plane</title>
|
|
||||||
<style>
|
|
||||||
:root {{
|
|
||||||
--bg: #0f1419;
|
|
||||||
--surface: #1a2332;
|
|
||||||
--text: #e7ecf3;
|
|
||||||
--muted: #8b9cb3;
|
|
||||||
--accent: #5b9fd4;
|
|
||||||
--border: #2a3648;
|
|
||||||
}}
|
|
||||||
* {{ box-sizing: border-box; }}
|
|
||||||
body {{
|
|
||||||
margin: 0;
|
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.5;
|
|
||||||
}}
|
|
||||||
header {{
|
|
||||||
background: var(--surface);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
}}
|
|
||||||
header h1 {{
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}}
|
|
||||||
nav {{
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem 1rem;
|
|
||||||
}}
|
|
||||||
nav a {{
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}}
|
|
||||||
nav a:hover {{ text-decoration: underline; }}
|
|
||||||
main {{
|
|
||||||
max-width: 52rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem 1.25rem 2.5rem;
|
|
||||||
}}
|
|
||||||
.notice {{
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
padding: 0.65rem 0.85rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--surface);
|
|
||||||
}}
|
|
||||||
h2 {{ margin-top: 0; font-size: 1.35rem; }}
|
|
||||||
p {{ color: var(--muted); }}
|
|
||||||
.stub {{
|
|
||||||
border-left: 3px solid var(--accent);
|
|
||||||
padding-left: 0.85rem;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}}
|
|
||||||
.meta {{ font-size: 0.85rem; }}
|
|
||||||
table.registry, table.detail {{
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 1rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}}
|
|
||||||
table.registry th, table.registry td,
|
|
||||||
table.detail th, table.detail td {{
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.45rem 0.6rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}}
|
|
||||||
table.registry th, table.detail th {{
|
|
||||||
color: var(--muted);
|
|
||||||
font-weight: 500;
|
|
||||||
}}
|
|
||||||
code {{
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: var(--text);
|
|
||||||
}}
|
|
||||||
ol.checklist {{
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
margin: 0.5rem 0 1.5rem;
|
|
||||||
}}
|
|
||||||
ol.checklist li {{ margin-bottom: 0.85rem; }}
|
|
||||||
ol.checklist p {{ margin: 0.25rem 0 0; font-size: 0.9rem; }}
|
|
||||||
.prompt-card {{
|
|
||||||
margin: 1.25rem 0 1.75rem;
|
|
||||||
padding: 1rem 1.1rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--surface);
|
|
||||||
}}
|
|
||||||
.prompt-card h3 {{ margin: 0 0 0.5rem; font-size: 1.05rem; }}
|
|
||||||
pre.prompt-text {{
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
margin: 0.75rem 0;
|
|
||||||
padding: 0.75rem 0.85rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text);
|
|
||||||
}}
|
|
||||||
.copy-btn {{
|
|
||||||
background: var(--accent);
|
|
||||||
color: #0b1219;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.4rem 0.85rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}}
|
|
||||||
.copy-btn:hover {{ filter: brightness(1.08); }}
|
|
||||||
.muted {{ color: var(--muted); }}
|
|
||||||
</style>
|
|
||||||
{extra_head}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>MCP Control Plane</h1>
|
|
||||||
<nav>{nav_links}</nav>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<p class="notice">{MVP_NOTICE}</p>
|
|
||||||
{body_html}
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
"""Load and validate the web UI project registry (#427)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
_FORBIDDEN_EXACT_KEYS = frozenset({
|
|
||||||
"token",
|
|
||||||
"password",
|
|
||||||
"secret",
|
|
||||||
"credential",
|
|
||||||
"auth",
|
|
||||||
"api_key",
|
|
||||||
"api-key",
|
|
||||||
})
|
|
||||||
_FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_")
|
|
||||||
_FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_forbidden_key(key: str) -> bool:
|
|
||||||
lowered = key.lower()
|
|
||||||
if lowered in _FORBIDDEN_EXACT_KEYS:
|
|
||||||
return True
|
|
||||||
return (
|
|
||||||
lowered.startswith(_FORBIDDEN_KEY_PREFIXES)
|
|
||||||
or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES)
|
|
||||||
)
|
|
||||||
|
|
||||||
_REQUIRED_PROJECT_FIELDS = (
|
|
||||||
"id",
|
|
||||||
"repo_name",
|
|
||||||
"gitea_owner",
|
|
||||||
"remote_host",
|
|
||||||
"default_branch",
|
|
||||||
"local_checkout_path",
|
|
||||||
"profiles",
|
|
||||||
"workflow_paths",
|
|
||||||
)
|
|
||||||
|
|
||||||
_REQUIRED_PROFILE_ROLES = ("author", "reviewer", "reconciler")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class OnboardingStep:
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
description: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProjectRecord:
|
|
||||||
id: str
|
|
||||||
repo_name: str
|
|
||||||
gitea_owner: str
|
|
||||||
remote_host: str
|
|
||||||
default_branch: str
|
|
||||||
local_checkout_path: str
|
|
||||||
profiles: dict[str, str]
|
|
||||||
workflow_paths: dict[str, str]
|
|
||||||
schema_paths: dict[str, str]
|
|
||||||
onboarding_checklist: tuple[OnboardingStep, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProjectRegistry:
|
|
||||||
version: int
|
|
||||||
projects: tuple[ProjectRecord, ...]
|
|
||||||
source_path: Path
|
|
||||||
|
|
||||||
|
|
||||||
def default_registry_path() -> Path:
|
|
||||||
override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip()
|
|
||||||
if override:
|
|
||||||
return Path(override).expanduser().resolve()
|
|
||||||
return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def _reject_credential_keys(obj: Any, *, path: str = "") -> None:
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
for key, value in obj.items():
|
|
||||||
key_path = f"{path}.{key}" if path else key
|
|
||||||
if _is_forbidden_key(key):
|
|
||||||
raise ValueError(f"registry must not store credentials ({key_path})")
|
|
||||||
_reject_credential_keys(value, path=key_path)
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
for index, item in enumerate(obj):
|
|
||||||
_reject_credential_keys(item, path=f"{path}[{index}]")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_onboarding(raw: list[dict[str, Any]] | None) -> tuple[OnboardingStep, ...]:
|
|
||||||
if not raw:
|
|
||||||
return ()
|
|
||||||
steps: list[OnboardingStep] = []
|
|
||||||
for item in raw:
|
|
||||||
steps.append(
|
|
||||||
OnboardingStep(
|
|
||||||
id=str(item["id"]),
|
|
||||||
title=str(item["title"]),
|
|
||||||
description=str(item["description"]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tuple(steps)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_project(raw: dict[str, Any]) -> ProjectRecord:
|
|
||||||
missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw]
|
|
||||||
if missing:
|
|
||||||
raise ValueError(f"project missing required fields: {', '.join(missing)}")
|
|
||||||
|
|
||||||
profiles = raw["profiles"]
|
|
||||||
if not isinstance(profiles, dict):
|
|
||||||
raise ValueError("profiles must be an object")
|
|
||||||
for role in _REQUIRED_PROFILE_ROLES:
|
|
||||||
if role not in profiles or not profiles[role]:
|
|
||||||
raise ValueError(f"profiles.{role} is required")
|
|
||||||
|
|
||||||
workflow_paths = raw["workflow_paths"]
|
|
||||||
if not isinstance(workflow_paths, dict) or not workflow_paths:
|
|
||||||
raise ValueError("workflow_paths must be a non-empty object")
|
|
||||||
|
|
||||||
schema_paths = raw.get("schema_paths") or {}
|
|
||||||
if not isinstance(schema_paths, dict):
|
|
||||||
raise ValueError("schema_paths must be an object when present")
|
|
||||||
|
|
||||||
return ProjectRecord(
|
|
||||||
id=str(raw["id"]),
|
|
||||||
repo_name=str(raw["repo_name"]),
|
|
||||||
gitea_owner=str(raw["gitea_owner"]),
|
|
||||||
remote_host=str(raw["remote_host"]),
|
|
||||||
default_branch=str(raw["default_branch"]),
|
|
||||||
local_checkout_path=str(raw["local_checkout_path"]),
|
|
||||||
profiles={role: str(profiles[role]) for role in _REQUIRED_PROFILE_ROLES},
|
|
||||||
workflow_paths={key: str(value) for key, value in workflow_paths.items()},
|
|
||||||
schema_paths={key: str(value) for key, value in schema_paths.items()},
|
|
||||||
onboarding_checklist=_parse_onboarding(raw.get("onboarding_checklist")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_registry(path: Path | None = None) -> ProjectRegistry:
|
|
||||||
"""Load the versioned project registry from disk."""
|
|
||||||
source = (path or default_registry_path()).resolve()
|
|
||||||
raw_text = source.read_text(encoding="utf-8")
|
|
||||||
payload = json.loads(raw_text)
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise ValueError("registry root must be an object")
|
|
||||||
|
|
||||||
version = payload.get("version")
|
|
||||||
if version != 1:
|
|
||||||
raise ValueError(f"unsupported registry version: {version!r}")
|
|
||||||
|
|
||||||
_reject_credential_keys(payload)
|
|
||||||
|
|
||||||
projects_raw = payload.get("projects")
|
|
||||||
if not isinstance(projects_raw, list) or not projects_raw:
|
|
||||||
raise ValueError("projects must be a non-empty array")
|
|
||||||
|
|
||||||
projects = tuple(_parse_project(item) for item in projects_raw)
|
|
||||||
return ProjectRegistry(version=version, projects=projects, source_path=source)
|
|
||||||
|
|
||||||
|
|
||||||
def project_to_dict(project: ProjectRecord) -> dict[str, Any]:
|
|
||||||
"""Serialize a project for JSON API responses."""
|
|
||||||
return {
|
|
||||||
"id": project.id,
|
|
||||||
"repo_name": project.repo_name,
|
|
||||||
"gitea_owner": project.gitea_owner,
|
|
||||||
"remote_host": project.remote_host,
|
|
||||||
"default_branch": project.default_branch,
|
|
||||||
"local_checkout_path": project.local_checkout_path,
|
|
||||||
"profiles": dict(project.profiles),
|
|
||||||
"workflow_paths": dict(project.workflow_paths),
|
|
||||||
"schema_paths": dict(project.schema_paths),
|
|
||||||
"onboarding_checklist": [
|
|
||||||
{"id": step.id, "title": step.title, "description": step.description}
|
|
||||||
for step in project.onboarding_checklist
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"version": registry.version,
|
|
||||||
"source_path": str(registry.source_path),
|
|
||||||
"projects": [project_to_dict(project) for project in registry.projects],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None:
|
|
||||||
for project in registry.projects:
|
|
||||||
if project.id == project_id:
|
|
||||||
return project
|
|
||||||
return None
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"""HTML views for project registry pages (#427)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import html
|
|
||||||
|
|
||||||
from webui.layout import render_page
|
|
||||||
from webui.project_registry import ProjectRecord, ProjectRegistry
|
|
||||||
|
|
||||||
|
|
||||||
def _escape(text: str) -> str:
|
|
||||||
return html.escape(text, quote=True)
|
|
||||||
|
|
||||||
|
|
||||||
def render_projects_list(registry: ProjectRegistry) -> str:
|
|
||||||
rows = []
|
|
||||||
for project in registry.projects:
|
|
||||||
rows.append(
|
|
||||||
"<tr>"
|
|
||||||
f"<td><a href=\"/projects/{_escape(project.id)}\">{_escape(project.repo_name)}</a></td>"
|
|
||||||
f"<td>{_escape(project.gitea_owner)}</td>"
|
|
||||||
f"<td>{_escape(project.remote_host)}</td>"
|
|
||||||
f"<td>{_escape(project.default_branch)}</td>"
|
|
||||||
f"<td><code>{_escape(project.profiles['author'])}</code></td>"
|
|
||||||
"</tr>"
|
|
||||||
)
|
|
||||||
table = (
|
|
||||||
"<table class=\"registry\">"
|
|
||||||
"<thead><tr>"
|
|
||||||
"<th>Repository</th><th>Owner</th><th>Remote</th>"
|
|
||||||
"<th>Branch</th><th>Author profile</th>"
|
|
||||||
"</tr></thead>"
|
|
||||||
f"<tbody>{''.join(rows)}</tbody></table>"
|
|
||||||
)
|
|
||||||
body = (
|
|
||||||
"<h2>Projects</h2>"
|
|
||||||
"<p>Configured repositories managed by the MCP Control Plane.</p>"
|
|
||||||
f"<p class=\"meta\">Registry: <code>{_escape(str(registry.source_path))}</code> "
|
|
||||||
f"(version {registry.version})</p>"
|
|
||||||
f"{table}"
|
|
||||||
"<p><a href=\"/api/projects\">JSON API</a></p>"
|
|
||||||
)
|
|
||||||
return render_page(title="Projects", body_html=body)
|
|
||||||
|
|
||||||
|
|
||||||
def render_project_detail(project: ProjectRecord) -> str:
|
|
||||||
profile_rows = "".join(
|
|
||||||
f"<tr><th>{_escape(role)}</th><td><code>{_escape(name)}</code></td></tr>"
|
|
||||||
for role, name in project.profiles.items()
|
|
||||||
)
|
|
||||||
workflow_rows = "".join(
|
|
||||||
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
|
|
||||||
for key, path in project.workflow_paths.items()
|
|
||||||
)
|
|
||||||
schema_rows = "".join(
|
|
||||||
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
|
|
||||||
for key, path in project.schema_paths.items()
|
|
||||||
)
|
|
||||||
checklist_items = []
|
|
||||||
for index, step in enumerate(project.onboarding_checklist, start=1):
|
|
||||||
checklist_items.append(
|
|
||||||
"<li>"
|
|
||||||
f"<strong>{index}. {_escape(step.title)}</strong>"
|
|
||||||
f"<p>{_escape(step.description)}</p>"
|
|
||||||
"</li>"
|
|
||||||
)
|
|
||||||
checklist_html = (
|
|
||||||
"<ol class=\"checklist\">" + "".join(checklist_items) + "</ol>"
|
|
||||||
if checklist_items
|
|
||||||
else "<p>No onboarding steps defined.</p>"
|
|
||||||
)
|
|
||||||
body = (
|
|
||||||
f"<h2>{_escape(project.repo_name)}</h2>"
|
|
||||||
"<p><a href=\"/projects\">← All projects</a></p>"
|
|
||||||
"<h3>Identity</h3>"
|
|
||||||
"<table class=\"detail\">"
|
|
||||||
f"<tr><th>Registry id</th><td><code>{_escape(project.id)}</code></td></tr>"
|
|
||||||
f"<tr><th>Gitea owner</th><td>{_escape(project.gitea_owner)}</td></tr>"
|
|
||||||
f"<tr><th>Remote host</th><td>{_escape(project.remote_host)}</td></tr>"
|
|
||||||
f"<tr><th>Default branch</th><td><code>{_escape(project.default_branch)}</code></td></tr>"
|
|
||||||
f"<tr><th>Local checkout</th><td><code>{_escape(project.local_checkout_path)}</code></td></tr>"
|
|
||||||
"</table>"
|
|
||||||
"<h3>Profiles</h3>"
|
|
||||||
f"<table class=\"detail\">{profile_rows}</table>"
|
|
||||||
"<h3>Workflow paths</h3>"
|
|
||||||
f"<table class=\"detail\">{workflow_rows}</table>"
|
|
||||||
"<h3>Schema paths</h3>"
|
|
||||||
f"<table class=\"detail\">{schema_rows}</table>"
|
|
||||||
"<h3>Onboarding checklist</h3>"
|
|
||||||
"<p class=\"meta\">Read-only MVP — complete these steps outside the UI.</p>"
|
|
||||||
f"{checklist_html}"
|
|
||||||
)
|
|
||||||
return render_page(title=project.repo_name, body_html=body)
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
"""Canonical workflow prompt library for the internal web UI (#428)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
_WORKFLOW_ROOT = Path("skills/llm-project-workflow/workflows")
|
|
||||||
|
|
||||||
_DEFAULT_PROMPT_RE = re.compile(
|
|
||||||
r"\*\*Default task prompt:\*\*\s*\n+>\s*(.+?)(?=\n\n|\nDo not improvise)",
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
|
||||||
_FRONTMATTER_TASK_MODE_RE = re.compile(r"^task_mode:\s*(\S+)", re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PromptEntry:
|
|
||||||
slug: str
|
|
||||||
label: str
|
|
||||||
prompt_text: str
|
|
||||||
workflow_path: str
|
|
||||||
task_mode: str | None
|
|
||||||
workflow_hash: str | None
|
|
||||||
source_note: str
|
|
||||||
|
|
||||||
|
|
||||||
def _repo_root() -> Path:
|
|
||||||
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
|
|
||||||
if override:
|
|
||||||
return Path(override).resolve()
|
|
||||||
return Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
def _workflow_file(path: str) -> Path:
|
|
||||||
return _repo_root() / path
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256_hex(content: str) -> str:
|
|
||||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _read_workflow(path: str) -> tuple[str, str]:
|
|
||||||
file_path = _workflow_file(path)
|
|
||||||
text = file_path.read_text(encoding="utf-8")
|
|
||||||
return text, _sha256_hex(text)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_default_prompt(markdown: str) -> str | None:
|
|
||||||
match = _DEFAULT_PROMPT_RE.search(markdown)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
lines = [line.strip() for line in match.group(1).splitlines()]
|
|
||||||
return " ".join(line for line in lines if line)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_task_mode(markdown: str) -> str | None:
|
|
||||||
match = _FRONTMATTER_TASK_MODE_RE.search(markdown)
|
|
||||||
return match.group(1) if match else None
|
|
||||||
|
|
||||||
|
|
||||||
def _entry_from_workflow(
|
|
||||||
*,
|
|
||||||
slug: str,
|
|
||||||
label: str,
|
|
||||||
workflow_path: str,
|
|
||||||
prompt_override: str | None = None,
|
|
||||||
source_note: str = "",
|
|
||||||
) -> PromptEntry:
|
|
||||||
markdown, digest = _read_workflow(workflow_path)
|
|
||||||
prompt_text = prompt_override or _extract_default_prompt(markdown)
|
|
||||||
if not prompt_text:
|
|
||||||
raise ValueError(f"No default task prompt found in {workflow_path}")
|
|
||||||
return PromptEntry(
|
|
||||||
slug=slug,
|
|
||||||
label=label,
|
|
||||||
prompt_text=prompt_text,
|
|
||||||
workflow_path=workflow_path,
|
|
||||||
task_mode=_extract_task_mode(markdown),
|
|
||||||
workflow_hash=digest,
|
|
||||||
source_note=source_note,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _static_entry(
|
|
||||||
*,
|
|
||||||
slug: str,
|
|
||||||
label: str,
|
|
||||||
prompt_text: str,
|
|
||||||
workflow_path: str,
|
|
||||||
source_note: str,
|
|
||||||
) -> PromptEntry:
|
|
||||||
path = _workflow_file(workflow_path)
|
|
||||||
digest = _sha256_hex(path.read_text(encoding="utf-8")) if path.is_file() else None
|
|
||||||
markdown = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
||||||
return PromptEntry(
|
|
||||||
slug=slug,
|
|
||||||
label=label,
|
|
||||||
prompt_text=prompt_text,
|
|
||||||
workflow_path=workflow_path,
|
|
||||||
task_mode=_extract_task_mode(markdown) if markdown else None,
|
|
||||||
workflow_hash=digest,
|
|
||||||
source_note=source_note,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_prompt_library() -> tuple[PromptEntry, ...]:
|
|
||||||
"""Load operator prompts derived from canonical workflows."""
|
|
||||||
entries = (
|
|
||||||
_entry_from_workflow(
|
|
||||||
slug="review-pr",
|
|
||||||
label="Review PR",
|
|
||||||
workflow_path=str(_WORKFLOW_ROOT / "review-merge-pr.md"),
|
|
||||||
),
|
|
||||||
_entry_from_workflow(
|
|
||||||
slug="work-issue",
|
|
||||||
label="Work issue",
|
|
||||||
workflow_path=str(_WORKFLOW_ROOT / "work-issue.md"),
|
|
||||||
),
|
|
||||||
_entry_from_workflow(
|
|
||||||
slug="create-issue",
|
|
||||||
label="Create issue",
|
|
||||||
workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
|
|
||||||
),
|
|
||||||
_static_entry(
|
|
||||||
slug="comment-issue",
|
|
||||||
label="Comment on issue",
|
|
||||||
workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
|
|
||||||
prompt_text=(
|
|
||||||
"Comment on the target Gitea issue only if exact comment_issue "
|
|
||||||
"capability is proven. Load the canonical create-issue workflow "
|
|
||||||
"first and follow §16 (comment-on-existing issue rule). Include "
|
|
||||||
"specific evidence; do not duplicate existing comments."
|
|
||||||
),
|
|
||||||
source_note="Derived from create-issue.md §16; full policy remains in the workflow file.",
|
|
||||||
),
|
|
||||||
_static_entry(
|
|
||||||
slug="cleanup",
|
|
||||||
label="Post-merge cleanup",
|
|
||||||
workflow_path="skills/llm-project-workflow/templates/worktree-cleanup.md",
|
|
||||||
prompt_text=(
|
|
||||||
"Task: clean up branch/worktree for PR #<pr> / issue #<n> after merge. "
|
|
||||||
"Confirm the merge on remote master before any deletion; never "
|
|
||||||
"force-remove a dirty worktree."
|
|
||||||
),
|
|
||||||
source_note="Full cleanup steps live in templates/worktree-cleanup.md.",
|
|
||||||
),
|
|
||||||
_entry_from_workflow(
|
|
||||||
slug="audit",
|
|
||||||
label="Reconciliation audit",
|
|
||||||
workflow_path=str(_WORKFLOW_ROOT / "reconcile-landed-pr.md"),
|
|
||||||
),
|
|
||||||
_static_entry(
|
|
||||||
slug="onboarding",
|
|
||||||
label="Project onboarding",
|
|
||||||
workflow_path="skills/llm-project-workflow/SKILL.md",
|
|
||||||
prompt_text=(
|
|
||||||
"Onboard this repository into the MCP Control Plane: prove identity "
|
|
||||||
"and task capability, configure author/reviewer/reconciler profiles "
|
|
||||||
"in separate namespaces, then complete the checklist at /projects. "
|
|
||||||
"Canonical router: skills/llm-project-workflow/SKILL.md."
|
|
||||||
),
|
|
||||||
source_note="Checklist details live in webui/data/projects.registry.json and /projects.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def find_prompt(slug: str) -> PromptEntry | None:
|
|
||||||
for entry in load_prompt_library():
|
|
||||||
if entry.slug == slug:
|
|
||||||
return entry
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_to_dict(entry: PromptEntry) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"slug": entry.slug,
|
|
||||||
"label": entry.label,
|
|
||||||
"prompt_text": entry.prompt_text,
|
|
||||||
"workflow_path": entry.workflow_path,
|
|
||||||
"task_mode": entry.task_mode,
|
|
||||||
"workflow_hash": entry.workflow_hash,
|
|
||||||
"source_note": entry.source_note,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def library_to_dict() -> dict[str, Any]:
|
|
||||||
entries = load_prompt_library()
|
|
||||||
return {
|
|
||||||
"count": len(entries),
|
|
||||||
"prompts": [prompt_to_dict(entry) for entry in entries],
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""HTML views for the prompt library (#428)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import html
|
|
||||||
|
|
||||||
from webui.layout import render_page
|
|
||||||
from webui.prompt_library import PromptEntry, load_prompt_library
|
|
||||||
|
|
||||||
|
|
||||||
def _escape(text: str) -> str:
|
|
||||||
return html.escape(text, quote=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_prompt_card(entry: PromptEntry) -> str:
|
|
||||||
hash_short = (
|
|
||||||
f"<code>{_escape(entry.workflow_hash[:12])}</code>"
|
|
||||||
if entry.workflow_hash
|
|
||||||
else "<span class=\"muted\">n/a</span>"
|
|
||||||
)
|
|
||||||
task_mode = (
|
|
||||||
f"<code>{_escape(entry.task_mode)}</code>"
|
|
||||||
if entry.task_mode
|
|
||||||
else "<span class=\"muted\">n/a</span>"
|
|
||||||
)
|
|
||||||
note = (
|
|
||||||
f'<p class="meta">{_escape(entry.source_note)}</p>'
|
|
||||||
if entry.source_note
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
prompt_id = f"prompt-{entry.slug}"
|
|
||||||
return (
|
|
||||||
f'<section class="prompt-card" id="{_escape(entry.slug)}">'
|
|
||||||
f"<h3>{_escape(entry.label)}</h3>"
|
|
||||||
f'<p class="meta">Workflow: <code>{_escape(entry.workflow_path)}</code> · '
|
|
||||||
f"task_mode: {task_mode} · sha256: {hash_short}</p>"
|
|
||||||
f'<pre class="prompt-text" id="{prompt_id}">{_escape(entry.prompt_text)}</pre>'
|
|
||||||
f'<button type="button" class="copy-btn" data-copy-target="{prompt_id}">'
|
|
||||||
"Copy prompt</button>"
|
|
||||||
f"{note}"
|
|
||||||
"</section>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
PROMPT_PAGE_SCRIPT = """
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('.copy-btn').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const targetId = btn.getAttribute('data-copy-target');
|
|
||||||
const node = document.getElementById(targetId);
|
|
||||||
if (!node) return;
|
|
||||||
const text = node.textContent || '';
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
const prior = btn.textContent;
|
|
||||||
btn.textContent = 'Copied';
|
|
||||||
setTimeout(() => { btn.textContent = prior; }, 1200);
|
|
||||||
} catch (_err) {
|
|
||||||
btn.textContent = 'Copy failed';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def render_prompts_page() -> str:
|
|
||||||
entries = load_prompt_library()
|
|
||||||
cards = "".join(_render_prompt_card(entry) for entry in entries)
|
|
||||||
body = (
|
|
||||||
"<h2>Prompt library</h2>"
|
|
||||||
"<p>Short copy/paste task prompts derived from canonical workflows. "
|
|
||||||
"Full policy remains in the cited workflow files — not duplicated here.</p>"
|
|
||||||
f"{cards}"
|
|
||||||
"<p><a href=\"/api/prompts\">JSON API</a></p>"
|
|
||||||
f"{PROMPT_PAGE_SCRIPT}"
|
|
||||||
)
|
|
||||||
return render_page(title="Prompts", body_html=body)
|
|
||||||
|
|
||||||
|
|
||||||
def render_prompt_detail(entry: PromptEntry) -> str:
|
|
||||||
body = (
|
|
||||||
f"<p><a href=\"/prompts\">← All prompts</a></p>"
|
|
||||||
f"{_render_prompt_card(entry)}"
|
|
||||||
f"{PROMPT_PAGE_SCRIPT}"
|
|
||||||
)
|
|
||||||
return render_page(title=entry.label, body_html=body)
|
|
||||||
Reference in New Issue
Block a user