CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Failing after 57s
CI & Build / Build & push image (push) Skipped
Step 1 found the reason the standard names never held, and it is sharper than "prose doesn't fire": the list WAS real and it WAS seeded — but only on the inception path, for a project with zero Systems. Ad-hoc create_system never consulted it, which is how Forge minted "CI and Release" and Portal minted "CI & release" after the constant already existed. This wires the vocabulary to the moment that mints a name. - services/systems.assess_system_name: the local duplicate gate AND the catalog lookup, in ONE service function both doors call. The gate lived only in the MCP tool, which is exactly how the web UI shipped without a check the agent surface enforced (#2482). REST now answers 409 with the System that already covers the area. - An `exact` catalog hit is APPLIED (mechanical — the names differ only in spelling). An `overlap` is only OFFERED, on both doors: applying a judgment call silently is how a cross-project rule surfaces in the wrong project. - canonical_systems.best_overlap is the ONE scorer behind the create-time offer and the review sweep, so the two surfaces can never name different areas for one System. It also takes the catalog the caller already holds, so the review is not an N+1. UI (folded in from step 1 — rule 27, that step shipped with no human surface): - SystemsSection: a Shared area picker on create and edit, the area on each card, and a collapsed review of proposals that appears only when there is something to decide. `exact` and `overlap` never share a style — one is mechanical, the other is the reviewer's judgment, and presenting them alike is how a wrong mapping gets waved through. - Settings → Admin → Areas: the catalog itself, showing each entry's slug, because the slug is what decides whether two names are the same area and a rename moves it. - A picker rather than a live matcher: reproducing the slug rule in TypeScript would give this feature two matchers to keep in step — the exact drift the catalog exists to end. The server stays authoritative. tests/helpers.fake_system gains canonical_id=None: an unnamed attribute is an auto-MagicMock and therefore truthy, which is the trap that helper exists for (note 2109) and a nullable FK walks straight into it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
180 lines
7.3 KiB
Python
180 lines
7.3 KiB
Python
"""System routes nested under /api/projects/<project_id>/systems, plus the
|
|
project's open-issues list. A System is a per-project, reusable subsystem/area
|
|
that records (notes/tasks/issues) associate with."""
|
|
import logging
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from scribe.auth import login_required, get_current_user_id
|
|
from scribe.routes.utils import not_found
|
|
from scribe.services import systems as systems_svc
|
|
from scribe.services.access import can_write_project
|
|
from scribe.services.projects import get_project_for_user
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
systems_bp = Blueprint("systems", __name__, url_prefix="/api/projects")
|
|
|
|
|
|
def _truthy(v: str | None) -> bool:
|
|
return (v or "").lower() in ("1", "true", "yes")
|
|
|
|
|
|
def _split_records(records: list) -> tuple[list, list, list]:
|
|
issues, tasks, notes = [], [], []
|
|
for r in records:
|
|
d = r.to_dict()
|
|
if r.status is None:
|
|
notes.append(d)
|
|
elif r.task_kind == "issue":
|
|
issues.append(d)
|
|
else:
|
|
tasks.append(d)
|
|
return issues, tasks, notes
|
|
|
|
|
|
@systems_bp.route("/<int:project_id>/systems", methods=["GET"])
|
|
@login_required
|
|
async def list_systems_route(project_id: int):
|
|
uid = get_current_user_id()
|
|
if await get_project_for_user(uid, project_id) is None:
|
|
return not_found("Project")
|
|
systems = await systems_svc.list_systems(
|
|
uid, project_id, include_archived=_truthy(request.args.get("include_archived")),
|
|
)
|
|
counts = await systems_svc.open_issue_counts_by_system(uid, project_id)
|
|
out = []
|
|
for s in systems:
|
|
d = s.to_dict()
|
|
d["open_issue_count"] = counts.get(s.id, 0)
|
|
out.append(d)
|
|
return jsonify({"systems": out})
|
|
|
|
|
|
@systems_bp.route("/<int:project_id>/systems", methods=["POST"])
|
|
@login_required
|
|
async def create_system_route(project_id: int):
|
|
uid = get_current_user_id()
|
|
if await get_project_for_user(uid, project_id) is None:
|
|
return not_found("Project")
|
|
if not await can_write_project(uid, project_id):
|
|
return jsonify({"error": "Permission denied"}), 403
|
|
data = await request.get_json() or {}
|
|
if not (data.get("name") or "").strip():
|
|
return jsonify({"error": "name is required"}), 400
|
|
# The same gate the MCP door enforces. It lived only in the tool layer
|
|
# until now, which is exactly how the web UI shipped without gates the
|
|
# agent surface had (#2482) — one service call, one answer (rule 33).
|
|
assessment = await systems_svc.assess_system_name(uid, project_id, data["name"])
|
|
duplicate = assessment["duplicate"]
|
|
if duplicate and not data.get("force"):
|
|
return jsonify({
|
|
"duplicate": True,
|
|
"existing_id": duplicate["id"],
|
|
"error": (
|
|
f"“{duplicate['name']}” already covers this area in this "
|
|
"project. Tag records to it, or rename it if its charter has "
|
|
"moved on — a second System with the same name splits the "
|
|
"area's records across two piles."
|
|
),
|
|
}), 409
|
|
canonical = assessment["canonical"]
|
|
# Exact is mechanical and applied; overlap is a judgment call and is only
|
|
# offered back for the form to present.
|
|
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
|
|
system = await systems_svc.create_system(
|
|
uid, project_id=project_id, name=data["name"],
|
|
description=data.get("description"), color=data.get("color"),
|
|
order_index=data.get("order_index", 0),
|
|
canonical_id=data.get("canonical_id") or applied,
|
|
)
|
|
if system is None:
|
|
return jsonify({"error": "Permission denied"}), 403
|
|
out = system.to_dict()
|
|
if canonical and canonical["basis"] == "overlap" and not system.canonical_id:
|
|
out["canonical_suggestion"] = canonical
|
|
return jsonify(out), 201
|
|
|
|
|
|
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["GET"])
|
|
@login_required
|
|
async def get_system_route(project_id: int, system_id: int):
|
|
uid = get_current_user_id()
|
|
if await get_project_for_user(uid, project_id) is None:
|
|
return not_found("Project")
|
|
system = await systems_svc.get_system(uid, system_id)
|
|
if system is None or system.project_id != project_id:
|
|
return not_found("System")
|
|
issues, tasks, notes = _split_records(
|
|
await systems_svc.list_records_for_system(uid, system_id)
|
|
)
|
|
data = system.to_dict()
|
|
data["issues"], data["tasks"], data["notes"] = issues, tasks, notes
|
|
return jsonify(data)
|
|
|
|
|
|
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["PATCH"])
|
|
@login_required
|
|
async def update_system_route(project_id: int, system_id: int):
|
|
uid = get_current_user_id()
|
|
if await get_project_for_user(uid, project_id) is None:
|
|
return not_found("Project")
|
|
if not await can_write_project(uid, project_id):
|
|
return jsonify({"error": "Permission denied"}), 403
|
|
system = await systems_svc.get_system(uid, system_id)
|
|
if system is None or system.project_id != project_id:
|
|
return not_found("System")
|
|
data = await request.get_json() or {}
|
|
allowed = {"name", "description", "color", "status", "order_index"}
|
|
fields = {k: v for k, v in data.items() if k in allowed}
|
|
if "status" in fields and fields["status"] not in ("active", "archived"):
|
|
return jsonify({"error": "status must be 'active' or 'archived'"}), 400
|
|
updated = await systems_svc.update_system(uid, system_id, **fields)
|
|
if updated is None:
|
|
return not_found("System")
|
|
return jsonify(updated.to_dict())
|
|
|
|
|
|
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["DELETE"])
|
|
@login_required
|
|
async def delete_system_route(project_id: int, system_id: int):
|
|
uid = get_current_user_id()
|
|
if await get_project_for_user(uid, project_id) is None:
|
|
return not_found("Project")
|
|
if not await can_write_project(uid, project_id):
|
|
return jsonify({"error": "Permission denied"}), 403
|
|
system = await systems_svc.get_system(uid, system_id)
|
|
if system is None or system.project_id != project_id:
|
|
return not_found("System")
|
|
await systems_svc.delete_system(uid, system_id)
|
|
return "", 204
|
|
|
|
|
|
@systems_bp.route("/<int:project_id>/systems/<int:system_id>/records", methods=["GET"])
|
|
@login_required
|
|
async def system_records_route(project_id: int, system_id: int):
|
|
uid = get_current_user_id()
|
|
if await get_project_for_user(uid, project_id) is None:
|
|
return not_found("Project")
|
|
system = await systems_svc.get_system(uid, system_id)
|
|
if system is None or system.project_id != project_id:
|
|
return not_found("System")
|
|
records = await systems_svc.list_records_for_system(
|
|
uid, system_id,
|
|
kind=request.args.get("kind") or None,
|
|
open_only=_truthy(request.args.get("open_only")),
|
|
)
|
|
return jsonify({"records": [r.to_dict() for r in records]})
|
|
|
|
|
|
@systems_bp.route("/<int:project_id>/issues", methods=["GET"])
|
|
@login_required
|
|
async def project_issues_route(project_id: int):
|
|
"""A project's issues (open by default — pass open_only=false for all)."""
|
|
uid = get_current_user_id()
|
|
if await get_project_for_user(uid, project_id) is None:
|
|
return not_found("Project")
|
|
open_only = request.args.get("open_only", "true").lower() in ("1", "true", "yes")
|
|
issues = await systems_svc.list_issues(uid, project_id, open_only=open_only)
|
|
return jsonify({"issues": [n.to_dict() for n in issues]})
|