Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a0603fc3 | ||
|
|
ee8e9a0247 | ||
|
|
f5370a94d3 | ||
|
|
c91e3642de | ||
|
|
3599a9e12a | ||
|
|
f845864889 | ||
|
|
6670c72e65 | ||
|
|
1529b9ff6b | ||
|
|
4a95c65f8b | ||
|
|
16dcf65825 | ||
|
|
81fcdb09fd | ||
|
|
30ded9b712 | ||
|
|
a8fcf0e01c |
@@ -0,0 +1,89 @@
|
||||
# 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
|
||||
```
|
||||
@@ -11,7 +11,6 @@ import inspect
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||
from review_proofs import (
|
||||
HANDOFF_HEADING,
|
||||
assess_controller_handoff,
|
||||
@@ -890,20 +889,6 @@ def _rule_reviewer_review_mutation(
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]:
|
||||
result = assess_post_merge_cleanup_proof(report_text)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.post_merge_cleanup_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Cleanup status",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "report CLEANUP_SKIPPED with blocker or full cleanup checklist",
|
||||
)
|
||||
|
||||
|
||||
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"review_pr": [
|
||||
_rule_shared_controller_handoff,
|
||||
@@ -924,7 +909,6 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_target_branch_freshness,
|
||||
_rule_reviewer_mutation_ledger,
|
||||
_rule_reviewer_review_mutation,
|
||||
_rule_reviewer_post_merge_cleanup_proof,
|
||||
],
|
||||
"reconcile_already_landed": [
|
||||
_rule_reconcile_controller_handoff,
|
||||
|
||||
+44
-4
@@ -440,10 +440,48 @@ 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)"
|
||||
)
|
||||
|
||||
if worktree_path:
|
||||
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(worktree_path)))
|
||||
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 dirty_files:
|
||||
details = _preflight_workspace_details(worktree_path, dirty_files)
|
||||
details = _preflight_workspace_details(workspace, dirty_files)
|
||||
raise RuntimeError(
|
||||
"Pre-flight order violation: Active task workspace has tracked "
|
||||
"file edits before mutation (fail closed). "
|
||||
@@ -972,6 +1010,7 @@ 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.
|
||||
|
||||
@@ -984,6 +1023,7 @@ 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).
|
||||
@@ -1010,7 +1050,7 @@ def gitea_create_issue(
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote)
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path)
|
||||
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(
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Post-merge cleanup proof verifier for reviewer final reports (#402)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLEANUP_SKIPPED = "CLEANUP_SKIPPED"
|
||||
CLEANUP_PERFORMED = "CLEANUP_PERFORMED"
|
||||
|
||||
_CLEANUP_SECTION_HINT = re.compile(
|
||||
r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|"
|
||||
r"gitea_delete_branch|remote branch.*deleted|worktree remove)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE)
|
||||
_CLEANUP_BLOCKER_RE = re.compile(
|
||||
r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REMOTE_DELETE_CLAIM_RE = re.compile(
|
||||
r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|"
|
||||
r"deleted remote branch|delete_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_REMOVE_CLAIM_RE = re.compile(
|
||||
r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|"
|
||||
r"worktree cleanup performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*"
|
||||
r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|"
|
||||
r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_TASK_RE = re.compile(
|
||||
r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"merge result\s*:\s*(?:merged|success|performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||
r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_HEAD_BRANCH_RE = re.compile(
|
||||
r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCH_NOT_PROTECTED_RE = re.compile(
|
||||
r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OPEN_PR_INVENTORY_RE = re.compile(
|
||||
r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|"
|
||||
r"open pr references).*(?:none|zero|0|clear|inventory complete)|"
|
||||
r"no other open pr references branch",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ACTIVE_CLAIM_LEASE_RE = re.compile(
|
||||
r"(?:no active (?:heartbeat|claim|lease)|"
|
||||
r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SESSION_OWNED_WORKTREE_RE = re.compile(
|
||||
r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE)
|
||||
_CLEAN_TRACKED_RE = re.compile(
|
||||
r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEAN_UNTRACKED_RE = re.compile(
|
||||
r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_LIST_AFTER_RE = re.compile(
|
||||
r"(?:git worktree list after|post-removal worktree list|worktree list after)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WRONG_BRANCH_RE = re.compile(
|
||||
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _claims_remote_delete(text: str) -> bool:
|
||||
return bool(_REMOTE_DELETE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _claims_worktree_remove(text: str) -> bool:
|
||||
return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _branch_safety_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
missing.append("delete-branch capability resolved (gitea.branch.delete)")
|
||||
if not _DELETE_TASK_RE.search(text):
|
||||
missing.append("delete-branch task named (delete_branch or cleanup)")
|
||||
if not _MERGE_RESULT_RE.search(text):
|
||||
missing.append("merge result: merged")
|
||||
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||
missing.append("merge commit SHA")
|
||||
if not _PR_HEAD_BRANCH_RE.search(text):
|
||||
missing.append("merged PR head branch / deleted branch name")
|
||||
if not _BRANCH_NOT_PROTECTED_RE.search(text):
|
||||
missing.append("branch not protected proof")
|
||||
if not _OPEN_PR_INVENTORY_RE.search(text):
|
||||
missing.append("open PR inventory proof (no other PR references branch)")
|
||||
if not _ACTIVE_CLAIM_LEASE_RE.search(text):
|
||||
missing.append("no active heartbeat/claim/lease proof")
|
||||
return missing
|
||||
|
||||
|
||||
def _worktree_cleanup_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
match = _SESSION_OWNED_WORKTREE_RE.search(text)
|
||||
path = match.group(1).strip() if match else ""
|
||||
if not path:
|
||||
missing.append("session-owned worktree path")
|
||||
elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")):
|
||||
missing.append("worktree path under branches/")
|
||||
if not _CLEAN_TRACKED_RE.search(text):
|
||||
missing.append("pre-removal tracked state: clean")
|
||||
if not _CLEAN_UNTRACKED_RE.search(text):
|
||||
missing.append("pre-removal untracked state: clean")
|
||||
if not _WORKTREE_LIST_AFTER_RE.search(text):
|
||||
missing.append("git worktree list after removal")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_post_merge_cleanup_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
cleanup_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate post-merge cleanup claims carry safety-gate proof (#402)."""
|
||||
text = report_text or ""
|
||||
session = dict(cleanup_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED:
|
||||
blocker = (session.get("blocker") or "").strip()
|
||||
if not blocker:
|
||||
match = _CLEANUP_BLOCKER_RE.search(text)
|
||||
blocker = match.group(1).strip() if match else ""
|
||||
if blocker.upper() == CLEANUP_SKIPPED:
|
||||
blocker = ""
|
||||
if not blocker:
|
||||
reasons.append(
|
||||
"CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"proven": not reasons,
|
||||
"outcome": CLEANUP_SKIPPED,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"):
|
||||
return {
|
||||
"block": False,
|
||||
"proven": True,
|
||||
"outcome": None,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
remote_delete = bool(
|
||||
session.get("remote_delete_claimed") or _claims_remote_delete(text)
|
||||
)
|
||||
worktree_remove = bool(
|
||||
session.get("worktree_remove_claimed") or _claims_worktree_remove(text)
|
||||
)
|
||||
|
||||
if _WRONG_BRANCH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup report claims deleted branch that is not the merged PR head branch"
|
||||
)
|
||||
|
||||
if remote_delete:
|
||||
reasons.extend(
|
||||
f"remote branch deletion missing {field}"
|
||||
for field in _branch_safety_fields_present(text)
|
||||
)
|
||||
|
||||
if worktree_remove:
|
||||
reasons.extend(
|
||||
f"worktree removal missing {field}"
|
||||
for field in _worktree_cleanup_fields_present(text)
|
||||
)
|
||||
|
||||
if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove):
|
||||
pass
|
||||
|
||||
if not remote_delete and not worktree_remove:
|
||||
cleanup_mutations = re.search(
|
||||
r"cleanup mutations\s*:\s*(?!none\b)\S",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if cleanup_mutations:
|
||||
reasons.append(
|
||||
"cleanup mutations reported without post-merge cleanup proof checklist"
|
||||
)
|
||||
|
||||
outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None
|
||||
if remote_delete or worktree_remove:
|
||||
outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN"
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"proven": not block,
|
||||
"outcome": outcome,
|
||||
"remote_delete_claimed": remote_delete,
|
||||
"worktree_remove_claimed": worktree_remove,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker or include the full cleanup "
|
||||
"checklist before claiming remote delete or worktree removal"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/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 "$@"
|
||||
@@ -797,40 +797,6 @@ Do not update the main checkout if merge failed, was blocked, or produced reconc
|
||||
|
||||
If any local artifact is created after final cleanup, run and report a new final status check.
|
||||
|
||||
## 28A. Post-merge cleanup proof checklist (#402)
|
||||
|
||||
Successful tool execution is not proof that cleanup was authorized. Before claiming remote branch deletion or local worktree removal, the final report must carry the full safety checklist below. If any gate is missing, report `CLEANUP_SKIPPED` with the exact blocker — never perform cleanup and never claim it was performed.
|
||||
|
||||
### Remote branch deletion checklist
|
||||
|
||||
When `gitea_delete_branch` (or equivalent) deletes the merged PR head branch, report:
|
||||
|
||||
* Delete-branch capability resolved: name the task (`delete_branch` / `cleanup_branch` / `reconcile_merged_cleanups`) and permission (`gitea.branch.delete`) with resolver proof before the delete call
|
||||
* Merge result: merged
|
||||
* Merge commit SHA: full 40-character SHA
|
||||
* Merged PR head branch / deleted branch: exact branch name (must match)
|
||||
* Branch protection: none / branch is not protected
|
||||
* Open PR inventory proof: no other open PR references the branch
|
||||
* Active heartbeat/claim/lease: none
|
||||
|
||||
### Local worktree removal checklist
|
||||
|
||||
When removing session-owned review/simulation worktrees under `branches/`, report:
|
||||
|
||||
* Session-owned worktree path: exact path under `branches/`
|
||||
* Pre-removal tracked state: clean
|
||||
* Pre-removal untracked state: clean
|
||||
* Git worktree list after removal: command output or equivalent proof
|
||||
|
||||
### Skipped cleanup
|
||||
|
||||
If any gate fails, report:
|
||||
|
||||
* Cleanup outcome: `CLEANUP_SKIPPED`
|
||||
* Cleanup blocker: exact missing gate (for example `gitea.branch.delete capability not resolved`)
|
||||
|
||||
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
|
||||
|
||||
## 29. Recovery handoff rules
|
||||
|
||||
If blocked, produce a recovery handoff with:
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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()
|
||||
@@ -1,169 +0,0 @@
|
||||
"""Tests for post-merge cleanup proof enforcement (#402)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
from post_merge_cleanup_proof import ( # noqa: E402
|
||||
CLEANUP_SKIPPED,
|
||||
assess_post_merge_cleanup_proof,
|
||||
)
|
||||
|
||||
MERGE_SHA = "a" * 40
|
||||
HEAD_BRANCH = "feat/issue-274-branches-only-worktrees"
|
||||
WORKTREE = "branches/review-pr374-conflicts"
|
||||
|
||||
|
||||
def _full_remote_cleanup_report(**overrides):
|
||||
fields = {
|
||||
"Task": "review PR #374",
|
||||
"Merge result": "merged",
|
||||
"Merge commit SHA": MERGE_SHA,
|
||||
"Cleanup status": "remote branch deleted",
|
||||
"Delete-branch capability resolved": "gitea.branch.delete allowed via delete_branch task",
|
||||
"Merged PR head branch": HEAD_BRANCH,
|
||||
"Deleted branch": HEAD_BRANCH,
|
||||
"Branch protection": "none",
|
||||
"Open PR inventory proof": "no other open PR references branch (inventory complete)",
|
||||
"Active heartbeat/claim/lease": "none",
|
||||
"Cleanup mutations": "gitea_delete_branch on remote head branch",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _full_worktree_cleanup_report(**overrides):
|
||||
fields = {
|
||||
"Task": "review PR #374",
|
||||
"Merge result": "merged",
|
||||
"Cleanup status": "local worktree removed",
|
||||
"Removed worktree path": WORKTREE,
|
||||
"Pre-removal tracked state": "clean",
|
||||
"Pre-removal untracked state": "clean",
|
||||
"Git worktree list after removal": "only main checkout listed",
|
||||
"Cleanup mutations": "git worktree remove on session-owned review worktree",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TestPostMergeCleanupProof(unittest.TestCase):
|
||||
def test_no_cleanup_claim_passes(self):
|
||||
report = "## Controller Handoff\n- Task: review PR #1\n- Merge result: merged"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_missing_capability_proof_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Delete-branch capability resolved": "delete succeeded"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("capability" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_unmerged_pr_blocked(self):
|
||||
report = _full_remote_cleanup_report(**{"Merge result": "not merged"})
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("merge result" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_wrong_branch_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Deleted branch": "feat/other-branch"}
|
||||
)
|
||||
report += "\nDeleted branch does not match merged PR head branch"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_protected_branch_blocked(self):
|
||||
report = _full_remote_cleanup_report(**{"Branch protection": "enabled"})
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_open_pr_reference_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Open PR inventory proof": "PR #999 still references branch"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_active_claim_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Active heartbeat/claim/lease": "status:in-progress on linked issue"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_dirty_worktree_blocked(self):
|
||||
report = _full_worktree_cleanup_report(
|
||||
**{"Pre-removal tracked state": "dirty"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("tracked" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_foreign_worktree_blocked(self):
|
||||
report = _full_worktree_cleanup_report(
|
||||
**{"Removed worktree path": "/tmp/foreign-worktree"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("branches/" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_cleanup_skipped_with_blocker_passes(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
||||
"- Cleanup blocker: gitea.branch.delete capability not resolved in active profile",
|
||||
])
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["outcome"], CLEANUP_SKIPPED)
|
||||
|
||||
def test_cleanup_skipped_without_blocker_blocked(self):
|
||||
report = "## Controller Handoff\n- Cleanup outcome: CLEANUP_SKIPPED"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_full_remote_cleanup_passes(self):
|
||||
result = assess_post_merge_cleanup_proof(_full_remote_cleanup_report())
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["remote_delete_claimed"])
|
||||
|
||||
def test_full_worktree_cleanup_passes(self):
|
||||
result = assess_post_merge_cleanup_proof(_full_worktree_cleanup_report())
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["worktree_remove_claimed"])
|
||||
|
||||
def test_validator_integration_blocks_unproven_delete(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Cleanup mutations: gitea_delete_branch deleted remote branch\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
||||
self.assertTrue(result["blocked"])
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.post_merge_cleanup_proof", rule_ids)
|
||||
|
||||
def test_validator_integration_allows_skipped(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
||||
"- Cleanup blocker: branch still referenced by open PR #414",
|
||||
])
|
||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
||||
cleanup_findings = [
|
||||
f for f in result["findings"]
|
||||
if f["rule_id"] == "reviewer.post_merge_cleanup_proof"
|
||||
]
|
||||
self.assertEqual(cleanup_findings, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,245 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Internal MCP Control Plane web UI (read-only MVP skeleton, #426)."""
|
||||
|
||||
from webui.app import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@@ -0,0 +1,19 @@
|
||||
"""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
@@ -0,0 +1,180 @@
|
||||
"""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},
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"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
@@ -0,0 +1,176 @@
|
||||
"""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>"""
|
||||
@@ -0,0 +1,196 @@
|
||||
"""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
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""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],
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,385 @@
|
||||
"""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),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""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>"
|
||||
)
|
||||
Reference in New Issue
Block a user