Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/backend/app/api/routes/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
RepositoryFileResponse,
RepositoryLineageResponse,
RepositoryListResponse,
RepositoryReanalysisResponse,
RepositoryResponse,
)
from app.services.repository_service import RepositoryService
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions apps/backend/app/github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
26 changes: 26 additions & 0 deletions apps/backend/app/schemas/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
80 changes: 78 additions & 2 deletions apps/backend/app/services/repository_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +28,7 @@
RepositoryLineageResponse,
RepositoryListResponse,
RepositoryMeta,
RepositoryReanalysisResponse,
RepositoryResponse,
RepositoryRevision,
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions apps/backend/tests/test_openapi_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
Loading
Loading