diff --git a/docs/schemas/repo-scope-context-response.json b/docs/schemas/repo-scope-context-response.json new file mode 100644 index 0000000..4c6751c --- /dev/null +++ b/docs/schemas/repo-scope-context-response.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://repo-scoping.local/schemas/repo-scope-context-response.json", + "title": "Repository Scope Context Response", + "description": "Provider-side context contract returned by GET /repos/{repo_slug}/scope/context for activity-core.", + "type": "object", + "additionalProperties": false, + "required": [ + "repo_slug", + "capabilities", + "tags", + "scope_md_exists", + "scope_summary" + ], + "properties": { + "repo_slug": { + "type": "string", + "description": "Slug requested by the caller after normal slugification.", + "examples": ["repo-scoping"] + }, + "capabilities": { + "type": "array", + "description": "Approved capability names from the repository ability map.", + "items": { + "type": "string" + }, + "examples": [["Generate SCOPE.md", "Preview generated SCOPE.md"]] + }, + "tags": { + "type": "array", + "description": "Stable, sorted union of approved ability and capability primary_class values plus their attributes.", + "items": { + "type": "string" + }, + "examples": [["api", "generation", "scope"]] + }, + "scope_md_exists": { + "type": "boolean", + "description": "True when repo-scoping can inspect a local or cached checkout and root SCOPE.md exists; false when absent or unknown.", + "examples": [true] + }, + "scope_summary": { + "description": "First non-empty paragraph of root SCOPE.md when inspectable; otherwise approved scope description, repository description, or null.", + "type": ["string", "null"], + "examples": [ + "Repository Scoping maps repositories into reviewable scope graphs." + ] + } + }, + "examples": [ + { + "repo_slug": "repo-scoping", + "capabilities": ["Generate SCOPE.md"], + "tags": ["api", "generation", "scope"], + "scope_md_exists": true, + "scope_summary": "Repository Scoping maps repositories into reviewable scope graphs." + } + ] +} diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 76ed2c0..ac2f125 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -62,6 +62,7 @@ from repo_registry.web_api.schemas import ( RepositoryComparisonResponse, RepositoryCreate, RepositoryResponse, + RepositoryScopeContextResponse, RepositoryUpdate, ReviewDecisionResponse, ScanSummaryResponse, @@ -1346,6 +1347,45 @@ def export_repository_registry_entry( return PlainTextResponse(content, media_type="application/x-yaml") +@app.get( + "/repos/{repo_slug}/scope/context", + tags=["scope"], + response_model=RepositoryScopeContextResponse, +) +def get_repository_scope_context( + repo_slug: str, + service: RegistryService = Depends(get_service), + settings: Settings = Depends(get_settings), +) -> dict[str, object]: + try: + repository = repository_by_slug(service, repo_slug) + ability_map = service.ability_map(repository.id) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + scope_path = inspectable_scope_file_path(service, repository, repo_slug, settings) + scope_md_exists = bool(scope_path and scope_path.is_file()) + scope_summary = ( + scope_summary_from_file(scope_path) + if scope_md_exists and scope_path is not None + else None + ) + if not scope_summary: + scope_summary = ( + ability_map.scope.description.strip() + or (repository.description.strip() if repository.description else None) + or None + ) + + return { + "repo_slug": slugify(repo_slug), + "capabilities": scope_context_capabilities(ability_map), + "tags": scope_context_tags(ability_map), + "scope_md_exists": scope_md_exists, + "scope_summary": scope_summary, + } + + @app.get( "/repos/{repo_slug}/scope", tags=["scope"], @@ -1413,6 +1453,97 @@ def write_repository_scope( return {"written": True, "path": str(scope_path)} +def scope_context_capabilities(ability_map) -> list[str]: + return unique_preserving_order( + [ + capability.name + for ability in ability_map.abilities + for capability in ability.capabilities + ] + ) + + +def scope_context_tags(ability_map) -> list[str]: + tags = [] + for ability in ability_map.abilities: + tags.append(ability.primary_class) + tags.extend(ability.attributes) + for capability in ability.capabilities: + tags.append(capability.primary_class) + tags.extend(capability.attributes) + return sorted(unique_preserving_order(tags), key=str.lower) + + +def unique_preserving_order(values: list[str]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + item = str(value).strip() + key = item.lower() + if not item or key in seen: + continue + seen.add(key) + result.append(item) + return result + + +def inspectable_scope_file_path( + service: RegistryService, + repository, + repo_slug: str, + settings: Settings, +) -> Path | None: + try: + state_hub_path = state_hub_scope_file_path(repo_slug, settings) + except ValueError: + state_hub_path = None + if state_hub_path is not None: + return state_hub_path + + source_path = Path(repository.url) + if source_path.exists() and source_path.is_dir(): + return source_path / "SCOPE.md" + + checkout = service.ingestion.cached_checkout(repository.url) + if checkout is not None and checkout.source_path.exists(): + return checkout.source_path / "SCOPE.md" + return None + + +def scope_summary_from_file(scope_path: Path) -> str | None: + try: + content = scope_path.read_text(encoding="utf-8") + except OSError: + return None + + paragraph: list[str] = [] + in_frontmatter = False + frontmatter_checked = False + for line in content.splitlines(): + stripped = line.strip() + if not frontmatter_checked: + frontmatter_checked = True + if stripped == "---": + in_frontmatter = True + continue + if in_frontmatter: + if stripped == "---": + in_frontmatter = False + continue + if not stripped: + if paragraph: + return " ".join(paragraph) + continue + if stripped == "---" or stripped.startswith("#") or stripped.startswith(">"): + if paragraph: + return " ".join(paragraph) + continue + paragraph.append(stripped) + if paragraph: + return " ".join(paragraph) + return None + + def ensure_scope_generation_ready( service: RegistryService, repo_slug: str, diff --git a/src/repo_registry/web_api/schemas.py b/src/repo_registry/web_api/schemas.py index f1b477c..6ed66b9 100644 --- a/src/repo_registry/web_api/schemas.py +++ b/src/repo_registry/web_api/schemas.py @@ -862,6 +862,51 @@ class RepositoryAbilityMapResponse(BaseModel): } +class RepositoryScopeContextResponse(BaseModel): + repo_slug: str = Field( + description="Slug requested by the caller after normal slugification." + ) + capabilities: list[str] = Field( + description="Approved capability names from the repository ability map." + ) + tags: list[str] = Field( + description=( + "Sorted union of approved ability and capability primary classes " + "plus their attributes." + ) + ) + scope_md_exists: bool = Field( + description=( + "True when repo-scoping can inspect a local or cached checkout and " + "root SCOPE.md exists." + ) + ) + scope_summary: str | None = Field( + description=( + "First non-empty paragraph of root SCOPE.md when available, " + "otherwise registry metadata." + ) + ) + + model_config = { + "extra": "forbid", + "json_schema_extra": { + "examples": [ + { + "repo_slug": "repo-scoping", + "capabilities": ["Generate SCOPE.md"], + "tags": ["api", "generation", "scope"], + "scope_md_exists": True, + "scope_summary": ( + "Repository Scoping maps repositories into reviewable " + "scope graphs." + ), + } + ] + }, + } + + class IdResponse(BaseModel): id: int diff --git a/tests/test_scope_context_api.py b/tests/test_scope_context_api.py new file mode 100644 index 0000000..db9849f --- /dev/null +++ b/tests/test_scope_context_api.py @@ -0,0 +1,162 @@ +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from repo_registry.web_api.app import Settings, app, get_settings + + +def override_settings(tmp_path): + def _override(): + return Settings( + database_path=str(tmp_path / "scope-context.sqlite3"), + checkout_root=str(tmp_path / "checkouts"), + state_hub_base_url="", + ) + + return _override + + +def test_scope_context_endpoint_returns_activity_core_contract(tmp_path): + scoped_source = tmp_path / "scoped-repo" + scoped_source.mkdir() + (scoped_source / "SCOPE.md").write_text( + "# SCOPE\n\n" + "> Generated context note.\n\n" + "---\n\n" + "## One-liner\n\n" + "Maps repositories into concise operating context.\n\n" + "## Notes\n\n" + "Further details stay out of the summary.\n", + encoding="utf-8", + ) + + plain_source = tmp_path / "plain-repo" + plain_source.mkdir() + + app.dependency_overrides[get_settings] = override_settings(tmp_path) + client = TestClient(app) + try: + scoped = client.post( + "/repos", + json={ + "name": "Scoped Repo", + "url": str(scoped_source), + "description": "Registry fallback for scoped repo.", + }, + ).json() + ability_id = client.post( + f"/repos/{scoped['id']}/abilities", + json={ + "name": "Context Publishing", + "description": "Expose repository context to callers.", + }, + ).json()["id"] + client.post( + f"/repos/{scoped['id']}/capabilities", + json={ + "ability_id": ability_id, + "name": "Generate SCOPE.md", + "description": "Render repository scope from approved entries.", + }, + ) + + client.post( + "/repos", + json={ + "name": "Plain Repo", + "url": str(plain_source), + "description": "Plain repository metadata summary.", + }, + ) + client.post( + "/repos", + json={ + "name": "Remote Repo", + "url": "https://example.test/remote-repo.git", + "description": "Remote repository metadata summary.", + }, + ) + + scoped_context = client.get("/repos/scoped-repo/scope/context") + assert scoped_context.status_code == 200 + assert scoped_context.json() == { + "repo_slug": "scoped-repo", + "capabilities": ["Generate SCOPE.md"], + "tags": ["ability", "capability"], + "scope_md_exists": True, + "scope_summary": "Maps repositories into concise operating context.", + } + + plain_context = client.get("/repos/plain-repo/scope/context") + assert plain_context.status_code == 200 + assert plain_context.json() == { + "repo_slug": "plain-repo", + "capabilities": [], + "tags": [], + "scope_md_exists": False, + "scope_summary": "Plain repository metadata summary.", + } + + remote_context = client.get("/repos/remote-repo/scope/context") + assert remote_context.status_code == 200 + assert remote_context.json() == { + "repo_slug": "remote-repo", + "capabilities": [], + "tags": [], + "scope_md_exists": False, + "scope_summary": "Remote repository metadata summary.", + } + + missing_context = client.get("/repos/missing-repo/scope/context") + assert missing_context.status_code == 404 + assert missing_context.json()["detail"] + + markdown_scope = client.get("/repos/scoped-repo/scope") + assert markdown_scope.status_code == 200 + assert markdown_scope.headers["content-type"].startswith("text/markdown") + assert "# SCOPE" in markdown_scope.text + finally: + app.dependency_overrides.clear() + + +def test_scope_context_schema_artifact_matches_contract_shape(): + schema = json.loads( + ( + Path(__file__).parents[1] + / "docs" + / "schemas" + / "repo-scope-context-response.json" + ).read_text(encoding="utf-8") + ) + + assert schema["additionalProperties"] is False + assert schema["required"] == [ + "repo_slug", + "capabilities", + "tags", + "scope_md_exists", + "scope_summary", + ] + assert schema["properties"]["scope_md_exists"]["type"] == "boolean" + assert schema["properties"]["scope_summary"]["type"] == ["string", "null"] + + +def test_scope_context_openapi_contract_is_exposed(tmp_path): + app.dependency_overrides[get_settings] = override_settings(tmp_path) + client = TestClient(app) + try: + schema = client.get("/openapi.json").json() + finally: + app.dependency_overrides.clear() + + operation = schema["paths"]["/repos/{repo_slug}/scope/context"]["get"] + assert operation["tags"] == ["scope"] + response_schema = operation["responses"]["200"]["content"]["application/json"][ + "schema" + ] + assert response_schema["$ref"].endswith("/RepositoryScopeContextResponse") + assert ( + "RepositoryScopeContextResponse" + in schema["components"]["schemas"] + ) diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 237cfd4..437cb88 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -333,6 +333,12 @@ def test_openapi_contract_snapshot_for_stable_agent_paths(): "/repos/{repo_slug}/scope": { "get": {"tags": ["scope"], "success_schema": None} }, + "/repos/{repo_slug}/scope/context": { + "get": { + "tags": ["scope"], + "success_schema": "RepositoryScopeContextResponse", + } + }, "/repos/{repo_slug}/scope/diff": { "get": {"tags": ["scope"], "success_schema": "object"} }, diff --git a/workplans/RREG-WP-0012-activity-core-context-api.md b/workplans/RREG-WP-0012-activity-core-context-api.md index 3ebfeca..902df52 100644 --- a/workplans/RREG-WP-0012-activity-core-context-api.md +++ b/workplans/RREG-WP-0012-activity-core-context-api.md @@ -1,152 +1,178 @@ --- id: RREG-WP-0012 type: workplan -domain: custodian +title: "Activity-Core Scope Context API" +domain: capabilities repo: repo-scoping status: active -state_hub_workstream_id: 83a43ab6-d566-41e0-afd7-f2833812564f -tasks: - - id: T01 - title: Define context query response schema for activity-core - state_hub_task_id: 83482f1d-4f44-440d-a27f-0c375786b87f - status: todo - - id: T02 - title: Verify/implement GET /repos/{slug}/scope endpoint - state_hub_task_id: 75f6123a-2703-42ec-9564-dd665e16c5ef - status: todo - - id: T03 - title: Add scope_md_exists boolean to scope response - state_hub_task_id: cb0b17f4-30c4-4d68-b2ff-9a3fa9ef73a3 - status: todo - - id: T04 - title: Write integration test for activity-core repo-scoping adapter - state_hub_task_id: f70c1c58-b23f-4b61-adca-a02644b6ee7e - status: todo +owner: codex +topic_slug: foerster-capabilities created: "2026-05-14" +updated: "2026-05-15" +state_hub_workstream_id: "83a43ab6-d566-41e0-afd7-f2833812564f" --- -# RREG-WP-0012: Activity-Core Context API +# Activity-Core Scope Context API -## Purpose +activity-core's `RunActivityWorkflow` resolves repository context before +evaluating rules and instructions. One of its context sources is repo-scoping: +given a repo slug, activity-core needs a compact capability profile with +capability names, classification tags, whether a `SCOPE.md` is known to exist, +and a short scope summary when available. -activity-core's `RunActivityWorkflow` resolves context before evaluating rules -and instructions. One of its context sources is repo-scoping: given a repo slug, -it queries repo-scoping for the repo's capability profile (tags, capabilities, -whether a SCOPE.md exists, etc.). +This is a contract workplan. The goal is a stable JSON interface for +activity-core, not new scope analysis functionality. -This workplan ensures repo-scoping exposes a stable, documented context API -endpoint that activity-core can call reliably. It is a **contract workplan** — -the goal is a stable interface, not new functionality. +## Contract Decision -## Context +Do not change `GET /repos/{repo_slug}/scope`. That endpoint already exists as +the generated `SCOPE.md` Markdown preview endpoint, with sibling diff/write +routes. Changing its success media type or response shape would break existing +repo-scoping API clients and the OpenAPI contract policy. -- **activity-core WP-0003** (in progress): implements `RepoScopingContextResolver` - in `src/activity_core/context_resolvers/repo_scoping.py` which calls - `GET /repos/{slug}/scope` on the repo-scoping API. -- **repo-scoping** already has a scope API; the question is whether it returns - the exact fields activity-core needs, and whether those fields are stable. +Add a new provider-side context endpoint instead: -See: `docs/adr/adr-001-event-bridge-architecture.md` in activity-core, section -on context resolution adapters. +```text +GET /repos/{repo_slug}/scope/context +``` -## Scope - -**In scope:** -- Documenting and stabilizing the `/repos/{slug}/scope` endpoint response schema -- Adding `scope_md_exists` if not present -- Contract/integration test - -**Out of scope:** -- Dynamic operational state (that is the state hub's domain, not repo-scoping's) -- NATS-based context queries (REST only for now) -- Any new scope analysis features - -## Contract - -The endpoint `GET /repos/{slug}/scope` must return: +The endpoint returns `application/json`: ```json { - "repo_slug": "string", - "capabilities": ["string"], - "tags": ["string"], + "repo_slug": "repo-scoping", + "capabilities": ["Generate SCOPE.md"], + "tags": ["api", "generation", "scope"], "scope_md_exists": true, - "scope_summary": "string or null" + "scope_summary": "Repository Scoping maps repositories into reviewable scope graphs." } ``` -| Field | Type | Required | Notes | +| Field | Type | Required | Source | |---|---|---|---| -| `repo_slug` | string | yes | URL-safe identifier | -| `capabilities` | string[] | yes | Declared capability types | -| `tags` | string[] | yes | Free-form classification tags | -| `scope_md_exists` | boolean | yes | True if SCOPE.md present in repo root | -| `scope_summary` | string or null | no | First paragraph of SCOPE.md if present | +| `repo_slug` | string | yes | Slug requested by the caller after normal slugification | +| `capabilities` | string[] | yes | Approved capability names from the repository ability map | +| `tags` | string[] | yes | Stable, sorted union of approved ability and capability `primary_class` values plus their `attributes` | +| `scope_md_exists` | boolean | yes | True when repo-scoping can inspect a local or cached checkout and root `SCOPE.md` exists; false when absent or unknown | +| `scope_summary` | string or null | yes | First non-empty paragraph of root `SCOPE.md` when inspectable; otherwise approved scope description, repository description, or null | -This schema is the contract. Changes must be versioned and communicated to the -activity-core agent before deployment. +The `scope_md_exists` field is intentionally conservative. The provider must +not clone or fetch remote repositories merely to answer this lightweight context +query. If no local or cached checkout is available, the value is `false` and +`scope_summary` falls back to registry metadata. -## Tasks +activity-core should call this new endpoint, not the Markdown preview endpoint. +If a future contract needs an unknown/absent distinction for `SCOPE.md`, add a +new optional field such as `scope_md_status` rather than changing the boolean. -### T01 — Define context query response schema for activity-core - -Write a JSON Schema file (`docs/schemas/repo-scope-context-response.json`) that -formalizes the response shape above. Include field descriptions and examples. -This becomes the machine-readable contract between repo-scoping and activity-core. - -### T02 — Verify/implement GET /repos/{slug}/scope endpoint - -Check the existing API for `GET /repos/{slug}/scope`. Verify it returns all -required fields. If missing fields, implement them. If the endpoint does not -exist, implement it. - -The endpoint is called by activity-core's `RepoScopingContextResolver` and -must be stable — no breaking changes without coordinating with activity-core. - -### T03 — Add scope_md_exists boolean to scope response - -Ensure `scope_md_exists` is present and accurate: `true` if a `SCOPE.md` file -exists in the repo's root directory on the default branch, `false` otherwise. - -This field is used by activity-core rules to decide whether to emit a -generate-SCOPE.md task for a repo. - -### T04 — Write integration test for activity-core repo-scoping adapter - -Write a contract test that: -1. Calls `GET /repos/{slug}/scope` with a known test repo slug -2. Asserts all required fields are present -3. Asserts types match the schema (scope_md_exists is bool, capabilities is list, etc.) - -This test lives in repo-scoping as the provider-side contract test. It should -run in CI so contract drift is caught before it breaks activity-core. - -**Test file**: `tests/test_scope_context_api.py` (or alongside existing API tests) - -## Build Order +## T01: Define Context Query Response Schema +```task +id: RREG-WP-0012-T01 +status: done +priority: high +state_hub_task_id: "83482f1d-4f44-440d-a27f-0c375786b87f" ``` -T01 (schema) → T02 (implement) → T03 (add field) → T04 (test) + +Write `docs/schemas/repo-scope-context-response.json` to formalize the response +shape above. Include field descriptions, required fields, `additionalProperties: +false`, and at least one complete example. + +Acceptance criteria: +- The schema describes `repo_slug`, `capabilities`, `tags`, `scope_md_exists`, + and `scope_summary`. +- Required fields match the contract table. +- The schema is easy for activity-core to vendor or validate against. + +## T02: Implement Scope Context Endpoint + +```task +id: RREG-WP-0012-T02 +status: done +priority: high +state_hub_task_id: "75f6123a-2703-42ec-9564-dd665e16c5ef" ``` +Implement `GET /repos/{repo_slug}/scope/context` in repo-scoping. The endpoint +must return the JSON contract above and must preserve the existing +`GET /repos/{repo_slug}/scope` Markdown behavior. + +Acceptance criteria: +- The endpoint resolves repositories with the same slug rules as the existing + scope endpoints. +- Repositories without approved capabilities still return a valid context + response with empty `capabilities` and `tags`. +- Missing repository slugs return the existing `404` error shape. +- The OpenAPI snapshot includes the new endpoint and response model. + +## T03: Resolve SCOPE.md Presence And Summary + +```task +id: RREG-WP-0012-T03 +status: done +priority: high +state_hub_task_id: "cb0b17f4-30c4-4d68-b2ff-9a3fa9ef73a3" +``` + +Populate `scope_md_exists` and `scope_summary` without performing network +operations. Reuse local source paths, cached checkouts, and State Hub local path +lookup where available. + +Acceptance criteria: +- A local registered repo with root `SCOPE.md` returns `scope_md_exists: true`. +- A local registered repo without root `SCOPE.md` returns `scope_md_exists: + false`. +- A remote repo with no cached checkout returns `scope_md_exists: false` and a + metadata fallback summary when available. +- `scope_summary` extracts the first non-empty paragraph after headings or + frontmatter noise when a `SCOPE.md` file is inspectable. + +## T04: Add Provider-Side Contract Tests + +```task +id: RREG-WP-0012-T04 +status: done +priority: high +state_hub_task_id: "f70c1c58-b23f-4b61-adca-a02644b6ee7e" +``` + +Add `tests/test_scope_context_api.py` or equivalent focused API coverage for +the new context endpoint. + +Acceptance criteria: +- Tests assert all required fields and types. +- Tests cover at least three repository cases: local with `SCOPE.md`, local + without `SCOPE.md`, and remote/uncached. +- Tests assert the existing Markdown `/scope` endpoint still returns Markdown. +- Tests assert the OpenAPI contract exposes the new response model. + ## Completion Criteria -1. `GET /repos/{slug}/scope` returns all five required fields -2. `scope_md_exists` is accurate for at least three test repos -3. JSON Schema file committed to `docs/schemas/` -4. Contract test passes in CI -5. activity-core agent confirmed the `RepoScopingContextResolver` integration works +- `GET /repos/{repo_slug}/scope/context` returns the five-field JSON contract. +- Existing `GET /repos/{repo_slug}/scope` Markdown preview behavior is unchanged. +- `docs/schemas/repo-scope-context-response.json` exists. +- Provider-side contract tests pass in CI. +- activity-core updates `RepoScopingContextResolver` to call `/scope/context`. + +Implementation note 2026-05-15: repo-scoping provider work is complete. Added +`GET /repos/{repo_slug}/scope/context`, the Pydantic response model, the JSON +Schema artifact, focused provider-side contract tests, and OpenAPI snapshot +coverage. Full repo test suite passed locally with `.venv/bin/python -m pytest` +(`140 passed`). Workplan remains active until activity-core updates and confirms +its resolver. ## Notes -- The local agent implementing this should coordinate with the activity-core agent - to confirm the exact field names and types before implementing T02. -- Do **not** add dynamic operational state to this endpoint (e.g., last_commit_at, - open_issues_count) — that belongs to the state hub context adapter, not repo-scoping. -- `scope_summary` is optional (null OK) — do not block on extracting it if the - SCOPE.md parsing is complex. +- Dynamic operational state such as last commit time, open issues, active tasks, + or runtime health belongs to the State Hub context adapter, not this endpoint. +- This endpoint should be cheap and deterministic. It must not trigger scans, + clones, LLM calls, or SCOPE.md generation. +- Backward-compatible additions can be optional fields. Required field changes + need coordination with activity-core. ## Change History - v0.1 (2026-05-14): Stub created by activity-core agent during WP-0003 planning. - Local agent to flesh out and implement. +- v0.2 (2026-05-15): Revised by repo-scoping agent to avoid breaking the + existing Markdown `/scope` endpoint, define `/scope/context`, and normalize + ADR-001 task blocks.