generated from coulomb/repo-seed
fix(api): normalize trailing slashes — no slash on param routes
Rule: trailing slash only on collection roots (/). Any route containing
a path parameter {…} uses no trailing slash. Applies across all routers,
scripts, Makefile, and tests. Fixes 307-redirect fragility on POST/PATCH
from naive clients (curl, Codex HTTP calls).
Also adds POST /repos/{slug}/sync — runs ADR-001 consistency check with
--fix via HTTP, so non-MCP agents (Codex) can self-service DB sync without
operator intervention.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2
Makefile
2
Makefile
@@ -127,7 +127,7 @@ rename-domain:
|
|||||||
register-path:
|
register-path:
|
||||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
|
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
|
||||||
@test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
|
@test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
|
||||||
curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths/" \
|
curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool
|
-d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async def create_contribution(
|
|||||||
return contrib
|
return contrib
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{contribution_id}/", response_model=ContributionRead)
|
@router.get("/{contribution_id}", response_model=ContributionRead)
|
||||||
async def get_contribution(
|
async def get_contribution(
|
||||||
contribution_id: uuid.UUID,
|
contribution_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
@@ -118,7 +118,7 @@ async def patch_contribution_status(
|
|||||||
return contrib
|
return contrib
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{contribution_id}/", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{contribution_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def withdraw_contribution(
|
async def withdraw_contribution(
|
||||||
contribution_id: uuid.UUID,
|
contribution_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async def create_domain(
|
|||||||
return domain
|
return domain
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{slug}/", response_model=DomainDetail)
|
@router.get("/{slug}", response_model=DomainDetail)
|
||||||
async def get_domain(
|
async def get_domain(
|
||||||
slug: str,
|
slug: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import case, func, select
|
from sqlalchemy import case, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from api.config import settings
|
||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
from api.doi_engine import compute_fingerprint, evaluate as _doi_evaluate
|
from api.doi_engine import compute_fingerprint, evaluate as _doi_evaluate
|
||||||
from api.models.doi_cache import DOICache
|
from api.models.doi_cache import DOICache
|
||||||
@@ -337,7 +343,7 @@ async def get_repo_by_id(
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{slug}/", response_model=RepoRead)
|
@router.get("/{slug}", response_model=RepoRead)
|
||||||
async def get_repo(
|
async def get_repo(
|
||||||
slug: str,
|
slug: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
@@ -345,7 +351,7 @@ async def get_repo(
|
|||||||
return await _get_repo_by_slug(slug, session)
|
return await _get_repo_by_slug(slug, session)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{slug}/", response_model=RepoRead)
|
@router.patch("/{slug}", response_model=RepoRead)
|
||||||
async def update_repo(
|
async def update_repo(
|
||||||
slug: str,
|
slug: str,
|
||||||
body: RepoUpdate,
|
body: RepoUpdate,
|
||||||
@@ -359,7 +365,7 @@ async def update_repo(
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{slug}/paths/", response_model=RepoRead)
|
@router.post("/{slug}/paths", response_model=RepoRead)
|
||||||
async def register_host_path(
|
async def register_host_path(
|
||||||
slug: str,
|
slug: str,
|
||||||
body: RepoPathRegister,
|
body: RepoPathRegister,
|
||||||
@@ -471,6 +477,53 @@ async def get_repo_dispatch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{slug}/sync")
|
||||||
|
async def sync_repo_consistency(
|
||||||
|
slug: str,
|
||||||
|
fix: bool = True,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Run ADR-001 consistency check (and optional --fix) for a repo via HTTP.
|
||||||
|
|
||||||
|
Intended for non-Claude-Code agents (e.g. Codex) that cannot use MCP tools
|
||||||
|
but need to sync workplan file state to the state-hub DB after making changes.
|
||||||
|
|
||||||
|
Returns the raw JSON output from consistency_check.py.
|
||||||
|
Query param ?fix=false to run check-only without writing.
|
||||||
|
"""
|
||||||
|
repo = await _get_repo_by_slug(slug, session)
|
||||||
|
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
host_paths = repo.host_paths or {}
|
||||||
|
repo_path = host_paths.get(hostname)
|
||||||
|
if not repo_path or not Path(repo_path).exists():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=(
|
||||||
|
f"No accessible path for repo '{slug}' on host '{hostname}'. "
|
||||||
|
f"Register with: POST /repos/{slug}/paths/"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
|
||||||
|
cmd = [sys.executable, str(script), "--repo", slug, "--json",
|
||||||
|
"--api-base", settings.api_base]
|
||||||
|
if fix:
|
||||||
|
cmd.append("--fix")
|
||||||
|
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
subprocess.run, cmd, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Consistency check failed: {result.stderr or result.stdout or '(no output)'}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||||
repo = result.scalar_one_or_none()
|
repo = result.scalar_one_or_none()
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ async def list_snapshots(
|
|||||||
return [SBOMSnapshotRead.model_validate(s) for s in result.scalars().all()]
|
return [SBOMSnapshotRead.model_validate(s) for s in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/snapshots/{snapshot_id}/", response_model=SBOMSnapshotDetail)
|
@router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail)
|
||||||
async def get_snapshot(
|
async def get_snapshot(
|
||||||
snapshot_id: uuid.UUID,
|
snapshot_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
@@ -209,7 +209,7 @@ async def licence_report(
|
|||||||
return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count)
|
return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{repo_slug}/", response_model=SBOMRepoView)
|
@router.get("/{repo_slug}", response_model=SBOMRepoView)
|
||||||
async def get_repo_sbom(
|
async def get_repo_sbom(
|
||||||
repo_slug: str,
|
repo_slug: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ async def defer_td(
|
|||||||
|
|
||||||
# ── Notes ─────────────────────────────────────────────────────────────────────
|
# ── Notes ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/{td_id}/notes/", response_model=list[TDNoteRead])
|
@router.get("/{td_id}/notes", response_model=list[TDNoteRead])
|
||||||
async def list_notes(
|
async def list_notes(
|
||||||
td_id: uuid.UUID,
|
td_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
@@ -124,7 +124,7 @@ async def list_notes(
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{td_id}/notes/", response_model=TDNoteRead, status_code=status.HTTP_201_CREATED)
|
@router.post("/{td_id}/notes", response_model=TDNoteRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def add_note(
|
async def add_note(
|
||||||
td_id: uuid.UUID,
|
td_id: uuid.UUID,
|
||||||
body: TDNoteCreate,
|
body: TDNoteCreate,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ PROMPT_FILE = SCRIPT_DIR.parent / "prompts" / "sbom-capture-agent.md"
|
|||||||
|
|
||||||
def resolve_repo_path(repo_slug: str) -> Path | None:
|
def resolve_repo_path(repo_slug: str) -> Path | None:
|
||||||
"""Look up the registered path for a repo slug via the state-hub API."""
|
"""Look up the registered path for a repo slug via the state-hub API."""
|
||||||
url = f"{API_BASE}/repos/{repo_slug}/"
|
url = f"{API_BASE}/repos/{repo_slug}"
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=10) as resp:
|
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ def _print_report(report, use_color: bool = True) -> None:
|
|||||||
|
|
||||||
async def check_repo(slug: str, as_json: bool) -> bool:
|
async def check_repo(slug: str, as_json: bool) -> bool:
|
||||||
try:
|
try:
|
||||||
repo = _get(f"/repos/{slug}/")
|
repo = _get(f"/repos/{slug}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"✗ Could not fetch repo '{slug}': {e}", file=sys.stderr)
|
print(f"✗ Could not fetch repo '{slug}': {e}", file=sys.stderr)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1083,7 +1083,7 @@ def fix_repo(
|
|||||||
hostname = socket.gethostname()
|
hostname = socket.gethostname()
|
||||||
if (repo_record.get("host_paths") or {}).get(hostname) != repo_path:
|
if (repo_record.get("host_paths") or {}).get(hostname) != repo_path:
|
||||||
result = _api_post(
|
result = _api_post(
|
||||||
api_base, f"/repos/{repo_slug}/paths/",
|
api_base, f"/repos/{repo_slug}/paths",
|
||||||
{"host": hostname, "path": repo_path},
|
{"host": hostname, "path": repo_path},
|
||||||
)
|
)
|
||||||
if result and "_error" not in result:
|
if result and "_error" not in result:
|
||||||
@@ -1333,7 +1333,7 @@ def fix_repo(
|
|||||||
from datetime import timezone as _tz
|
from datetime import timezone as _tz
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
now_iso = _dt.datetime.now(_tz.utc).isoformat()
|
now_iso = _dt.datetime.now(_tz.utc).isoformat()
|
||||||
_api_patch(api_base, f"/repos/{repo_slug}/", {"last_state_synced_at": now_iso})
|
_api_patch(api_base, f"/repos/{repo_slug}", {"last_state_synced_at": now_iso})
|
||||||
|
|
||||||
# Write the worker orientation brief (.custodian-brief.md)
|
# Write the worker orientation brief (.custodian-brief.md)
|
||||||
if repo_path:
|
if repo_path:
|
||||||
|
|||||||
@@ -509,7 +509,7 @@ def post_ingest(api_base: str, repo_slug: str, entries: list[dict]) -> dict:
|
|||||||
def _resolve_repo_path_from_hub(api_base: str, repo_slug: str) -> Path | None:
|
def _resolve_repo_path_from_hub(api_base: str, repo_slug: str) -> Path | None:
|
||||||
"""Query the hub for this host's registered path for repo_slug."""
|
"""Query the hub for this host's registered path for repo_slug."""
|
||||||
try:
|
try:
|
||||||
url = f"{api_base}/repos/{repo_slug}/"
|
url = f"{api_base}/repos/{repo_slug}"
|
||||||
with urllib.request.urlopen(url) as resp:
|
with urllib.request.urlopen(url) as resp:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
hostname = socket.gethostname()
|
hostname = socket.gethostname()
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ resolve_path() {
|
|||||||
local slug="$1"
|
local slug="$1"
|
||||||
# Try the registered local_path first
|
# Try the registered local_path first
|
||||||
local api_path
|
local api_path
|
||||||
api_path=$(curl -sf "${API_BASE}/repos/${slug}/" | python3 -c \
|
api_path=$(curl -sf "${API_BASE}/repos/${slug}" | python3 -c \
|
||||||
"import json,sys; d=json.load(sys.stdin); print(d.get('local_path') or '')" 2>/dev/null || true)
|
"import json,sys; d=json.load(sys.stdin); print(d.get('local_path') or '')" 2>/dev/null || true)
|
||||||
if [[ -n "$api_path" && -d "$api_path" ]]; then
|
if [[ -n "$api_path" && -d "$api_path" ]]; then
|
||||||
echo "$api_path"
|
echo "$api_path"
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ echo " API OK"
|
|||||||
|
|
||||||
# ── Step 2: Verify domain exists ───────────────────────────────────────────────
|
# ── Step 2: Verify domain exists ───────────────────────────────────────────────
|
||||||
echo "==> Verifying domain '$DOMAIN' ..."
|
echo "==> Verifying domain '$DOMAIN' ..."
|
||||||
DOMAIN_JSON="$(curl -sf "$API_BASE/domains/$DOMAIN/" 2>/dev/null || echo 'NOT_FOUND')"
|
DOMAIN_JSON="$(curl -sf "$API_BASE/domains/$DOMAIN" 2>/dev/null || echo 'NOT_FOUND')"
|
||||||
if [[ "$DOMAIN_JSON" == "NOT_FOUND" ]] || ! echo "$DOMAIN_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get('slug') else 1)" 2>/dev/null; then
|
if [[ "$DOMAIN_JSON" == "NOT_FOUND" ]] || ! echo "$DOMAIN_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get('slug') else 1)" 2>/dev/null; then
|
||||||
echo "ERROR: Domain '$DOMAIN' not found in the State Hub."
|
echo "ERROR: Domain '$DOMAIN' not found in the State Hub."
|
||||||
echo " To create: make add-domain DOMAIN=$DOMAIN NAME=\"<display name>\""
|
echo " To create: make add-domain DOMAIN=$DOMAIN NAME=\"<display name>\""
|
||||||
@@ -252,7 +252,7 @@ fi
|
|||||||
|
|
||||||
# ── Step 7: Register this machine's local path ────────────────────────────────
|
# ── Step 7: Register this machine's local path ────────────────────────────────
|
||||||
echo "==> Registering host path for $(hostname) ..."
|
echo "==> Registering host path for $(hostname) ..."
|
||||||
curl -sf -X POST "$API_BASE/repos/$REPO_SLUG/paths/" \
|
curl -sf -X POST "$API_BASE/repos/$REPO_SLUG/paths" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"host\": \"$(hostname)\", \"path\": \"$PROJECT_PATH\"}" > /dev/null \
|
-d "{\"host\": \"$(hostname)\", \"path\": \"$PROJECT_PATH\"}" > /dev/null \
|
||||||
&& echo " host_paths[$(hostname)] = $PROJECT_PATH"
|
&& echo " host_paths[$(hostname)] = $PROJECT_PATH"
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ class TestTechnicalDebt:
|
|||||||
})
|
})
|
||||||
td_id = r.json()["id"]
|
td_id = r.json()["id"]
|
||||||
|
|
||||||
r2 = await client.post(f"/technical-debt/{td_id}/notes/", json={
|
r2 = await client.post(f"/technical-debt/{td_id}/notes", json={
|
||||||
"step": "analysis",
|
"step": "analysis",
|
||||||
"content": "Root cause identified.",
|
"content": "Root cause identified.",
|
||||||
})
|
})
|
||||||
assert r2.status_code == 201
|
assert r2.status_code == 201
|
||||||
|
|
||||||
r3 = await client.get(f"/technical-debt/{td_id}/notes/")
|
r3 = await client.get(f"/technical-debt/{td_id}/notes")
|
||||||
assert r3.status_code == 200
|
assert r3.status_code == 200
|
||||||
assert len(r3.json()) == 1
|
assert len(r3.json()) == 1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user