diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql index 568c2a6..a1e7589 100644 --- a/migrations/0001_initial.sql +++ b/migrations/0001_initial.sql @@ -123,6 +123,15 @@ CREATE TABLE IF NOT EXISTS review_decisions ( created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS repository_scopes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repository_id INTEGER NOT NULL UNIQUE REFERENCES repositories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + confidence REAL NOT NULL DEFAULT 1.0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS approved_abilities ( id INTEGER PRIMARY KEY AUTOINCREMENT, repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, @@ -183,6 +192,7 @@ CREATE INDEX IF NOT EXISTS idx_candidate_capabilities_repository ON candidate_ca CREATE INDEX IF NOT EXISTS idx_candidate_features_repository ON candidate_features(repository_id); CREATE INDEX IF NOT EXISTS idx_candidate_evidence_repository ON candidate_evidence(repository_id); CREATE INDEX IF NOT EXISTS idx_review_decisions_repository ON review_decisions(repository_id); +CREATE INDEX IF NOT EXISTS idx_scopes_repository ON repository_scopes(repository_id); CREATE INDEX IF NOT EXISTS idx_abilities_repository ON approved_abilities(repository_id); CREATE INDEX IF NOT EXISTS idx_capabilities_repository ON approved_capabilities(repository_id); CREATE INDEX IF NOT EXISTS idx_features_repository ON approved_features(repository_id); diff --git a/src/repo_registry/core/models.py b/src/repo_registry/core/models.py index c335280..77b2afc 100644 --- a/src/repo_registry/core/models.py +++ b/src/repo_registry/core/models.py @@ -210,6 +210,15 @@ class Evidence: reference_id: int | None = None +@dataclass(frozen=True) +class Scope: + id: int + name: str + description: str + confidence: float + confidence_label: str = "" + + @dataclass(frozen=True) class Feature: id: int @@ -247,6 +256,7 @@ class Ability: @dataclass(frozen=True) class RepositoryAbilityMap: repository: Repository + scope: Scope abilities: list[Ability] diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index b3dacfc..720ff14 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -1214,6 +1214,22 @@ class RegistryService: self.store.delete_evidence(repository_id, evidence_id) return self.store.get_ability_map(repository_id) + def update_scope( + self, + repository_id: int, + *, + name: str | None = None, + description: str | None = None, + confidence: float | None = None, + ) -> RepositoryAbilityMap: + self.store.update_scope( + repository_id, + name=name, + description=description, + confidence=confidence, + ) + return self.store.get_ability_map(repository_id) + def ability_map(self, repository_id: int) -> RepositoryAbilityMap: return self.store.get_ability_map(repository_id) diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index a37452e..4dd80ea 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -25,6 +25,7 @@ from repo_registry.core.models import ( RepositorySnapshot, ReviewDecision, SearchResult, + Scope, SourceReference, confidence_label, ) @@ -47,6 +48,7 @@ class RegistryStore: with self.connect() as connection: connection.executescript(migration_path.read_text(encoding="utf-8")) self._ensure_content_chunks_table(connection) + self._ensure_repository_scopes_table(connection) self._ensure_approved_source_ref_columns(connection) self._ensure_evidence_relationship_columns(connection) self._ensure_expectation_gaps_table(connection) @@ -130,6 +132,23 @@ class RegistryStore: "CREATE INDEX IF NOT EXISTS idx_content_chunks_run ON content_chunks(analysis_run_id)" ) + def _ensure_repository_scopes_table(self, connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS repository_scopes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repository_id INTEGER NOT NULL UNIQUE REFERENCES repositories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + confidence REAL NOT NULL DEFAULT 1.0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS idx_scopes_repository ON repository_scopes(repository_id)" + ) + def _ensure_expectation_gaps_table(self, connection: sqlite3.Connection) -> None: connection.execute( """ @@ -170,6 +189,14 @@ class RegistryStore: (name, url, description, branch), ) repository_id = int(cursor.lastrowid) + connection.execute( + """ + INSERT INTO repository_scopes + (repository_id, name, description, confidence) + VALUES (?, ?, ?, 1.0) + """, + (repository_id, name, description or ""), + ) return self.get_repository(repository_id) def update_repository( @@ -1822,6 +1849,81 @@ class RegistryStore: row_id=evidence_id, ) + def _ensure_scope(self, repository_id: int) -> Scope: + repository = self.get_repository(repository_id) + with self.connect() as connection: + row = connection.execute( + """ + SELECT id, name, description, confidence + FROM repository_scopes + WHERE repository_id = ? + """, + (repository_id,), + ).fetchone() + if row is None: + cursor = connection.execute( + """ + INSERT INTO repository_scopes + (repository_id, name, description, confidence) + VALUES (?, ?, ?, 1.0) + """, + (repository_id, repository.name, repository.description or ""), + ) + scope_id = int(cursor.lastrowid) + return Scope( + id=scope_id, + name=repository.name, + description=repository.description or "", + confidence=1.0, + confidence_label=confidence_label(1.0), + ) + return self._scope_from_row(row) + + def _get_scope(self, repository_id: int) -> Scope: + with self.connect() as connection: + row = connection.execute( + """ + SELECT id, name, description, confidence + FROM repository_scopes + WHERE repository_id = ? + """, + (repository_id,), + ).fetchone() + if row is None: + return self._ensure_scope(repository_id) + return self._scope_from_row(row) + + def _scope_from_row(self, row: sqlite3.Row) -> Scope: + return Scope( + id=row["id"], + name=row["name"], + description=row["description"], + confidence=row["confidence"], + confidence_label=confidence_label(row["confidence"]), + ) + + def update_scope( + self, + repository_id: int, + *, + name: str | None = None, + description: str | None = None, + confidence: float | None = None, + ) -> Scope: + self._ensure_scope(repository_id) + self._update_approved_row( + table="repository_scopes", + label="scope", + repository_id=repository_id, + row_id=self._get_scope(repository_id).id, + values={ + "name": name, + "description": description, + "confidence": confidence, + }, + ) + return self._get_scope(repository_id) + def replace_approved_from_candidate_graph( self, repository_id: int, @@ -1922,6 +2024,7 @@ class RegistryStore: def get_ability_map(self, repository_id: int) -> RepositoryAbilityMap: repository = self.get_repository(repository_id) + scope = self._ensure_scope(repository_id) with self.connect() as connection: ability_rows = connection.execute( """ @@ -2018,7 +2121,7 @@ class RegistryStore: ) for row in ability_rows ] - return RepositoryAbilityMap(repository=repository, abilities=abilities) + return RepositoryAbilityMap(repository=repository, scope=scope, abilities=abilities) def search( self, @@ -2370,7 +2473,7 @@ class RegistryStore: label: str, repository_id: int, row_id: int, - values: dict[str, str | float | None], + values: dict[str, str | float | int | None], ) -> None: assignments: list[str] = [] params: list[str | float | int] = [] diff --git a/src/repo_registry/web_api/schemas.py b/src/repo_registry/web_api/schemas.py index ec40ca0..792775d 100644 --- a/src/repo_registry/web_api/schemas.py +++ b/src/repo_registry/web_api/schemas.py @@ -716,8 +716,17 @@ class AbilityResponse(BaseModel): capabilities: list[CapabilityResponse] +class ScopeResponse(BaseModel): + id: int + name: str + description: str + confidence: float + confidence_label: str + + class RepositoryAbilityMapResponse(BaseModel): repository: RepositoryResponse + scope: ScopeResponse abilities: list[AbilityResponse] model_config = { @@ -725,6 +734,13 @@ class RepositoryAbilityMapResponse(BaseModel): "examples": [ { "repository": REPOSITORY_EXAMPLE, + "scope": { + "id": 1, + "name": "MailRouter", + "description": "Scope root for the MailRouter repository.", + "confidence": 1.0, + "confidence_label": "high", + }, "abilities": [ { "id": 1, diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index f692e84..c554c90 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -647,6 +647,23 @@ def edit_repository_from_form( return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) +@router.post("/ui/repos/{repository_id}/scope/edit") +def edit_scope_from_form( + repository_id: int, + name: str = Form(...), + description: str = Form(""), + confidence: float = Form(1.0), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.update_scope( + repository_id, + name=name, + description=description, + confidence=confidence, + ) + return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) + + @router.post("/ui/repos/{repository_id}/delete") def delete_repository_from_form( repository_id: int, @@ -2697,11 +2714,9 @@ def render_candidate_merge_form( def render_ability_map(ability_map: dict, repository_id: int) -> str: abilities = ability_map.get("abilities", []) - if not abilities: - return '
No approved entries yet.
' - repository = ability_map["repository"] - scope_description = repository.get("description") or ( - f"Scope root for the approved characteristics of {repository['name']}." + scope = ability_map["scope"] + scope_description = scope.get("description") or ( + f"Scope root for the approved characteristics of {scope['name']}." ) items = [] for ability in abilities: @@ -2745,17 +2760,32 @@ def render_ability_map(ability_map: dict, repository_id: int) -> str: return f"""{escape(scope_description)}
-