diff --git a/apps/backend/app/api/routes/repositories.py b/apps/backend/app/api/routes/repositories.py index f8645552..0865841d 100644 --- a/apps/backend/app/api/routes/repositories.py +++ b/apps/backend/app/api/routes/repositories.py @@ -9,6 +9,7 @@ RepositoryFileResponse, RepositoryLineageResponse, RepositoryListResponse, + RepositoryReanalysisResponse, RepositoryResponse, ) from app.services.repository_service import RepositoryService @@ -43,6 +44,13 @@ "meta": None, "fileTree": [], } +_REPOSITORY_REANALYSIS_EXAMPLE = { + "outcome": "already-current", + "repository": _REPOSITORY_EXAMPLE, + "remoteHead": "0123456789abcdef0123456789abcdef01234567", + "previousRepositoryId": None, +} + _REPOSITORY_LINEAGE_EXAMPLE = { "isLineaged": True, "lineageId": "22222222-2222-2222-2222-222222222222", @@ -204,6 +212,32 @@ def get_repository_lineage( return service.get_lineage(repository_id) +@router.post( + "/{repository_id}/reanalyse", + response_model=RepositoryReanalysisResponse, + responses=documented_responses( + status.HTTP_200_OK, + "Branch head compared against the lineage's latest revision. `already-current` means the head still " + "names the sealed revision and nothing was imported; `revision-imported` means a new revision was " + "added to the same lineage and is being analysed.", + _REPOSITORY_REANALYSIS_EXAMPLE, + 401, + 404, + 409, + 429, + 502, + 504, + 500, + ), + openapi_extra=suppress_automatic_validation_error(), +) +def reanalyse_repository( + repository_id: str, + service: RepositoryService = Depends(get_repository_service), +) -> RepositoryReanalysisResponse: + return service.reanalyse_repository(repository_id) + + @router.get( "/{repository_id}/file", response_model=RepositoryFileResponse, diff --git a/apps/backend/app/github/client.py b/apps/backend/app/github/client.py index d0c3988c..527c3e8a 100644 --- a/apps/backend/app/github/client.py +++ b/apps/backend/app/github/client.py @@ -111,6 +111,59 @@ def read_head_ref(self, repo_dir: Path, requested_ref: str | None = None) -> str ref = result.stdout.strip() return ref if ref.startswith("refs/") else None + def read_remote_head_commit(self, url: str, branch: str | None = None) -> str | None: + """Resolve a branch head over the network without cloning (#448). + + Re-analysis asks "has this moved?", and for a repository that has not + moved -- the common answer -- a full clone is a download, a parse and a + directory of disk to learn one SHA. ``ls-remote`` answers the same + question in one round trip and touches no storage. + + ``url`` and ``branch`` must already be through ``validate_public_url`` + and ``validate_branch``: both are passed to git as arguments, and the + branch is additionally wrapped as a full ``refs/heads/`` ref so a name + can never be read as a flag or a wildcard. + """ + + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + ref = f"refs/heads/{branch}" if branch else "HEAD" + try: + result = subprocess.run( + ["git", "ls-remote", "--exit-code", "--", url, ref], + check=True, + capture_output=True, + text=True, + timeout=self.timeout_seconds, + env=env, + ) + except subprocess.TimeoutExpired as exc: + raise TimeoutServiceError( + "Resolving the GitHub branch head timed out.", + {"timeoutSeconds": self.timeout_seconds}, + ) from exc + except (subprocess.CalledProcessError, OSError) as exc: + return_code = exc.returncode if isinstance(exc, subprocess.CalledProcessError) else None + # As with clone, raw stderr is not logged: it can echo the URL and + # whatever the remote chose to say back. + logger.warning( + "git ls-remote failed for public repository (error_type=%s, return_code=%s).", + type(exc).__name__, + return_code, + ) + raise ExternalServiceError( + "Failed to reach the GitHub repository. Confirm it is still public and the branch still exists.", + ) from exc + + first_line = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" + sha = first_line.split("\t", 1)[0].strip() if first_line else "" + # A short or non-hex answer means the remote said something this method + # does not understand; reporting None keeps the caller from comparing a + # malformed value against a sealed revision. + if len(sha) != 40 or not all(character in "0123456789abcdef" for character in sha): + return None + return sha + def clone_public_repository(self, url: str, destination: Path, branch: str | None = None) -> None: destination.parent.mkdir(parents=True, exist_ok=True) env = os.environ.copy() diff --git a/apps/backend/app/schemas/repository.py b/apps/backend/app/schemas/repository.py index 20afe322..2c28bf6e 100644 --- a/apps/backend/app/schemas/repository.py +++ b/apps/backend/app/schemas/repository.py @@ -141,3 +141,29 @@ class RepositoryLineageResponse(CamelModel): canonical_source_key: str | None = None canonical_branch: str | None = None entries: list[RepositoryLineageEntry] + + +RepositoryReanalysisOutcome = Literal["already-current", "revision-imported"] + + +class RepositoryReanalysisResponse(CamelModel): + """The answer to "has this repository moved?" (#448). + + ``already-current`` is a state, not a failure: the branch head still names + the revision that is already sealed, so nothing was cloned and nothing was + imported. ``repository`` is the lineage's latest revision either way -- + the newly imported one when the branch had moved, the existing one when it + had not -- so a caller can render the result without a second request. + + ``remote_head`` is the commit the branch points at right now. On + ``already-current`` it equals the sealed revision by definition; it is + still returned so the client can show what was checked rather than asking + the reader to trust that something was. + """ + + outcome: RepositoryReanalysisOutcome + repository: RepositoryResponse + remote_head: str + #: The revision that was current before this call, present only when a new + #: one was imported -- it is what a two-revision diff (#219) compares from. + previous_repository_id: str | None = None diff --git a/apps/backend/app/services/repository_service.py b/apps/backend/app/services/repository_service.py index 870e120b..cefe853e 100644 --- a/apps/backend/app/services/repository_service.py +++ b/apps/backend/app/services/repository_service.py @@ -9,7 +9,13 @@ from fastapi import UploadFile from app.core.config import Settings -from app.core.exceptions import ConflictServiceError, NotFoundError, ServiceError, ValidationServiceError +from app.core.exceptions import ( + ConflictServiceError, + ExternalServiceError, + NotFoundError, + ServiceError, + ValidationServiceError, +) from app.github.client import GitHubClient from app.models.repository import RepositoryRecord from app.parsers.repository_parser import RepositoryFileLimitExceeded, RepositoryParser @@ -22,6 +28,7 @@ RepositoryLineageResponse, RepositoryListResponse, RepositoryMeta, + RepositoryReanalysisResponse, RepositoryResponse, RepositoryRevision, ) @@ -125,10 +132,20 @@ def delete_repository(self, repository_id: str) -> None: self.storage.delete_repository(local_path) def import_github_repository(self, request: GitHubImportRequest) -> RepositoryResponse: - repository_id = str(uuid4()) url = self.github.validate_public_url(str(request.url)) branch = self.github.validate_branch(request.branch) + return self._import_github_revision(url, branch) + + def _import_github_revision(self, url: str, branch: str | None) -> RepositoryResponse: + """Clone, parse and seal one revision of an already-validated GitHub URL. + Shared by the first import and by re-analysis (#448) so a second + revision is produced by exactly the same path as the first -- the + lineage, the duplicate check and the sealed snapshot all behave + identically whichever entry point asked for it. + """ + + repository_id = str(uuid4()) # A new commit is a new revision, so duplicate detection is keyed on the # resolved commit SHA rather than URL+branch (#87). That requires cloning # first: URL+branch is only a fallback when git identity is unavailable, @@ -197,6 +214,65 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe raise return self.to_response(persisted) + def reanalyse_repository(self, repository_id: str) -> RepositoryReanalysisResponse: + """Bring a lineaged GitHub repository up to its branch head (#448). + + The capability was already there -- re-importing at a new commit has + always allocated a new revision in the same lineage (#298/#299/#400). + What was missing was any way to ask for it: the only route to a second + revision was retyping the URL, and if the branch had not moved the + answer came back as "Repository has already been imported", which + reads as a wall rather than as "you are already current". + + So an unmoved branch is reported as a state, not an error. A moved one + goes through exactly the same import path as the first revision. + """ + + record = self._get_record(repository_id) + if record.source != "github" or not record.source_url: + raise ConflictServiceError( + "Only repositories imported from GitHub can be re-analysed. An upload has no upstream to check.", + {"repositoryId": record.id, "source": record.source}, + ) + latest = self._latest_in_lineage(record) + url = self.github.validate_public_url(record.source_url) + branch = self.github.validate_branch(record.branch) + + remote_head = self.github.read_remote_head_commit(url, branch) + if remote_head is None: + raise ExternalServiceError( + "GitHub did not return a commit for this branch, so there is nothing to compare against.", + {"repositoryId": record.id}, + ) + if latest.revision_kind == "git" and latest.revision_value == remote_head: + return RepositoryReanalysisResponse( + outcome="already-current", + repository=self.to_response(latest), + remote_head=remote_head, + ) + return RepositoryReanalysisResponse( + outcome="revision-imported", + repository=self._import_github_revision(url, branch), + remote_head=remote_head, + previous_repository_id=latest.id, + ) + + def _latest_in_lineage(self, record: RepositoryRecord) -> RepositoryRecord: + """The newest revision of `record`'s lineage, or `record` itself. + + A repository viewed at an older revision must still re-analyse against + the head of its own history, not against the revision the reader + happens to be looking at -- otherwise opening revision 1 of a + three-revision lineage would report the branch as moved and import a + fourth copy of something already sealed. + """ + + if record.lineage_id is None: + return record + # `list_lineage_members` orders most-recent-first and is owner-scoped. + members = self.repository.list_lineage_members(record.lineage_id, self.owner_id) + return members[0] if members else record + def _canonical_github_source(self, url: str) -> str: """Owner-scoped lineage grouping key for an already-validated live URL. diff --git a/apps/backend/tests/test_openapi_contract.py b/apps/backend/tests/test_openapi_contract.py index 1cfda8b4..8f8fe340 100644 --- a/apps/backend/tests/test_openapi_contract.py +++ b/apps/backend/tests/test_openapi_contract.py @@ -57,6 +57,7 @@ ("GET", "/repositories/{repository_id}"): {200, 401, 404, 429, 500}, ("GET", "/repositories/{repository_id}/file"): {200, 401, 404, 422, 429, 500}, ("GET", "/repositories/{repository_id}/lineage"): {200, 401, 404, 429, 500}, + ("POST", "/repositories/{repository_id}/reanalyse"): {200, 401, 404, 409, 429, 500, 502, 504}, ("DELETE", "/repositories/{repository_id}"): {204, 401, 404, 429, 500}, ("GET", "/intelligence/v1/snapshots/{snapshot_id}"): {200, 401, 404, 422, 429, 500}, ("GET", "/intelligence/v1/snapshots/{snapshot_id}/symbols"): {200, 401, 404, 422, 429, 500}, diff --git a/apps/backend/tests/test_repository_reanalysis_api.py b/apps/backend/tests/test_repository_reanalysis_api.py new file mode 100644 index 00000000..90a2bc67 --- /dev/null +++ b/apps/backend/tests/test_repository_reanalysis_api.py @@ -0,0 +1,195 @@ +"""HTTP-level coverage for `POST /repositories/{id}/reanalyse` (#448). + +Lineage allocation is already covered by test_repository_lineage_service.py +and test_repository_lineage_concurrency.py; this file is scoped to the new +affordance: what happens when the branch has moved, when it has not, when the +repository has no upstream at all, and that it stays owner-scoped. + +The point of the feature is that "nothing has changed" is a state rather than +the `409 Repository has already been imported` a manual re-import used to +return, so the unmoved case asserts a 200 as deliberately as the moved one. +""" + +import uuid +from pathlib import Path + +import pytest + +from app.github.client import GitHubClient + + +def _fake_clone(_: GitHubClient, __: str, destination: Path, ___: str | None = None) -> None: + destination.mkdir(parents=True, exist_ok=True) + (destination / "README.md").write_text("# demo\n", encoding="utf-8") + + +def _mock_github( + monkeypatch: pytest.MonkeyPatch, + commits: list[str], + *, + remote_head: str, + ref: str = "refs/heads/main", +) -> None: + """Clone the same tree every time, but hand out the given commit identities. + + `commits` is consumed one per import; `remote_head` is what `ls-remote` + reports, which is the value re-analysis compares against. + """ + + commit_iter = iter(commits) + monkeypatch.setattr(GitHubClient, "clone_public_repository", _fake_clone) + monkeypatch.setattr(GitHubClient, "read_head_commit", lambda *_: next(commit_iter)) + monkeypatch.setattr(GitHubClient, "read_head_ref", lambda *_: ref) + monkeypatch.setattr(GitHubClient, "read_remote_head_commit", lambda *_, **__: remote_head) + + +def _seed_upload(owner_id: str, name: str = "standalone-repo") -> str: + from app.core.database import SessionLocal + from app.models.repository import RepositoryRecord + + db = SessionLocal() + try: + repository_id = str(uuid.uuid4()) + db.add( + RepositoryRecord( + id=repository_id, + owner_id=owner_id, + name=name, + source="upload", + local_path=f"/tmp/{repository_id}", + status="completed", + ) + ) + db.commit() + return repository_id + finally: + db.close() + + +def test_an_unmoved_branch_reports_the_current_revision_rather_than_a_conflict( + auth_client, monkeypatch: pytest.MonkeyPatch +): + _mock_github(monkeypatch, ["a" * 40], remote_head="a" * 40) + imported = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + assert imported.status_code == 201 + + response = auth_client.post(f"/repositories/{imported.json()['id']}/reanalyse") + + assert response.status_code == 200, response.text + body = response.json() + assert body["outcome"] == "already-current" + assert body["remoteHead"] == "a" * 40 + assert body["previousRepositoryId"] is None + # The repository returned is the one already sealed, so a caller can render + # "you are current at " without a second request. + assert body["repository"]["id"] == imported.json()["id"] + assert body["repository"]["revision"]["value"] == "a" * 40 + + # Nothing was imported: the lineage still holds exactly one revision. + lineage = auth_client.get(f"/repositories/{imported.json()['id']}/lineage").json() + assert [entry["sequence"] for entry in lineage["entries"]] == [1] + + +def test_a_moved_branch_seals_a_new_revision_in_the_same_lineage(auth_client, monkeypatch: pytest.MonkeyPatch): + _mock_github(monkeypatch, ["a" * 40, "b" * 40], remote_head="b" * 40) + first = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + assert first.status_code == 201 + + response = auth_client.post(f"/repositories/{first.json()['id']}/reanalyse") + + assert response.status_code == 200, response.text + body = response.json() + assert body["outcome"] == "revision-imported" + assert body["remoteHead"] == "b" * 40 + assert body["previousRepositoryId"] == first.json()["id"] + assert body["repository"]["id"] != first.json()["id"] + assert body["repository"]["revision"]["value"] == "b" * 40 + + # Same lineage, new sequence, latest pointer advanced -- the history the + # two-revision work (#219) needs, produced without retyping a URL. + lineage = auth_client.get(f"/repositories/{body['repository']['id']}/lineage").json() + assert lineage["isLineaged"] is True + assert [entry["sequence"] for entry in lineage["entries"]] == [2, 1] + assert [entry["repositoryId"] for entry in lineage["entries"]] == [ + body["repository"]["id"], + first.json()["id"], + ] + + +def test_reanalysing_an_older_member_still_compares_against_the_lineage_head( + auth_client, monkeypatch: pytest.MonkeyPatch +): + """Opening revision 1 of a two-revision lineage and asking to re-analyse + must not report the branch as moved and import a third copy of something + already sealed: the comparison is against the lineage's head, not against + whichever revision the reader happens to be looking at.""" + + _mock_github(monkeypatch, ["a" * 40, "b" * 40], remote_head="b" * 40) + first = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + second = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + assert first.status_code == second.status_code == 201 + + response = auth_client.post(f"/repositories/{first.json()['id']}/reanalyse") + + assert response.status_code == 200, response.text + body = response.json() + assert body["outcome"] == "already-current" + assert body["repository"]["id"] == second.json()["id"] + + lineage = auth_client.get(f"/repositories/{second.json()['id']}/lineage").json() + assert [entry["sequence"] for entry in lineage["entries"]] == [2, 1] + + +def test_an_upload_has_no_upstream_and_says_so(auth_client): + repository_id = _seed_upload(auth_client.default_user["id"]) + + response = auth_client.post(f"/repositories/{repository_id}/reanalyse") + + assert response.status_code == 409, response.text + # A refusal a reader can act on: it names why, not just that. + body = response.json() + assert body["code"] == "conflict_error" + assert "upload" in body["message"].lower() + assert body["details"]["source"] == "upload" + + +def test_reanalyse_returns_404_for_another_owners_repository( + client, make_auth_headers, monkeypatch: pytest.MonkeyPatch +): + _mock_github(monkeypatch, ["a" * 40], remote_head="a" * 40) + alice = make_auth_headers("alice@example.com") + bob = make_auth_headers("bob@example.com") + imported = client.post( + "/repositories/github", + json={"url": "https://github.com/acme/widgets"}, + headers=alice["headers"], + ) + assert imported.status_code == 201 + repository_id = imported.json()["id"] + + # 404 rather than 403: a cross-owner request never learns the id exists. + denied = client.post(f"/repositories/{repository_id}/reanalyse", headers=bob["headers"]) + assert denied.status_code == 404 + + allowed = client.post(f"/repositories/{repository_id}/reanalyse", headers=alice["headers"]) + assert allowed.status_code == 200 + + +def test_reanalyse_returns_404_for_a_nonexistent_repository(auth_client): + response = auth_client.post(f"/repositories/{uuid.uuid4()}/reanalyse") + assert response.status_code == 404 + + +def test_an_unreadable_branch_head_is_reported_rather_than_guessed(auth_client, monkeypatch: pytest.MonkeyPatch): + """A remote that answers with nothing this client understands must not be + silently treated as "unchanged" -- that would report a repository as + current on the strength of a failed lookup.""" + + _mock_github(monkeypatch, ["a" * 40], remote_head="a" * 40) + imported = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + assert imported.status_code == 201 + monkeypatch.setattr(GitHubClient, "read_remote_head_commit", lambda *_, **__: None) + + response = auth_client.post(f"/repositories/{imported.json()['id']}/reanalyse") + + assert response.status_code == 502, response.text diff --git a/apps/frontend/src/features/repositories/components/RepositoryLineageHistory.test.tsx b/apps/frontend/src/features/repositories/components/RepositoryLineageHistory.test.tsx index a9565f75..4c65ea32 100644 --- a/apps/frontend/src/features/repositories/components/RepositoryLineageHistory.test.tsx +++ b/apps/frontend/src/features/repositories/components/RepositoryLineageHistory.test.tsx @@ -86,6 +86,77 @@ describe('RepositoryLineageHistory', () => { expect(link).toHaveAttribute('href', '/repositories/repo-1'); }); + it('offers to check for a new revision on a lineage, and not on a standalone import', async () => { + const fetchLineage = vi.spyOn(backendService, 'fetchRepositoryLineage').mockResolvedValue(lineaged); + + const { unmount } = render( + + + , + ); + + expect(await screen.findByRole('button', { name: /check for a new revision/i })).toBeInTheDocument(); + + // A standalone import has no upstream to poll, so the action is absent + // rather than present and refusing (#448). + unmount(); + fetchLineage.mockResolvedValue(standalone); + render( + + + , + ); + + expect(await screen.findByText(/standalone import/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /check for a new revision/i })).not.toBeInTheDocument(); + }); + + it('reports an unmoved branch as a state, naming the commit it checked', async () => { + vi.spyOn(backendService, 'fetchRepositoryLineage').mockResolvedValue(lineaged); + vi.spyOn(backendService, 'reanalyseRepository').mockResolvedValue({ + outcome: 'already-current', + repository: { id: 'repo-2' } as never, + remoteHead: 'b'.repeat(40), + previousRepositoryId: null, + }); + + render( + + + , + ); + + (await screen.findByRole('button', { name: /check for a new revision/i })).click(); + + // Neutral wording and no error role: being current is not a failure. + expect(await screen.findByText(/nothing new to analyse/i)).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('re-reads the history in place when a new revision is imported', async () => { + const fetchLineage = vi.spyOn(backendService, 'fetchRepositoryLineage').mockResolvedValue(lineaged); + vi.spyOn(backendService, 'reanalyseRepository').mockResolvedValue({ + outcome: 'revision-imported', + repository: { id: 'repo-3' } as never, + remoteHead: 'c'.repeat(40), + previousRepositoryId: 'repo-2', + }); + + render( + + + , + ); + + await screen.findByText('#2'); + expect(fetchLineage).toHaveBeenCalledTimes(1); + + (await screen.findByRole('button', { name: /check for a new revision/i })).click(); + + // No reload: the panel asks for the history again itself. + await waitFor(() => expect(fetchLineage).toHaveBeenCalledTimes(2)); + }); + it('renders an honest error state with a retry action on failure', async () => { vi.spyOn(backendService, 'fetchRepositoryLineage').mockRejectedValue(new Error('network down')); diff --git a/apps/frontend/src/features/repositories/components/RepositoryLineageHistory.tsx b/apps/frontend/src/features/repositories/components/RepositoryLineageHistory.tsx index 67317804..73fae399 100644 --- a/apps/frontend/src/features/repositories/components/RepositoryLineageHistory.tsx +++ b/apps/frontend/src/features/repositories/components/RepositoryLineageHistory.tsx @@ -4,6 +4,7 @@ import { Badge } from '@/shared/components/ui/Badge'; import { EmptyState } from '@/shared/components/ui/EmptyState'; import { repositoryStatusVariant } from '@/features/repositories/status'; import { useRepositoryLineage } from '@/features/repositories/hooks/useRepositoryLineage'; +import { RepositoryReanalyseButton } from '@/features/repositories/components/RepositoryReanalyseButton'; import { cn } from '@/shared/utils/cn'; interface RepositoryLineageHistoryProps { @@ -11,7 +12,7 @@ interface RepositoryLineageHistoryProps { } export function RepositoryLineageHistory({ repositoryId }: RepositoryLineageHistoryProps) { - const { entries, isLineaged, loading, error, retry } = useRepositoryLineage(repositoryId); + const { entries, isLineaged, loading, error, retry, refresh } = useRepositoryLineage(repositoryId); if (loading) { return ( @@ -34,7 +35,12 @@ export function RepositoryLineageHistory({ repositoryId }: RepositoryLineageHist return (
- {!isLineaged && ( + {isLineaged ? ( + // Only a lineage has an upstream to check. A standalone import has + // nothing to poll, so the action is absent rather than present and + // failing -- an affordance that refuses is worse than no affordance. + + ) : (

This is a standalone import — repeated GitHub imports of the same repository and branch are grouped into a shared history; a one-off upload or an unresolved-ref import never gets one. diff --git a/apps/frontend/src/features/repositories/components/RepositoryReanalyseButton.tsx b/apps/frontend/src/features/repositories/components/RepositoryReanalyseButton.tsx new file mode 100644 index 00000000..5a4fec46 --- /dev/null +++ b/apps/frontend/src/features/repositories/components/RepositoryReanalyseButton.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Loader2, RefreshCw } from 'lucide-react'; +import { backendService } from '@/shared/services/backend'; +import { getErrorMessage } from '@/shared/services/api'; + +interface RepositoryReanalyseButtonProps { + repositoryId: string; + /** Re-read the history in place once a new revision exists (#448). */ + onRevisionImported: () => void; +} + +/* Bring a lineaged GitHub repository up to its branch head (#448). + * + * "Nothing has changed" is reported here as a state, not as a failure: before + * this existed, the only way to ask was to retype the URL, and an unmoved + * branch answered "Repository has already been imported", which reads as a + * wall rather than as "you are already current". So the settled case gets a + * plain, neutral sentence and the moved case navigates to what it found. */ +export function RepositoryReanalyseButton({ repositoryId, onRevisionImported }: RepositoryReanalyseButtonProps) { + const navigate = useNavigate(); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const run = async () => { + setBusy(true); + setMessage(null); + setError(null); + try { + const result = await backendService.reanalyseRepository(repositoryId); + if (result.outcome === 'already-current') { + // The checked commit is named rather than implied: "up to date" with + // nothing behind it asks the reader to take the check on trust. + setMessage(`Already at ${result.remoteHead.slice(0, 12)} — nothing new to analyse.`); + return; + } + onRevisionImported(); + navigate(`/repositories/${result.repository.id}`); + } catch (caught) { + setError(getErrorMessage(caught)); + } finally { + setBusy(false); + } + }; + + return ( +

+ + {/* Polite, not assertive: the outcome is information, and the settled + case is the common one -- it should not interrupt a screen reader. */} +

+ {message} +

+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/frontend/src/shared/services/api/generated.ts b/apps/frontend/src/shared/services/api/generated.ts index 167109b2..5ce4e706 100644 --- a/apps/frontend/src/shared/services/api/generated.ts +++ b/apps/frontend/src/shared/services/api/generated.ts @@ -791,6 +791,23 @@ export interface paths { patch?: never; trace?: never; }; + "/repositories/{repository_id}/reanalyse": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reanalyse Repository */ + post: operations["reanalyse_repository_repositories__repository_id__reanalyse_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/repositories/github": { parameters: { query?: never; @@ -2044,6 +2061,33 @@ export interface components { /** Totalfolders */ totalFolders: number; }; + /** + * RepositoryReanalysisResponse + * @description The answer to "has this repository moved?" (#448). + * + * ``already-current`` is a state, not a failure: the branch head still names + * the revision that is already sealed, so nothing was cloned and nothing was + * imported. ``repository`` is the lineage's latest revision either way -- + * the newly imported one when the branch had moved, the existing one when it + * had not -- so a caller can render the result without a second request. + * + * ``remote_head`` is the commit the branch points at right now. On + * ``already-current`` it equals the sealed revision by definition; it is + * still returned so the client can show what was checked rather than asking + * the reader to trust that something was. + */ + RepositoryReanalysisResponse: { + /** + * Outcome + * @enum {string} + */ + outcome: "already-current" | "revision-imported"; + /** Previousrepositoryid */ + previousRepositoryId?: string | null; + /** Remotehead */ + remoteHead: string; + repository: components["schemas"]["RepositoryResponse"]; + }; /** RepositoryResponse */ RepositoryResponse: { /** Analysedat */ @@ -8248,6 +8292,182 @@ export interface operations { }; }; }; + reanalyse_repository_repositories__repository_id__reanalyse_post: { + parameters: { + query?: never; + header?: never; + path: { + repository_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Branch head compared against the lineage's latest revision. `already-current` means the head still names the sealed revision and nothing was imported; `revision-imported` means a new revision was added to the same lineage and is being analysed. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "outcome": "already-current", + * "repository": { + * "id": "11111111-1111-1111-1111-111111111111", + * "name": "example-service", + * "source": "github", + * "sourceUrl": "https://github.com/example/example-service", + * "branch": "main", + * "size": 2048, + * "fileCount": 12, + * "status": "completed", + * "analysisStage": "completed", + * "analysisProgress": 100, + * "uploadedAt": "2026-07-17T00:00:00Z", + * "analysedAt": "2026-07-17T00:00:02Z", + * "revision": { + * "kind": "git", + * "value": "0123456789abcdef0123456789abcdef01234567", + * "ref": "refs/heads/main" + * }, + * "commitSha": "0123456789abcdef0123456789abcdef01234567", + * "fileTree": [] + * }, + * "remoteHead": "0123456789abcdef0123456789abcdef01234567" + * } + */ + "application/json": components["schemas"]["RepositoryReanalysisResponse"]; + }; + }; + /** @description Authentication is required or the access token is invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "unauthorized", + * "message": "Not authenticated.", + * "request_id": "req_01HXYZEXAMPLE" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The requested resource does not exist or is not accessible to this user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "not_found", + * "message": "Repository not found.", + * "details": { + * "repositoryId": "11111111-1111-1111-1111-111111111111" + * }, + * "request_id": "req_01HXYZEXAMPLE" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The request conflicts with existing state. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "conflict_error", + * "message": "Repository has already been imported.", + * "details": { + * "repositoryId": "11111111-1111-1111-1111-111111111111" + * }, + * "request_id": "req_01HXYZEXAMPLE" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The request-rate limit has been exceeded. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "rate_limited", + * "message": "Too many requests. Try again shortly.", + * "details": { + * "retryAfterSeconds": 30 + * }, + * "request_id": "req_01HXYZEXAMPLE" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description An unexpected server error occurred. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "internal_server_error", + * "message": "An unexpected error occurred.", + * "request_id": "req_01HXYZEXAMPLE" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description An upstream service could not complete the request. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "external_service_error", + * "message": "AI provider request failed.", + * "details": { + * "provider": "openai" + * }, + * "request_id": "req_01HXYZEXAMPLE" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description An upstream service did not respond before the timeout. */ + 504: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "timeout_error", + * "message": "GitHub repository clone timed out.", + * "details": { + * "timeoutSeconds": 120 + * }, + * "request_id": "req_01HXYZEXAMPLE" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; import_github_repository_repositories_github_post: { parameters: { query?: never; diff --git a/apps/frontend/src/shared/services/api/repositories.ts b/apps/frontend/src/shared/services/api/repositories.ts index d76a3472..161251fa 100644 --- a/apps/frontend/src/shared/services/api/repositories.ts +++ b/apps/frontend/src/shared/services/api/repositories.ts @@ -6,6 +6,7 @@ import type { RepositoryListResponse, RepositoryFileResponse, RepositoryLineageResponse, + RepositoryReanalysisResponse, ImportGithubRequest, RiNeighboursResponse, RiSnapshotMetadata, @@ -24,6 +25,10 @@ export const repositoryService = { return api.get(`/repositories/${id}/lineage`, config); }, + reanalyse(id: string, config?: RequestConfig): Promise { + return api.post(`/repositories/${id}/reanalyse`, undefined, config); + }, + getFile(id: string, path: string, config?: RequestConfig): Promise { return api.get(`/repositories/${id}/file?path=${encodeURIComponent(path)}`, config); }, diff --git a/apps/frontend/src/shared/services/api/types.ts b/apps/frontend/src/shared/services/api/types.ts index 236f246c..bc820758 100644 --- a/apps/frontend/src/shared/services/api/types.ts +++ b/apps/frontend/src/shared/services/api/types.ts @@ -31,6 +31,7 @@ export type ImportGithubRequest = components['schemas']['GitHubImportRequest']; export type RepositoryListResponse = components['schemas']['RepositoryListResponse']; export type RepositoryFileResponse = components['schemas']['RepositoryFileResponse']; export type RepositoryLineageResponse = components['schemas']['RepositoryLineageResponse']; +export type RepositoryReanalysisResponse = components['schemas']['RepositoryReanalysisResponse']; export type RepositoryLineageEntry = components['schemas']['RepositoryLineageEntry']; export type RiSchemaVersion = components['schemas']['RiEvidenceResponse']['schemaVersion']; diff --git a/apps/frontend/src/shared/services/backend.ts b/apps/frontend/src/shared/services/backend.ts index c854e7fa..4c2e178b 100644 --- a/apps/frontend/src/shared/services/backend.ts +++ b/apps/frontend/src/shared/services/backend.ts @@ -8,6 +8,7 @@ import type { AuthenticationExplanationResponse, DependencyGraphResponse, RepositoryLineageResponse, + RepositoryReanalysisResponse, RepositoryResponse, } from './api/types'; import { repositoryService } from './api/repositories'; @@ -43,6 +44,13 @@ export const backendService = { return repositoryService.getLineage(id); }, + async reanalyseRepository(id: string): Promise { + if (!USE_BACKEND) { + throw new Error('Backend API is not configured.'); + } + return repositoryService.reanalyse(id); + }, + async uploadRepository( file: File, fields?: { name?: string; description?: string },