From 60101301f43c904e8dcf59fd3414eab84e48823b Mon Sep 17 00:00:00 2001 From: PARTH J ROHIT Date: Fri, 11 Sep 2026 18:39:33 +0100 Subject: [PATCH] feat: re-analyse a repository at its latest revision There was no way to bring an imported repository up to date. The only route to a second revision was retyping the GitHub URL, and if the branch had not moved the answer was "Repository has already been imported" -- a conflict, which reads as a wall rather than as "you are already current". The capability was already there. Re-importing at a new commit has always allocated a new revision in the same lineage (#298/#299/#400): the duplicate check is per-commit, not per-repository. What was missing was any way to ask for it. This is wiring, not engine work. POST /repositories/{id}/reanalyse resolves the branch head, compares it with the lineage's head and reports one of two outcomes. An unmoved branch is `already-current` with the revision it checked named, at 200 -- a state, not an error. A moved one imports through exactly the same path as the first revision, so the lineage, the duplicate check and the sealed snapshot behave identically whichever entry point asked. The head is resolved with `ls-remote`, not a clone. "Has this moved?" is usually answered "no", and answering it by downloading and parsing the whole repository to learn one SHA is a poor trade. A remote answer this client cannot read is reported as a failure rather than being treated as unchanged; reporting a repository current on the strength of a failed lookup is worse than saying the lookup failed. Comparison is against the lineage head, not the revision being viewed: opening revision 1 of a three-revision lineage and asking to re-analyse must not import a fourth copy of something already sealed. An upload has no upstream, so it gets neither the action nor a route that would refuse it: the history panel shows the button only for a lineage. A new revision re-reads the history in place. Closes #448 --- apps/backend/app/api/routes/repositories.py | 34 +++ apps/backend/app/github/client.py | 53 +++++ apps/backend/app/schemas/repository.py | 26 +++ .../app/services/repository_service.py | 80 ++++++- apps/backend/tests/test_openapi_contract.py | 1 + .../tests/test_repository_reanalysis_api.py | 195 ++++++++++++++++ .../RepositoryLineageHistory.test.tsx | 71 ++++++ .../components/RepositoryLineageHistory.tsx | 10 +- .../components/RepositoryReanalyseButton.tsx | 74 ++++++ .../src/shared/services/api/generated.ts | 220 ++++++++++++++++++ .../src/shared/services/api/repositories.ts | 5 + .../frontend/src/shared/services/api/types.ts | 1 + apps/frontend/src/shared/services/backend.ts | 8 + 13 files changed, 774 insertions(+), 4 deletions(-) create mode 100644 apps/backend/tests/test_repository_reanalysis_api.py create mode 100644 apps/frontend/src/features/repositories/components/RepositoryReanalyseButton.tsx 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 },