feat(coverage): pattern-library coverage measurement (#2692, milestone 288 step 7)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 41s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 41s
Server-side shape enumeration per bound repo — one archive download via the forge adapter, definitions extracted with a Python mirror of the write-path hook's awk rules (shared test vectors pin the two together) — compared against recorded snippet locations by path+symbol. Summary is cached in the settings KV with a freshness stamp; recomputed on webhook push (spawned off the delivery path) or explicit refresh, never in a request path. Surfaces: GET/POST /api/projects/<id>/coverage[/refresh], a project-page card (estimate-labeled, largest-gaps chips), and a one-line evidence-carrying entry in enter_project read from cache only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,61 @@ async def delete_project_route(project_id: int):
|
||||
return "", 204
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>/coverage", methods=["GET"])
|
||||
@login_required
|
||||
async def get_coverage_route(project_id: int):
|
||||
"""The cached pattern-library coverage summary — never computes.
|
||||
|
||||
`configured` tells the card whether offering a Refresh button makes
|
||||
sense; `coverage` is null until something has computed it (a webhook
|
||||
push or an explicit refresh).
|
||||
"""
|
||||
from scribe.services.coverage import cached_coverage
|
||||
from scribe.services.forge import get_forge
|
||||
|
||||
uid = get_current_user_id()
|
||||
result = await get_project_for_user(uid, project_id)
|
||||
if result is None:
|
||||
return not_found("Project")
|
||||
project, _ = result
|
||||
owner_uid = project.user_id or uid
|
||||
return jsonify({
|
||||
"configured": await get_forge() is not None,
|
||||
"coverage": await cached_coverage(owner_uid, project_id),
|
||||
})
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>/coverage/refresh", methods=["POST"])
|
||||
@login_required
|
||||
async def refresh_coverage_route(project_id: int):
|
||||
"""Recompute coverage now (archive fetch — seconds, not milliseconds).
|
||||
|
||||
Synchronous on purpose: the caller is a person who just clicked Refresh
|
||||
and wants the new number, and the forge timeout bounds the wait.
|
||||
"""
|
||||
from scribe.services.coverage import refresh_coverage
|
||||
from scribe.services.forge import ForgeError, get_forge
|
||||
|
||||
uid = get_current_user_id()
|
||||
result = await get_project_for_user(uid, project_id)
|
||||
if result is None:
|
||||
return not_found("Project")
|
||||
project, _ = result
|
||||
owner_uid = project.user_id or uid
|
||||
if await get_forge() is None:
|
||||
return jsonify({"error": "No git forge is configured (Settings → Config → Git Forge)"}), 400
|
||||
try:
|
||||
coverage = await refresh_coverage(owner_uid, project_id)
|
||||
except ForgeError as exc:
|
||||
return jsonify({"error": str(exc)}), 502
|
||||
if coverage is None:
|
||||
return jsonify({
|
||||
"error": "No bound repo is served by the configured forge — "
|
||||
"bind the project's repo (bind_repo) on a remote the forge hosts"
|
||||
}), 400
|
||||
return jsonify({"coverage": coverage})
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
|
||||
@login_required
|
||||
async def get_project_notes_route(project_id: int):
|
||||
|
||||
@@ -29,7 +29,9 @@ import traceback
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.config import Config
|
||||
from scribe.services.repo_bindings import normalize_repo_key
|
||||
from scribe.services.background import spawn
|
||||
from scribe.services.coverage import refresh_coverage
|
||||
from scribe.services.repo_bindings import bindings_for_key, normalize_repo_key
|
||||
from scribe.services.settings import get_admin_setting
|
||||
from scribe.services.snippets import invalidate_for_push
|
||||
|
||||
@@ -88,6 +90,16 @@ async def forge_push():
|
||||
logger.info(
|
||||
"forge push %s flagged %d snippet(s) for recheck", head[:12], flagged
|
||||
)
|
||||
# A push is exactly when the coverage number goes stale — recompute it
|
||||
# off the delivery path (#2692). Fire-and-forget: the forge's delivery
|
||||
# loop must not wait on an archive download, and a failure is a
|
||||
# WARNING from spawn(), never a failed delivery. This also SEEDS the
|
||||
# cache on a webhook-configured instance — no manual first refresh.
|
||||
for binding in await bindings_for_key(repo_key):
|
||||
spawn(
|
||||
refresh_coverage(binding.user_id, binding.project_id),
|
||||
site="webhooks.coverage_refresh",
|
||||
)
|
||||
return jsonify({"ok": True, "flagged": flagged})
|
||||
except Exception:
|
||||
logger.warning("forge webhook processing failed", exc_info=True)
|
||||
|
||||
Reference in New Issue
Block a user