Compare commits

..
Author SHA1 Message Date
sysadmin 126a983b69 feat: gate reconciliation audit mode from unauthorized cleanup (Closes #419)
Add audit vs cleanup phase tracking so reconciliation audits stay read-only
unless cleanup is explicitly authorized with delete capability proof, safety
proof, and before/after snapshots. Block gitea_delete_branch and merged-cleanup
execution during audit phase, validate final reports for false no-mutations
claims, and document the boundary in the reconcile-landed-pr workflow.
2026-07-07 12:03:08 -04:00
26 changed files with 786 additions and 2311 deletions
+344
View File
@@ -0,0 +1,344 @@
"""Audit vs cleanup phase gates for reconciliation workflows (#419).
Audit/reconciliation tasks are read-only unless a separate cleanup phase is
explicitly authorized with exact capability proof, safety proof, and
before/after snapshots. Cleanup mutations must be classified in final reports;
audit reports must not claim ``no mutations`` when cleanup occurred.
"""
from __future__ import annotations
import re
from typing import Any
RECONCILE_WORKFLOW_PATH = "workflows/reconcile-landed-pr.md"
PHASE_AUDIT = "audit"
PHASE_CLEANUP = "cleanup"
# Tasks that enter audit phase on capability resolution (read-only default).
AUDIT_PHASE_TASKS = frozenset({
"reconcile-landed-pr",
"reconcile_landed_pr",
"reconcile_issue_claims",
"reconcile_merged_cleanups",
})
# Mutation tasks forbidden during audit phase (fail closed).
AUDIT_FORBIDDEN_TASKS = frozenset({
"delete_branch",
"create_branch",
"push_branch",
"create_pr",
"commit_files",
"gitea_commit_files",
"mark_issue",
"lock_issue",
"claim_issue",
"close_pr",
"close_issue",
"create_issue",
"merge_pr",
"review_pr",
"submit_pr_review",
"comment_pr",
"comment_issue",
"set_issue_labels",
})
# Shell/git commands audit phase must not run.
AUDIT_FORBIDDEN_COMMAND_RE = re.compile(
r"(?:^|\s)(?:git\s+(?:push|branch\s+-D|worktree\s+remove)|"
r"gitea_delete_branch|delete_remote_branch)",
re.IGNORECASE,
)
_NO_MUTATIONS_RE = re.compile(
r"(?:no\s+mutations|mutations\s*:\s*none|no\s+unsafe\s+mutation)",
re.IGNORECASE,
)
_CLEANUP_OCCURRED_RE = re.compile(
r"(?:delete_remote_branch|remove_local_worktree|git\s+branch\s+-D|"
r"git\s+worktree\s+remove|remote branch.*deleted|worktree.*removed|"
r"cleanup\s+phase\s*:\s*(?!none\b)\S)",
re.IGNORECASE,
)
_EXTERNAL_STATE_RE = re.compile(
r"^\s*[-*]?\s*external[- ]state mutations\s*:",
re.IGNORECASE | re.MULTILINE,
)
_GIT_REF_RE = re.compile(
r"^\s*[-*]?\s*git ref mutations\s*:",
re.IGNORECASE | re.MULTILINE,
)
_CLEANUP_MUTATIONS_RE = re.compile(
r"^\s*[-*]?\s*cleanup mutations\s*:",
re.IGNORECASE | re.MULTILINE,
)
_CLEANUP_PHASE_AUTH_RE = re.compile(
r"^\s*[-*]?\s*cleanup phase (?:authorized|authorization)\s*:\s*true",
re.IGNORECASE | re.MULTILINE,
)
_DELETE_CAPABILITY_RE = re.compile(
r"^\s*[-*]?\s*delete.?branch capability(?: proven)?\s*:\s*true",
re.IGNORECASE | re.MULTILINE,
)
_BEFORE_AFTER_RE = re.compile(
r"^\s*[-*]?\s*before/after (?:state )?snapshot\s*:",
re.IGNORECASE | re.MULTILINE,
)
_SAFETY_PROOF_RE = re.compile(
r"^\s*[-*]?\s*(?:branch|worktree) safe to remove\s*:\s*true",
re.IGNORECASE | re.MULTILINE,
)
_session: dict[str, Any] | None = None
def _blank_session() -> dict[str, Any]:
return {
"phase": PHASE_AUDIT,
"entered_from_task": None,
"cleanup_authorized": False,
"cleanup_authorization": {},
}
def current_phase() -> str | None:
"""Return active reconciliation phase or None when unset."""
if not _session:
return None
return _session.get("phase")
def active_record() -> dict[str, Any] | None:
"""Return a copy of the session record, if any."""
return dict(_session) if _session else None
def clear_phase() -> None:
"""Clear reconciliation phase state."""
global _session
_session = None
def enter_audit_phase(task: str) -> dict[str, Any]:
"""Enter read-only audit phase for a reconciliation task."""
global _session
normalized = (task or "").strip().lower()
_session = _blank_session()
_session["entered_from_task"] = normalized
return dict(_session)
def authorize_cleanup_phase(
*,
operator_approved: bool = False,
workflow_authorized: bool = False,
delete_capability_proven: bool = False,
safety_proof: dict[str, Any] | None = None,
before_after_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Authorize cleanup phase after explicit approval and safety proofs."""
reasons: list[str] = []
if not (operator_approved or workflow_authorized):
reasons.append(
"cleanup phase requires operator approval or explicit workflow "
"authorization"
)
if not delete_capability_proven:
reasons.append(
"cleanup phase requires exact delete_branch capability proof "
"(gitea.branch.delete)"
)
safety = dict(safety_proof or {})
if not safety.get("safe_to_delete_remote") and not safety.get(
"safe_to_remove_worktree"
):
reasons.append(
"cleanup phase requires proof that branch/worktree is safe to remove"
)
snapshot = dict(before_after_snapshot or {})
if not snapshot.get("before") or not snapshot.get("after"):
reasons.append(
"cleanup phase requires before/after state snapshot"
)
if reasons:
return {
"authorized": False,
"phase": current_phase() or PHASE_AUDIT,
"reasons": reasons,
"safe_next_action": (
"remain in audit-only mode or supply operator approval, "
"delete_branch capability proof, safety proof, and "
"before/after snapshot before cleanup"
),
}
global _session
if _session is None:
_session = _blank_session()
_session["phase"] = PHASE_CLEANUP
_session["cleanup_authorized"] = True
_session["cleanup_authorization"] = {
"operator_approved": operator_approved,
"workflow_authorized": workflow_authorized,
"delete_capability_proven": delete_capability_proven,
"safety_proof": safety,
"before_after_snapshot": snapshot,
}
return {
"authorized": True,
"phase": PHASE_CLEANUP,
"reasons": [],
"cleanup_authorization": dict(_session["cleanup_authorization"]),
"safe_next_action": "proceed with authorized cleanup mutations only",
}
def check_audit_task_enters_phase(task: str) -> bool:
"""Return whether resolving *task* should enter audit phase."""
return (task or "").strip().lower() in AUDIT_PHASE_TASKS
def check_audit_mutation_allowed(task: str) -> tuple[bool, list[str]]:
"""Fail closed when a mutation task runs during audit phase."""
normalized = (task or "").strip().lower()
phase = current_phase()
if phase != PHASE_AUDIT:
return True, []
if normalized in AUDIT_FORBIDDEN_TASKS:
return False, [
f"task '{normalized}' is forbidden in audit-only reconciliation "
"mode: switch to an explicit cleanup phase with operator approval "
"and exact delete_branch capability proof before cleanup mutations"
]
return True, []
def check_cleanup_execution_allowed() -> tuple[bool, list[str]]:
"""Fail closed when cleanup execution is attempted without authorization."""
phase = current_phase()
if phase == PHASE_CLEANUP and (_session or {}).get("cleanup_authorized"):
return True, []
if phase is None:
return False, [
"cleanup execution requires an active reconciliation session; "
"resolve a reconciliation audit task first"
]
return False, [
"cleanup execution forbidden in audit-only reconciliation mode; "
"call gitea_authorize_reconciliation_cleanup_phase with operator "
"approval, delete_branch capability proof, safety proof, and "
"before/after snapshot"
]
def classify_cleanup_mutation(action: str) -> str:
"""Map a cleanup action to the required mutation ledger category (#419)."""
normalized = (action or "").strip().lower()
if "delete_remote" in normalized or normalized in {
"delete_branch",
"gitea_delete_branch",
}:
return "external-state"
if "branch" in normalized and "delete" in normalized:
return "git-ref"
if "worktree" in normalized or "remove_local" in normalized:
return "cleanup"
return "cleanup"
def assess_audit_reconciliation_report(report_text: str) -> dict[str, Any]:
"""Validate audit/cleanup reconciliation reports (fail closed)."""
text = report_text or ""
reasons: list[str] = []
cleanup_occurred = bool(_CLEANUP_OCCURRED_RE.search(text))
claims_no_mutations = bool(_NO_MUTATIONS_RE.search(text))
if cleanup_occurred and claims_no_mutations:
reasons.append(
"report claims no mutations but documents cleanup mutations; "
"audit-only reports must not perform cleanup and cleanup reports "
"must not claim no mutations"
)
if cleanup_occurred:
if not _CLEANUP_PHASE_AUTH_RE.search(text):
reasons.append(
"cleanup mutations reported without "
"'Cleanup phase authorized: true'"
)
if not _DELETE_CAPABILITY_RE.search(text):
reasons.append(
"cleanup mutations reported without delete_branch capability "
"proof"
)
if not _BEFORE_AFTER_RE.search(text):
reasons.append(
"cleanup mutations reported without before/after state snapshot"
)
if not _SAFETY_PROOF_RE.search(text):
reasons.append(
"cleanup mutations reported without branch/worktree safety proof"
)
if re.search(r"delete_remote|remote branch.*delet", text, re.I):
if not _EXTERNAL_STATE_RE.search(text):
reasons.append(
"remote branch deletion must be classified under "
"External-state mutations"
)
if re.search(r"git\s+branch\s+-D|local branch.*delet", text, re.I):
if not _GIT_REF_RE.search(text):
reasons.append(
"local branch deletion must be classified under "
"Git ref mutations"
)
if re.search(r"worktree.*remov|remove_local_worktree", text, re.I):
if not _CLEANUP_MUTATIONS_RE.search(text):
reasons.append(
"worktree removal must be classified under Cleanup mutations"
)
if (
RECONCILE_WORKFLOW_PATH.replace("workflows/", "") in text
or "reconcile-landed-pr" in text.lower()
):
if cleanup_occurred and "audit phase" in text.lower():
if "cleanup phase" not in text.lower():
reasons.append(
"report mixes audit phase with cleanup mutations without "
"documenting cleanup phase transition"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"cleanup_occurred": cleanup_occurred,
"claims_no_mutations": claims_no_mutations,
"safe_next_action": (
"proceed"
if proven
else "fix audit/cleanup report: separate audit from cleanup phase, "
"classify mutations, and do not claim no mutations after cleanup"
),
}
def assess_audit_command_allowed(command: str) -> tuple[bool, list[str]]:
"""Block shell commands that perform cleanup during audit phase."""
phase = current_phase()
if phase != PHASE_AUDIT:
return True, []
cmd = (command or "").strip()
if AUDIT_FORBIDDEN_COMMAND_RE.search(cmd):
return False, [
f"command forbidden in audit-only reconciliation mode: {cmd!r}; "
"authorize cleanup phase before branch/worktree deletion or push"
]
return True, []
-89
View File
@@ -1,89 +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`) |
| `/queue` | Live PR and issue queue dashboard (#429) |
| `/api/queue` | JSON queue export with pagination metadata |
| `/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.
## Live queue dashboard (#429)
`/queue` loads open PRs and issues for the default registry project (seed:
**Gitea-Tools** on `https://gitea.prgs.cc`) using existing `gitea_auth` read
credentials. The UI surfaces pagination proof (returned count, pages fetched,
`has_more`, `inventory_complete`) and classification badges (`claimed`,
`blocked`, `in-review`, `duplicate`) when evidence exists.
If credentials are missing or the fetch fails, the page shows an explicit error
instead of an empty queue (fail closed).
## Tests
```bash
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q
```
+17
View File
@@ -870,6 +870,22 @@ def _rule_reviewer_mutation_ledger(
)
def _rule_audit_reconciliation_boundary(report_text: str) -> list[dict[str, str]]:
from audit_reconciliation_mode import assess_audit_reconciliation_report
result = assess_audit_reconciliation_report(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"reconcile.audit_cleanup_boundary",
result.get("reasons") or [],
field="Audit/cleanup phase",
severity="block",
safe_next_action=result.get("safe_next_action")
or "separate audit from authorized cleanup and classify mutations",
)
def _rule_reviewer_review_mutation(
report_text: str,
*,
@@ -920,6 +936,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
_rule_audit_reconciliation_boundary,
],
"author_issue": [
_rule_shared_controller_handoff,
+79 -44
View File
@@ -440,48 +440,10 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
)
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),
)
real_workspace = os.path.realpath(workspace)
real_root = os.path.realpath(PROJECT_ROOT)
if real_workspace != real_root:
if not _preflight_in_test_mode():
if not os.path.exists(real_workspace):
raise RuntimeError(
f"Branches-only mutation guard (#274): worktree path '{workspace}' does not exist (fail closed)"
)
if not os.path.isdir(real_workspace):
raise RuntimeError(
f"Branches-only mutation guard (#274): worktree path '{workspace}' is not a directory (fail closed)"
)
try:
res = subprocess.run(
["git", "-C", real_workspace, "rev-parse", "--git-common-dir"],
capture_output=True,
text=True,
check=True,
)
common_dir = os.path.realpath(res.stdout.strip())
expected_dir = os.path.realpath(os.path.join(real_root, ".git"))
if common_dir != expected_dir:
raise RuntimeError(
f"Branches-only mutation guard (#274): worktree '{workspace}' does not belong to the target repository '{PROJECT_ROOT}' (fail closed)"
)
except Exception as e:
if isinstance(e, RuntimeError):
raise e
raise RuntimeError(
f"Branches-only mutation guard (#274): worktree '{workspace}' is not a valid git repository (fail closed)"
)
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(workspace)))
if worktree_path:
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(worktree_path)))
if dirty_files:
details = _preflight_workspace_details(workspace, dirty_files)
details = _preflight_workspace_details(worktree_path, dirty_files)
raise RuntimeError(
"Pre-flight order violation: Active task workspace has tracked "
"file edits before mutation (fail closed). "
@@ -544,6 +506,7 @@ import issue_claim_heartbeat # noqa: E402
import merged_cleanup_reconcile # noqa: E402
import reconciler_profile # noqa: E402
import reconciliation_workflow # noqa: E402
import audit_reconciliation_mode # noqa: E402
import review_merge_state_machine # noqa: E402
import native_mcp_preference # noqa: E402
@@ -1010,7 +973,6 @@ def gitea_create_issue(
repo: str | None = None,
allow_duplicate_override: bool = False,
split_from_issue: int | None = None,
worktree_path: str | None = None,
) -> dict:
"""Create a new issue on a Gitea repository.
@@ -1023,7 +985,6 @@ def gitea_create_issue(
repo: Override the repository name.
allow_duplicate_override: Operator-approved split after duplicate found.
split_from_issue: Existing duplicate issue number when overriding.
worktree_path: Optional path to verify branches-only guard.
Returns:
dict with 'number' of the created issue ('url' only with the reveal opt-in).
@@ -1050,7 +1011,7 @@ def gitea_create_issue(
)
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path)
verify_preflight_purity(remote)
base = repo_api_url(h, o, r)
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
closed_issues = api_get_all(
@@ -3518,6 +3479,18 @@ def gitea_delete_branch(
"permission_report": _permission_block_report("gitea.branch.delete"),
}
audit_allowed, audit_reasons = (
audit_reconciliation_mode.check_audit_mutation_allowed("delete_branch")
)
if not audit_allowed:
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": audit_reasons,
"audit_phase": audit_reconciliation_mode.current_phase(),
}
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
@@ -3587,6 +3560,18 @@ def gitea_reconcile_merged_cleanups(
"execute_confirmed must be True when dry_run=False (fail closed)"
)
if not dry_run:
exec_allowed, exec_reasons = (
audit_reconciliation_mode.check_cleanup_execution_allowed()
)
if not exec_allowed:
return {
"success": False,
"performed": False,
"reasons": exec_reasons,
"audit_phase": audit_reconciliation_mode.current_phase(),
}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
@@ -3669,6 +3654,53 @@ def gitea_reconcile_merged_cleanups(
return {"success": True, "performed": True, **report}
@mcp.tool()
def gitea_authorize_reconciliation_cleanup_phase(
operator_approved: bool = False,
workflow_authorized: bool = False,
delete_capability_proven: bool = False,
safe_to_delete_remote: bool = False,
safe_to_remove_worktree: bool = False,
before_state: str = "",
after_state: str = "",
) -> dict:
"""Authorize cleanup phase after audit-only reconciliation (#419).
Requires operator or workflow approval, exact delete_branch capability proof,
branch/worktree safety proof, and before/after state snapshots. Audit phase
forbids branch deletion, worktree removal, pushes, and issue/PR mutations.
"""
delete_gate = _profile_operation_gate("gitea.branch.delete")
capability_ok = not bool(delete_gate)
if delete_capability_proven and delete_gate:
return {
"authorized": False,
"performed": False,
"delete_capability_verified": False,
"reasons": [
"delete_capability_proven=true but active profile lacks "
"gitea.branch.delete",
] + delete_gate,
"audit_phase": audit_reconciliation_mode.current_phase(),
}
result = audit_reconciliation_mode.authorize_cleanup_phase(
operator_approved=operator_approved,
workflow_authorized=workflow_authorized,
delete_capability_proven=delete_capability_proven and capability_ok,
safety_proof={
"safe_to_delete_remote": safe_to_delete_remote,
"safe_to_remove_worktree": safe_to_remove_worktree,
},
before_after_snapshot={
"before": (before_state or "").strip(),
"after": (after_state or "").strip(),
},
)
result["performed"] = bool(result.get("authorized"))
result["delete_capability_verified"] = capability_ok
return result
@mcp.tool()
def gitea_assess_already_landed_reconciliation(
pr_number: int,
@@ -6775,6 +6807,9 @@ def gitea_resolve_task_capability(
if reason_msg:
result["reason"] = reason_msg
role_session_router.sync_route_from_capability(result)
if audit_reconciliation_mode.check_audit_task_enters_phase(task):
phase_record = audit_reconciliation_mode.enter_audit_phase(task)
result["reconciliation_phase"] = phase_record.get("phase")
was_terminal = capability_stop_terminal.is_active()
terminal = capability_stop_terminal.sync_from_capability_result(result)
if terminal:
+9
View File
@@ -5112,6 +5112,15 @@ def assess_pr_queue_cleanup_report(report_text: str | None) -> dict:
return _assess(report_text or "")
def assess_audit_reconciliation_report(report_text: str | None) -> dict:
"""#419: validate audit vs cleanup reconciliation report boundaries."""
from audit_reconciliation_mode import (
assess_audit_reconciliation_report as _assess,
)
return _assess(report_text or "")
_GATE_PASSED_VALUE = re.compile(r"\bpassed\b", re.I)
_NOT_APPLICABLE_VALUE = re.compile(
-6
View File
@@ -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 "$@"
@@ -304,6 +304,40 @@ If any required mutation capability is missing:
* include safe next action (profile switch, human close, or dedicated reconciler
profile)
## 15A. Audit vs cleanup phase (#419)
Reconciliation audits are **read-only** unless a separate cleanup phase is
explicitly authorized.
**Audit phase forbids** (``audit_reconciliation_mode.check_audit_mutation_allowed``
fails closed):
* ``gitea_delete_branch``
* ``git branch -D``
* ``git worktree remove``
* pushes
* issue/PR mutations
* file edits
Dry-run merged-cleanup reconciliation (``gitea_reconcile_merged_cleanups`` with
``dry_run=True``) stays in audit phase. Execution requires:
1. Operator approval or workflow authorization
2. Exact ``delete_branch`` capability proof (``gitea.branch.delete``)
3. Proof branch/worktree is safe to remove
4. Before/after state snapshot
Call ``gitea_authorize_reconciliation_cleanup_phase`` before any cleanup
mutation. Final reports must not claim ``no mutations`` if cleanup occurred.
Classify cleanup mutations as:
* remote branch deletion → **External-state mutations**
* local branch deletion → **Git ref mutations**
* worktree removal → **Cleanup mutations**
``audit_reconciliation_mode.assess_audit_reconciliation_report`` validates
these boundaries in final reports.
## 16. Mutation classification
Use precise mutation categories in the final report:
+4
View File
@@ -104,6 +104,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.read",
"role": "author",
},
"reconciliation_cleanup": {
"permission": "gitea.branch.delete",
"role": "author",
},
"work_issue": {
"permission": "gitea.pr.create",
"role": "author",
+291
View File
@@ -0,0 +1,291 @@
"""Tests for audit vs cleanup reconciliation mode (#419)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import audit_reconciliation_mode as arm
import mcp_server
from audit_reconciliation_mode import (
AUDIT_FORBIDDEN_TASKS,
PHASE_AUDIT,
PHASE_CLEANUP,
assess_audit_command_allowed,
assess_audit_reconciliation_report,
authorize_cleanup_phase,
check_audit_mutation_allowed,
check_cleanup_execution_allowed,
classify_cleanup_mutation,
clear_phase,
enter_audit_phase,
)
from final_report_validator import assess_final_report_validator
from review_proofs import assess_audit_reconciliation_report as proofs_assess
from task_capability_map import required_permission, required_role
DELETE_PROFILE = {
"profile_name": "prgs-author-delete",
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
"forbidden_operations": [],
"audit_label": "prgs-author-delete",
}
READ_PROFILE = {
"profile_name": "prgs-author",
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
"forbidden_operations": ["gitea.branch.delete"],
"audit_label": "prgs-author",
}
READ_ENV = {
"GITEA_MCP_CONFIG": os.path.join(
os.path.dirname(__file__), "..", "profiles.json"
),
"GITEA_MCP_PROFILE": "prgs-author",
}
def _authorize_cleanup(**kwargs):
defaults = {
"operator_approved": True,
"delete_capability_proven": True,
"safety_proof": {"safe_to_delete_remote": True},
"before_after_snapshot": {
"before": "remote branch exists",
"after": "remote branch absent",
},
}
defaults.update(kwargs)
return authorize_cleanup_phase(**defaults)
def _cleanup_report(**overrides):
base = (
"Task mode: reconcile-landed-pr\n"
"Workflow source: workflows/reconcile-landed-pr.md\n"
"Audit phase: read-only assessment\n"
"Cleanup phase authorized: true\n"
"Delete-branch capability proven: true\n"
"Branch safe to remove: true\n"
"Before/after state snapshot: remote branch feat/x present → absent\n"
"External-state mutations: deleted remote branch feat/x\n"
"Git ref mutations: none\n"
"Cleanup mutations: removed worktree branches/feat-x\n"
)
for key, value in overrides.items():
base = base.replace(key, value)
return base
class TestAuditPhaseGates(unittest.TestCase):
def setUp(self):
clear_phase()
enter_audit_phase("reconcile-landed-pr")
def tearDown(self):
clear_phase()
def test_audit_blocks_delete_branch_task(self):
allowed, reasons = check_audit_mutation_allowed("delete_branch")
self.assertFalse(allowed)
self.assertTrue(reasons)
def test_audit_blocks_worktree_shell_commands(self):
allowed, reasons = assess_audit_command_allowed(
"git worktree remove branches/feat-x"
)
self.assertFalse(allowed)
self.assertTrue(reasons)
def test_audit_blocks_local_branch_delete_command(self):
allowed, reasons = assess_audit_command_allowed("git branch -D feat/x")
self.assertFalse(allowed)
def test_audit_allows_read_tasks(self):
allowed, _ = check_audit_mutation_allowed("reconcile_landed_pr")
self.assertTrue(allowed)
def test_forbidden_set_covers_issue_and_pr_mutations(self):
for task in ("close_pr", "comment_issue", "commit_files", "push_branch"):
self.assertIn(task, AUDIT_FORBIDDEN_TASKS)
class TestCleanupAuthorization(unittest.TestCase):
def setUp(self):
clear_phase()
enter_audit_phase("reconcile_merged_cleanups")
def tearDown(self):
clear_phase()
def test_cleanup_without_approval_blocked(self):
result = authorize_cleanup_phase(
delete_capability_proven=True,
safety_proof={"safe_to_delete_remote": True},
before_after_snapshot={"before": "a", "after": "b"},
)
self.assertFalse(result["authorized"])
def test_cleanup_without_capability_proof_blocked(self):
result = _authorize_cleanup(delete_capability_proven=False)
self.assertFalse(result["authorized"])
def test_cleanup_without_snapshot_blocked(self):
result = _authorize_cleanup(before_after_snapshot={"before": "", "after": ""})
self.assertFalse(result["authorized"])
def test_authorized_cleanup_switches_phase(self):
result = _authorize_cleanup()
self.assertTrue(result["authorized"])
self.assertEqual(result["phase"], PHASE_CLEANUP)
def test_cleanup_execution_allowed_only_after_authorization(self):
self.assertFalse(check_cleanup_execution_allowed()[0])
_authorize_cleanup()
self.assertTrue(check_cleanup_execution_allowed()[0])
class TestReportVerifier(unittest.TestCase):
def test_false_no_mutations_after_cleanup_blocked(self):
report = (
"Task mode: reconcile-landed-pr\n"
"No mutations performed.\n"
"delete_remote_branch feat/dup\n"
)
result = assess_audit_reconciliation_report(report)
self.assertFalse(result["proven"])
self.assertIn("no mutations", result["reasons"][0].lower())
def test_cleanup_without_authorization_fields_blocked(self):
report = (
"Task mode: reconcile-landed-pr\n"
"remove_local_worktree branches/feat-x\n"
)
result = assess_audit_reconciliation_report(report)
self.assertFalse(result["proven"])
def test_authorized_cleanup_report_passes(self):
result = assess_audit_reconciliation_report(_cleanup_report())
self.assertTrue(result["proven"])
def test_mutation_classification_enforced(self):
report = (
"Task mode: reconcile-landed-pr\n"
"Cleanup phase authorized: true\n"
"Delete-branch capability proven: true\n"
"Branch safe to remove: true\n"
"Before/after state snapshot: present\n"
"remove_local_worktree branches/feat-x\n"
)
result = assess_audit_reconciliation_report(report)
self.assertFalse(result["proven"])
def test_proofs_export_matches_module(self):
report = "No mutations performed.\ndelete_remote_branch feat/dup"
self.assertEqual(
proofs_assess(report)["proven"],
assess_audit_reconciliation_report(report)["proven"],
)
def test_final_report_validator_includes_boundary_rule(self):
report = "No mutations performed.\ndelete_remote_branch feat/dup"
result = assess_final_report_validator(
report_text=report,
task_kind="reconcile_already_landed",
)
self.assertTrue(result["blocked"])
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reconcile.audit_cleanup_boundary", rule_ids)
class TestMutationClassification(unittest.TestCase):
def test_remote_delete_is_external_state(self):
self.assertEqual(
classify_cleanup_mutation("delete_remote_branch"),
"external-state",
)
def test_worktree_remove_is_cleanup(self):
self.assertEqual(
classify_cleanup_mutation("remove_local_worktree"),
"cleanup",
)
class TestMcpGates(unittest.TestCase):
def setUp(self):
clear_phase()
enter_audit_phase("reconcile_merged_cleanups")
self.mock_api = patch("mcp_server.api_request").start()
self.mock_auth = patch(
"mcp_server.get_auth_header", return_value="token test"
).start()
def tearDown(self):
patch.stopall()
clear_phase()
mcp_server._IDENTITY_CACHE.clear()
@patch.dict(os.environ, READ_ENV, clear=True)
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
def test_delete_branch_blocked_in_audit_phase(self, _profile):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
self.assertFalse(result["success"])
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
self.mock_api.assert_not_called()
@patch.dict(os.environ, READ_ENV, clear=True)
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
def test_reconcile_execute_blocked_without_cleanup_auth(self, _profile):
with self.assertRaises(ValueError):
mcp_server.gitea_reconcile_merged_cleanups(
dry_run=False,
execute_confirmed=False,
remote="prgs",
)
@patch.dict(os.environ, READ_ENV, clear=True)
@patch("mcp_server.get_profile", return_value=READ_PROFILE)
def test_cleanup_auth_fails_without_delete_capability(self, _profile):
result = mcp_server.gitea_authorize_reconciliation_cleanup_phase(
operator_approved=True,
delete_capability_proven=True,
safe_to_delete_remote=True,
before_state="exists",
after_state="gone",
)
self.assertFalse(result["authorized"])
self.assertFalse(result["delete_capability_verified"])
@patch.dict(os.environ, READ_ENV, clear=True)
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
def test_delete_branch_allowed_after_cleanup_authorization(self, _profile):
mcp_server.gitea_authorize_reconciliation_cleanup_phase(
operator_approved=True,
delete_capability_proven=True,
safe_to_delete_remote=True,
before_state="exists",
after_state="gone",
)
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
self.mock_api.return_value = {}
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
self.assertTrue(result["success"])
class TestTaskCapabilityMap(unittest.TestCase):
def test_reconciliation_cleanup_maps_delete_permission(self):
self.assertEqual(
required_permission("reconciliation_cleanup"),
"gitea.branch.delete",
)
self.assertEqual(required_role("reconciliation_cleanup"), "author")
if __name__ == "__main__":
unittest.main()
-145
View File
@@ -1,145 +0,0 @@
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
# Ensure we import from the repo root
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_mcp_server as srv
FAKE_AUTH = {"Authorization": "token test-token"}
# Stable control checkout (parent of branches/), not the MCP server worktree root.
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
PROJECT_ROOT = srv.PROJECT_ROOT
class TestCreateIssueWorkspaceGuard(unittest.TestCase):
def setUp(self):
# Reset preflight flags
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "author"
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
# Disable early return in verify_preflight_purity for testing
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
def test_create_issue_stable_checkout_rejected(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
# Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
# path is the stable control checkout (not under branches/), mutation must fail.
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(title="Test issue", body="body text")
self.assertIn("stable control checkout", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
@patch("os.path.exists", return_value=True)
@patch("os.path.isdir", return_value=True)
@patch("subprocess.run")
def test_create_issue_valid_worktree_succeeds(self, mock_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
# Mock subprocess.run for git --git-common-dir to return PROJECT_ROOT/.git
mock_res = MagicMock()
mock_res.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
mock_run.return_value = mock_res
mock_api.return_value = {"number": 42, "html_url": "https://gitea.example.com/issues/42"}
# Provide a valid branches path under the control checkout root
valid_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
res = srv.gitea_create_issue(
title="Test issue", body="body", worktree_path=valid_path
)
self.assertEqual(res["number"], 42)
mock_api.assert_called_once()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
def test_create_issue_missing_worktree_fails_closed(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
# Path under branches/ but doesn't exist
missing_path = os.path.join(
CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-worktree-path-999"
)
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(
title="Test issue", body="body", worktree_path=missing_path
)
self.assertIn("does not exist (fail closed)", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
@patch("os.path.exists", return_value=True)
@patch("os.path.isdir", return_value=True)
@patch("subprocess.run")
def test_create_issue_wrong_repo_fails_closed(self, mock_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
# Mock subprocess.run for git --git-common-dir to return a different path
mock_res = MagicMock()
mock_res.stdout = "/Users/jasonwalker/Development/some-other-repo/.git\n"
mock_run.return_value = mock_res
wrong_repo_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(
title="Test issue", body="body", worktree_path=wrong_repo_path
)
self.assertIn("does not belong to the target repository", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
def test_create_issue_fails_without_whoami_preflight(self, _ns, _prof, _auth):
srv._preflight_whoami_called = False
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(title="Test issue", body="body")
self.assertIn("Identity (gitea_whoami) has not been verified", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
def test_create_issue_fails_without_capability_preflight(self, _ns, _prof, _auth):
srv._preflight_capability_called = False
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(title="Test issue", body="body")
self.assertIn("Task capability (gitea_resolve_task_capability) has not been resolved", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -87,6 +87,14 @@ def test_reconcile_landed_workflow_contract():
assert "PARTIAL_RECONCILE_COMMENT_THEN_STOP" in text
assert "RECOVERY_HANDOFF_ONLY" in text
assert "resolve_partial_reconciliation_plan" in text
assert "check_audit_mutation_allowed" in text
assert "gitea_authorize_reconciliation_cleanup_phase" in text
def test_audit_reconciliation_verifier_exported():
from review_proofs import assess_audit_reconciliation_report
assert callable(assess_audit_reconciliation_report)
def test_create_issue_workflow_contract():
-111
View File
@@ -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()
-90
View File
@@ -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()
-245
View File
@@ -1,245 +0,0 @@
"""Tests for web UI live queue dashboard (#429)."""
import sys
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
from webui.queue_loader import (
PaginationMeta,
_classify_issue,
_classify_pr,
_extract_linked_issue,
load_queue_snapshot,
snapshot_to_dict,
)
_RECENT = datetime.now(timezone.utc).isoformat()
_STALE = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
_SAMPLE_PRS = [
{
"number": 100,
"title": "feat: queue dashboard (Closes #429)",
"body": "",
"mergeable": True,
"updated_at": _RECENT,
"head": {"ref": "feat/issue-429", "sha": "abc123def456"},
"base": {"ref": "master"},
},
{
"number": 99,
"title": "fix: conflict",
"body": "Closes #50",
"mergeable": False,
"updated_at": _STALE,
"head": {"ref": "fix/issue-50", "sha": "deadbeef0001"},
"base": {"ref": "master"},
},
{
"number": 98,
"title": "feat: duplicate A (Closes #60)",
"body": "",
"mergeable": True,
"updated_at": _RECENT,
"head": {"ref": "feat/issue-60-a", "sha": "111111111111"},
"base": {"ref": "master"},
},
{
"number": 97,
"title": "feat: duplicate B (Closes #60)",
"body": "",
"mergeable": True,
"updated_at": _RECENT,
"head": {"ref": "feat/issue-60-b", "sha": "222222222222"},
"base": {"ref": "master"},
},
]
_SAMPLE_ISSUES = [
{
"number": 429,
"title": "Web UI queue dashboard",
"state": "open",
"labels": [{"name": "status:in-progress"}],
"assignee": None,
"updated_at": _RECENT,
},
{
"number": 60,
"title": "Duplicate PR target",
"state": "open",
"labels": [],
"assignee": None,
"updated_at": _RECENT,
},
{
"number": 50,
"title": "Blocked PR target",
"state": "open",
"labels": [],
"assignee": None,
"updated_at": _STALE,
},
]
def _mock_pagination(count: int) -> PaginationMeta:
return PaginationMeta(
page=1,
per_page=50,
returned_count=count,
has_more=False,
is_final_page=True,
inventory_complete=True,
pages_fetched=1,
)
def _mock_fetch_prs(*_args, **_kwargs):
return list(_SAMPLE_PRS), _mock_pagination(len(_SAMPLE_PRS))
def _mock_fetch_issues(*_args, **_kwargs):
return list(_SAMPLE_ISSUES), _mock_pagination(len(_SAMPLE_ISSUES))
class TestQueueClassification(unittest.TestCase):
def test_extract_linked_issue_from_title(self):
self.assertEqual(
_extract_linked_issue("feat: X (Closes #429)", ""),
429,
)
def test_classify_pr_blocked_and_stale(self):
pr = _SAMPLE_PRS[1]
badges = _classify_pr(
pr,
issue_claimed=set(),
issue_to_prs={50: [99]},
)
self.assertIn("blocked", badges)
self.assertIn("stale", badges)
def test_classify_pr_duplicate(self):
badges = _classify_pr(
_SAMPLE_PRS[2],
issue_claimed=set(),
issue_to_prs={60: [98, 97]},
)
self.assertIn("duplicate", badges)
self.assertIn("in-review", badges)
def test_classify_issue_claimed_and_duplicate(self):
claimed = _classify_issue(_SAMPLE_ISSUES[0], linked_prs=[])
self.assertIn("claimed", claimed)
duplicate = _classify_issue(_SAMPLE_ISSUES[1], linked_prs=[98, 97])
self.assertIn("duplicate", duplicate)
class TestQueueLoader(unittest.TestCase):
def test_snapshot_with_mock_fetch(self):
snapshot = load_queue_snapshot(
fetch_prs=_mock_fetch_prs,
fetch_issues=_mock_fetch_issues,
)
self.assertEqual(snapshot.project_id, "gitea-tools")
self.assertIsNone(snapshot.fetch_error)
self.assertEqual(len(snapshot.prs), 4)
self.assertEqual(len(snapshot.issues), 3)
self.assertTrue(snapshot.pr_pagination.inventory_complete)
self.assertTrue(snapshot.issue_pagination.inventory_complete)
pr100 = next(p for p in snapshot.prs if p.number == 100)
self.assertEqual(pr100.extra["linked_issue"], "429")
self.assertIn("claimed", pr100.badges)
def test_snapshot_dict_export(self):
snapshot = load_queue_snapshot(
fetch_prs=_mock_fetch_prs,
fetch_issues=_mock_fetch_issues,
)
data = snapshot_to_dict(snapshot)
self.assertEqual(data["project_id"], "gitea-tools")
self.assertIsNone(data["fetch_error"])
self.assertEqual(len(data["prs"]), 4)
self.assertTrue(data["pagination"]["prs"]["inventory_complete"])
class TestQueueRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
self._patch = mock.patch(
"webui.app.load_queue_snapshot",
return_value=load_queue_snapshot(
fetch_prs=_mock_fetch_prs,
fetch_issues=_mock_fetch_issues,
),
)
self._patch.start()
def tearDown(self):
self._patch.stop()
def test_queue_page_renders_tables_and_pagination(self):
response = self.client.get("/queue")
self.assertEqual(response.status_code, 200)
self.assertIn("Live queue", response.text)
self.assertIn("Open pull requests", response.text)
self.assertIn("Open issues", response.text)
self.assertIn("pages_fetched", response.text)
self.assertIn("Gitea-Tools", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_api_queue_json(self):
response = self.client.get("/api/queue")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["repo"], "Scaled-Tech-Consulting/Gitea-Tools")
self.assertEqual(data["pagination"]["issues"]["returned_count"], 3)
def test_queue_fail_closed_without_credentials(self):
with mock.patch("webui.queue_loader.get_auth_header", return_value=None):
snapshot = load_queue_snapshot()
self.assertIsNotNone(snapshot.fetch_error)
self.assertEqual(len(snapshot.prs), 0)
def test_queue_fail_closed_hides_empty_state_copy(self):
with mock.patch("webui.queue_loader.get_auth_header", return_value=None):
snapshot = load_queue_snapshot()
with mock.patch("webui.app.load_queue_snapshot", return_value=snapshot):
response = self.client.get("/queue")
self.assertEqual(response.status_code, 200)
self.assertIn("Queue unavailable", response.text)
self.assertIn("not fetched", response.text.lower())
self.assertNotIn("No open items.", response.text)
self.assertNotIn("Open pull requests", response.text)
self.assertNotIn("pages_fetched", response.text)
def test_queue_successful_empty_inventory_shows_empty_state(self):
def _empty_prs(*_args, **_kwargs):
return [], _mock_pagination(0)
def _empty_issues(*_args, **_kwargs):
return [], _mock_pagination(0)
snapshot = load_queue_snapshot(
fetch_prs=_empty_prs,
fetch_issues=_empty_issues,
)
with mock.patch("webui.app.load_queue_snapshot", return_value=snapshot):
response = self.client.get("/queue")
self.assertEqual(response.status_code, 200)
self.assertIsNone(snapshot.fetch_error)
self.assertEqual(response.text.count("No open items."), 2)
self.assertIn("pages_fetched", response.text)
self.assertIn("pagination (complete)", response.text)
if __name__ == "__main__":
unittest.main()
-73
View File
@@ -1,73 +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_queue_route_renders(self):
response = self.client.get("/queue")
self.assertEqual(response.status_code, 200)
self.assertIn("Live queue", response.text)
def test_nav_links_on_all_pages(self):
for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit"):
with self.subTest(path=path):
text = self.client.get(path).text
for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit"):
self.assertIn(f'href="{href}"', text)
if __name__ == "__main__":
unittest.main()
-5
View File
@@ -1,5 +0,0 @@
"""Internal MCP Control Plane web UI (read-only MVP skeleton, #426)."""
from webui.app import create_app
__all__ = ["create_app"]
-19
View File
@@ -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()
-180
View File
@@ -1,180 +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
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict
from webui.queue_views import render_queue_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>Queue</strong> — live PR and issue dashboard (#429)</li>"
"<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 queue(_request: Request) -> HTMLResponse:
snapshot = load_queue_snapshot()
return HTMLResponse(render_page(title="Queue", body_html=render_queue_page(snapshot)))
async def api_queue(_request: Request) -> JSONResponse:
return JSONResponse(snapshot_to_dict(load_queue_snapshot()))
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("/queue", queue, methods=["GET"]),
Route("/api/queue", api_queue, 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},
)
-49
View File
@@ -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."
}
]
}
]
}
-176
View File
@@ -1,176 +0,0 @@
"""Shared HTML layout for the internal web UI."""
from __future__ import annotations
NAV_ITEMS = (
("/", "Home"),
("/queue", "Queue"),
("/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); }}
.badges {{ display: inline-flex; flex-wrap: wrap; gap: 0.35rem; margin-left: 0.5rem; }}
.badge {{
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
text-transform: lowercase;
}}
.badge-claimed {{ color: #8fd19e; border-color: #3d6b4a; }}
.badge-blocked {{ color: #f0a8a8; border-color: #7a3b3b; }}
.badge-in-review {{ color: #9ec8f0; border-color: #3d5f7a; }}
.badge-duplicate {{ color: #e0c27a; border-color: #6b5730; }}
.badge-stale {{ color: #c9b8e8; border-color: #5a4a78; }}
</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>"""
-196
View File
@@ -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
-93
View File
@@ -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)
-197
View File
@@ -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],
}
-87
View File
@@ -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)
-385
View File
@@ -1,385 +0,0 @@
"""Load live Gitea PR/issue queue state for the web UI dashboard (#429)."""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable
from urllib.parse import urlparse
from gitea_auth import api_fetch_page, get_auth_header, repo_api_url
from webui.project_registry import ProjectRecord, load_registry
_CLOSES_RE = re.compile(r"(?:closes|fixes|resolves)\s+#(\d+)", re.I)
_ISSUE_REF_RE = re.compile(r"#(\d+)")
_STALE_DAYS = 14
@dataclass(frozen=True)
class PaginationMeta:
page: int
per_page: int
returned_count: int
has_more: bool
is_final_page: bool
inventory_complete: bool
pages_fetched: int
@dataclass(frozen=True)
class QueueItem:
number: int
title: str
badges: tuple[str, ...]
extra: dict[str, str]
@dataclass(frozen=True)
class QueueSnapshot:
project_id: str
repo_label: str
prs: tuple[QueueItem, ...]
issues: tuple[QueueItem, ...]
pr_pagination: PaginationMeta | None
issue_pagination: PaginationMeta | None
fetch_error: str | None = None
def _host_from_url(remote_host: str) -> str:
parsed = urlparse(remote_host.strip())
return parsed.netloc or remote_host.strip().rstrip("/")
def _extract_linked_issue(title: str | None, body: str | None = None) -> int | None:
for text in (title, body):
if not text:
continue
match = _CLOSES_RE.search(text)
if match:
return int(match.group(1))
if body:
refs = _ISSUE_REF_RE.findall(body)
if refs:
return int(refs[0])
return None
def _is_stale(updated_at: str | None) -> bool:
if not updated_at:
return False
try:
normalized = updated_at.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)
return age.days >= _STALE_DAYS
except ValueError:
return False
def _classify_pr(
pr: dict,
*,
issue_claimed: set[int],
issue_to_prs: dict[int, list[int]],
) -> tuple[str, ...]:
badges: list[str] = []
mergeable = pr.get("mergeable")
if mergeable is False:
badges.append("blocked")
linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
if linked is not None:
if linked in issue_claimed:
badges.append("claimed")
if len(issue_to_prs.get(linked, [])) > 1:
badges.append("duplicate")
if _is_stale(pr.get("updated_at")):
badges.append("stale")
if mergeable is True and "blocked" not in badges:
badges.append("in-review")
if not badges:
badges.append("open")
return tuple(dict.fromkeys(badges))
def _classify_issue(
issue: dict,
*,
linked_prs: list[int],
) -> tuple[str, ...]:
badges: list[str] = []
labels = [lb.get("name", "") for lb in issue.get("labels", [])]
if "status:in-progress" in labels:
badges.append("claimed")
if len(linked_prs) > 1:
badges.append("duplicate")
elif linked_prs and "claimed" not in badges:
badges.append("in-review")
if _is_stale(issue.get("updated_at")):
badges.append("stale")
if not badges:
badges.append("open")
return tuple(dict.fromkeys(badges))
def _format_pr_item(pr: dict, badges: tuple[str, ...]) -> QueueItem:
head = pr.get("head") or {}
base = pr.get("base") or {}
mergeable = pr.get("mergeable")
merge_label = (
"mergeable" if mergeable is True else "conflicted" if mergeable is False else "unknown"
)
linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
return QueueItem(
number=int(pr["number"]),
title=str(pr.get("title") or ""),
badges=badges,
extra={
"branch": f"{head.get('ref', '?')}{base.get('ref', '?')}",
"head_sha": str(head.get("sha") or "")[:12],
"mergeable": merge_label,
"linked_issue": str(linked) if linked is not None else "",
},
)
def _format_issue_item(issue: dict, badges: tuple[str, ...]) -> QueueItem:
labels = ", ".join(lb.get("name", "") for lb in issue.get("labels", []))
assignee = (issue.get("assignee") or {}).get("login", "")
return QueueItem(
number=int(issue["number"]),
title=str(issue.get("title") or ""),
badges=badges,
extra={
"labels": labels or "",
"assignee": assignee or "unassigned",
"state": str(issue.get("state") or ""),
},
)
def _pagination_from_pages(
*,
per_page: int,
pages_fetched: int,
returned_count: int,
is_final_page: bool,
) -> PaginationMeta:
return PaginationMeta(
page=1,
per_page=per_page,
returned_count=returned_count,
has_more=not is_final_page,
is_final_page=is_final_page,
inventory_complete=is_final_page,
pages_fetched=pages_fetched,
)
def _fetch_prs(
host: str,
org: str,
repo: str,
auth: str,
*,
per_page: int = 50,
) -> tuple[list[dict], PaginationMeta]:
url = f"{repo_api_url(host, org, repo)}/pulls?state=open"
all_raw: list[dict] = []
pages_fetched = 0
is_final = False
page = 1
while pages_fetched < 20:
raw_page, meta = api_fetch_page(url, auth, page=page, limit=per_page)
pages_fetched += 1
all_raw.extend(raw_page)
is_final = bool(meta["is_final_page"])
if is_final:
break
page += 1
pagination = _pagination_from_pages(
per_page=per_page,
pages_fetched=pages_fetched,
returned_count=len(all_raw),
is_final_page=is_final,
)
return all_raw, pagination
def _fetch_issues(
host: str,
org: str,
repo: str,
auth: str,
*,
per_page: int = 50,
) -> tuple[list[dict], PaginationMeta]:
url = f"{repo_api_url(host, org, repo)}/issues?state=open&type=issues"
all_raw: list[dict] = []
page = 1
pages_fetched = 0
is_final = False
while pages_fetched < 20:
raw_page, meta = api_fetch_page(url, auth, page=page, limit=per_page)
pages_fetched += 1
all_raw.extend(raw_page)
is_final = bool(meta["is_final_page"])
if is_final:
break
page += 1
pagination = _pagination_from_pages(
per_page=per_page,
pages_fetched=pages_fetched,
returned_count=len(all_raw),
is_final_page=is_final,
)
return all_raw, pagination
def load_queue_snapshot(
project_id: str | None = None,
*,
fetch_prs: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
fetch_issues: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
) -> QueueSnapshot:
"""Load open PR and issue queues for a registry project (default: first entry)."""
registry = load_registry()
project: ProjectRecord | None = None
if project_id:
for entry in registry.projects:
if entry.id == project_id:
project = entry
break
else:
project = registry.projects[0] if registry.projects else None
if project is None:
return QueueSnapshot(
project_id=project_id or "",
repo_label="",
prs=(),
issues=(),
pr_pagination=None,
issue_pagination=None,
fetch_error="project not found in registry",
)
host = _host_from_url(project.remote_host)
pr_fetch = fetch_prs or _fetch_prs
issue_fetch = fetch_issues or _fetch_issues
using_live_fetch = fetch_prs is None or fetch_issues is None
auth = get_auth_header(host) if using_live_fetch else "test-auth"
if using_live_fetch and not auth:
return QueueSnapshot(
project_id=project.id,
repo_label=f"{project.gitea_owner}/{project.repo_name}",
prs=(),
issues=(),
pr_pagination=None,
issue_pagination=None,
fetch_error=(
f"Gitea credentials unavailable for {host}; "
"queue cannot be loaded (fail closed — not showing empty queue)"
),
)
try:
raw_prs, pr_pagination = pr_fetch(
host, project.gitea_owner, project.repo_name, auth
)
raw_issues, issue_pagination = issue_fetch(
host, project.gitea_owner, project.repo_name, auth
)
except Exception as exc: # noqa: BLE001 — surface operator-visible fetch errors
return QueueSnapshot(
project_id=project.id,
repo_label=f"{project.gitea_owner}/{project.repo_name}",
prs=(),
issues=(),
pr_pagination=None,
issue_pagination=None,
fetch_error=f"Gitea fetch failed: {exc}",
)
issue_to_prs: dict[int, list[int]] = {}
for pr in raw_prs:
linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
if linked is not None:
issue_to_prs.setdefault(linked, []).append(int(pr["number"]))
issue_claimed = {
int(i["number"])
for i in raw_issues
if any(lb.get("name") == "status:in-progress" for lb in i.get("labels", []))
}
pr_items = tuple(
_format_pr_item(
pr,
_classify_pr(
pr,
issue_claimed=issue_claimed,
issue_to_prs=issue_to_prs,
),
)
for pr in sorted(raw_prs, key=lambda p: int(p["number"]), reverse=True)
)
issue_items = tuple(
_format_issue_item(
issue,
_classify_issue(
issue,
linked_prs=issue_to_prs.get(int(issue["number"]), []),
),
)
for issue in sorted(raw_issues, key=lambda i: int(i["number"]), reverse=True)
)
return QueueSnapshot(
project_id=project.id,
repo_label=f"{project.gitea_owner}/{project.repo_name}",
prs=pr_items,
issues=issue_items,
pr_pagination=pr_pagination,
issue_pagination=issue_pagination,
)
def snapshot_to_dict(snapshot: QueueSnapshot) -> dict[str, Any]:
"""JSON-serializable export for /api/queue."""
def _page(meta: PaginationMeta | None) -> dict[str, Any] | None:
if meta is None:
return None
return {
"page": meta.page,
"per_page": meta.per_page,
"returned_count": meta.returned_count,
"has_more": meta.has_more,
"is_final_page": meta.is_final_page,
"inventory_complete": meta.inventory_complete,
"pages_fetched": meta.pages_fetched,
}
def _item(item: QueueItem) -> dict[str, Any]:
return {
"number": item.number,
"title": item.title,
"badges": list(item.badges),
**item.extra,
}
return {
"project_id": snapshot.project_id,
"repo": snapshot.repo_label,
"fetch_error": snapshot.fetch_error,
"prs": [_item(p) for p in snapshot.prs],
"issues": [_item(i) for i in snapshot.issues],
"pagination": {
"prs": _page(snapshot.pr_pagination),
"issues": _page(snapshot.issue_pagination),
},
}
-121
View File
@@ -1,121 +0,0 @@
"""HTML views for the live Gitea queue dashboard (#429)."""
from __future__ import annotations
import html
from webui.queue_loader import PaginationMeta, QueueItem, QueueSnapshot
def _badge_html(badges: tuple[str, ...]) -> str:
if not badges:
return ""
chips = "".join(
f'<span class="badge badge-{html.escape(b)}">{html.escape(b)}</span>'
for b in badges
)
return f'<span class="badges">{chips}</span>'
def _pagination_html(label: str, meta: PaginationMeta | None) -> str:
if meta is None:
return (
f"<p class='muted'><strong>{html.escape(label)} pagination:</strong> "
"unavailable</p>"
)
status = "complete" if meta.inventory_complete else "partial"
more = "yes" if meta.has_more else "no"
return (
f"<p class='meta'><strong>{html.escape(label)} pagination ({status}):</strong> "
f"returned {meta.returned_count} · per_page {meta.per_page} · "
f"pages_fetched {meta.pages_fetched} · has_more {more} · "
f"final_page {'yes' if meta.is_final_page else 'no'}</p>"
)
def _queue_table(
*,
title: str,
items: tuple[QueueItem, ...],
columns: tuple[tuple[str, str], ...],
) -> str:
if not items:
return f"<h3>{html.escape(title)}</h3><p class='muted'>No open items.</p>"
headers = "".join(f"<th>{html.escape(label)}</th>" for _, label in columns)
rows = []
for item in items:
cells = []
for key, _ in columns:
if key == "number":
cells.append(f"<td>#{item.number}</td>")
elif key == "title":
cells.append(
f"<td>{html.escape(item.title)}{_badge_html(item.badges)}</td>"
)
else:
cells.append(f"<td>{html.escape(item.extra.get(key, ''))}</td>")
rows.append("<tr>" + "".join(cells) + "</tr>")
body = (
f"<h3>{html.escape(title)}</h3>"
f"<table class='registry'><thead><tr>{headers}</tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table>"
)
return body
def render_queue_page(snapshot: QueueSnapshot) -> str:
error_block = ""
if snapshot.fetch_error:
error_block = (
f'<div class="stub"><p><strong>Queue unavailable:</strong> '
f"{html.escape(snapshot.fetch_error)}</p></div>"
)
inventory_block = (
"<p class='muted'><strong>Inventory:</strong> not fetched "
"(fail closed — queue tables omitted).</p>"
)
return (
"<h2>Live queue</h2>"
f"<p class='meta'>Repository: <code>{html.escape(snapshot.repo_label)}</code> "
f"· project <code>{html.escape(snapshot.project_id)}</code></p>"
f"{error_block}"
f"{inventory_block}"
"<p class='muted'>Read-only MVP — claims, reviews, and merges stay in Gitea/MCP tools.</p>"
)
pr_section = _queue_table(
title="Open pull requests",
items=snapshot.prs,
columns=(
("number", "#"),
("title", "Title"),
("branch", "Branch"),
("head_sha", "Head"),
("mergeable", "Mergeable"),
("linked_issue", "Linked issue"),
),
)
issue_section = _queue_table(
title="Open issues",
items=snapshot.issues,
columns=(
("number", "#"),
("title", "Title"),
("labels", "Labels"),
("assignee", "Assignee"),
("state", "State"),
),
)
return (
"<h2>Live queue</h2>"
f"<p class='meta'>Repository: <code>{html.escape(snapshot.repo_label)}</code> "
f"· project <code>{html.escape(snapshot.project_id)}</code></p>"
f"{_pagination_html('PR', snapshot.pr_pagination)}"
f"{pr_section}"
f"{_pagination_html('Issue', snapshot.issue_pagination)}"
f"{issue_section}"
"<p class='muted'>Read-only MVP — claims, reviews, and merges stay in Gitea/MCP tools.</p>"
)