diff --git a/apps/backend/api/v1/projects.py b/apps/backend/api/v1/projects.py index 6bb5ad9..ec885ba 100644 --- a/apps/backend/api/v1/projects.py +++ b/apps/backend/api/v1/projects.py @@ -669,6 +669,18 @@ async def list_project_releases_endpoint( project_id: uuid.UUID, page: int = Query(default=1, ge=1), size: int = Query(default=20, ge=1, le=100), + release: str | None = Query( + default=None, + max_length=100, + description=( + "Optional version label filter (e.g. '4.0'), matched exactly against " + "the scan's ``metadata.release`` with surrounding whitespace trimmed. " + "This is the lookup for \"which snapshot is version 4.0?\": a label " + "identifies at most one live snapshot, so the filtered list carries " + "one row whose ``scan_id`` you then pin on the detail endpoints via " + "``?scan_id=``. An unknown label returns an empty list, not a 404." + ), + ), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> Response: @@ -682,6 +694,7 @@ async def list_project_releases_endpoint( actor=actor, page=page, size=size, + release=release, ) except ProjectError as exc: return _problem_for_project_error(request, exc) diff --git a/apps/backend/services/release_snapshot_service.py b/apps/backend/services/release_snapshot_service.py index f2c593d..22d1c9a 100644 --- a/apps/backend/services/release_snapshot_service.py +++ b/apps/backend/services/release_snapshot_service.py @@ -53,7 +53,7 @@ from typing import Any import structlog -from sqlalchemy import String, case, cast, func, literal, select +from sqlalchemy import String, and_, case, cast, func, literal, select from sqlalchemy.ext.asyncio import AsyncSession from core.authz import assert_team_access @@ -150,6 +150,7 @@ async def _paged_succeeded_scans( project_id: uuid.UUID, page: int, size: int, + release: str | None = None, ) -> tuple[list[Scan], int]: """Return ``(page_of_succeeded_scans, total)`` newest-first. @@ -157,8 +158,18 @@ async def _paged_succeeded_scans( pages and covered by ``ix_scans_project_created_at``) and one count. We load the ORM ``Scan`` rows so we can read ``scan_metadata['release']`` without a second round-trip. + + ``release`` narrows the listing to snapshots carrying that exact version + label. This is how a caller answers "which snapshot is 4.0?" without paging + the project's whole history: since a labelled scan supersedes any earlier + scan claiming the same label, and superseded rows are excluded below, a + label match yields at most one row. The comparison trims both sides — the + stored label is trimmed at write time and the supersede rule compares + trimmed — and it is served by ``ix_scans_project_release_label``, whose key + is the trimmed expression. """ offset = (page - 1) * size + label = release.strip() if release is not None else None # scan-retention: hide superseded snapshots. A superseded scan lost its ref # slot to a newer winner (and carries no release label — retire never @@ -180,6 +191,15 @@ async def _paged_succeeded_scans( .where(cast(Scan.status, String) == "succeeded") .where(Scan.superseded_at.is_(None)) ) + if label: + # Applied to both statements so `total` describes the filtered set — a + # caller paging a label filter must not be told there are 40 matches. + label_match = and_( + func.jsonb_typeof(Scan.scan_metadata["release"]) == "string", + func.btrim(Scan.scan_metadata["release"].astext) == label, + ) + items_stmt = items_stmt.where(label_match) + count_stmt = count_stmt.where(label_match) items_result = await session.execute(items_stmt) count_result = await session.execute(count_stmt) @@ -322,6 +342,7 @@ async def list_release_snapshots( actor: CurrentUser, page: int = 1, size: int = 20, + release: str | None = None, ) -> tuple[list[dict[str, Any]], int]: """List a project's release snapshots (succeeded scans), newest-first. @@ -329,6 +350,12 @@ async def list_release_snapshots( :class:`schemas.release_snapshot.ReleaseSnapshot`. ``total`` is the count of succeeded scans before pagination. + ``release`` narrows the listing to the snapshot carrying that version label, + which is how "give me version 4.0" is answered — the caller reads the row's + ``scan_id`` and pins it on the detail endpoints via ``?scan_id=``. An + unknown label is an empty 200, not a 404: absence of a version is a normal + answer, and 404 here would be indistinguishable from "no such project". + Authorization mirrors :func:`get_project_overview`: ``ProjectNotFound`` (404) for a missing project, ``ProjectForbidden`` (403) for a non-member (super_admin bypasses). A project with no succeeded scan returns ``([], 0)`` — an empty @@ -356,7 +383,7 @@ async def list_release_snapshots( ) scans, total = await _paged_succeeded_scans( - session, project_id=project_id, page=page, size=size + session, project_id=project_id, page=page, size=size, release=release ) if not scans: return [], total diff --git a/apps/backend/tests/integration/test_release_snapshots_api.py b/apps/backend/tests/integration/test_release_snapshots_api.py index 12bd3a3..ba7a0b4 100644 --- a/apps/backend/tests/integration/test_release_snapshots_api.py +++ b/apps/backend/tests/integration/test_release_snapshots_api.py @@ -342,6 +342,97 @@ async def test_releases_lists_succeeded_scans_newest_first_with_summaries(client assert older_row["risk_score"] == 83.3 +async def test_releases_release_filter_returns_only_that_version(client) -> None: + # "Which snapshot is 4.0?" — the whole point of attaching a version label. + team, user = await _seed_team_with_user(client) + project_id = await _seed_empty_project(client, team_id=team.id) + headers = _bearer_for(user) + base = datetime(2026, 5, 20, tzinfo=UTC) + + tagged = await _seed_succeeded_scan( + client, project_id=project_id, created_at=base, release="4.0" + ) + await _seed_succeeded_scan( + client, project_id=project_id, created_at=base + timedelta(days=1), release="4.1" + ) + await _seed_succeeded_scan( + client, project_id=project_id, created_at=base + timedelta(days=2) + ) + + unfiltered = await client.get(f"/v1/projects/{project_id}/releases", headers=headers) + assert unfiltered.json()["total"] == 3 + + filtered = await client.get( + f"/v1/projects/{project_id}/releases", headers=headers, params={"release": "4.0"} + ) + assert filtered.status_code == 200, filtered.text + body = filtered.json() + # `total` must describe the FILTERED set — a caller paging the filter must + # not be told there are three matches. + assert body["total"] == 1 + assert [row["scan_id"] for row in body["items"]] == [str(tagged)] + assert body["items"][0]["release"] == "4.0" + + +async def test_releases_release_filter_trims_both_sides(client) -> None: + team, user = await _seed_team_with_user(client) + project_id = await _seed_empty_project(client, team_id=team.id) + headers = _bearer_for(user) + tagged = await _seed_succeeded_scan( + client, + project_id=project_id, + created_at=datetime(2026, 5, 20, tzinfo=UTC), + release=" 4.0 ", + ) + + for query in ("4.0", " 4.0 "): + response = await client.get( + f"/v1/projects/{project_id}/releases", + headers=headers, + params={"release": query}, + ) + assert response.status_code == 200, response.text + assert [row["scan_id"] for row in response.json()["items"]] == [str(tagged)] + + +async def test_releases_unknown_release_is_empty_200_not_404(client) -> None: + # Absence of a version is a normal answer. A 404 here would be + # indistinguishable from "no such project". + team, user = await _seed_team_with_user(client) + project_id = await _seed_empty_project(client, team_id=team.id) + headers = _bearer_for(user) + await _seed_succeeded_scan( + client, + project_id=project_id, + created_at=datetime(2026, 5, 20, tzinfo=UTC), + release="4.0", + ) + + response = await client.get( + f"/v1/projects/{project_id}/releases", + headers=headers, + params={"release": "9.9"}, + ) + assert response.status_code == 200, response.text + assert response.json()["items"] == [] + assert response.json()["total"] == 0 + + +async def test_releases_release_filter_does_not_cross_projects(client) -> None: + team, user = await _seed_team_with_user(client) + mine = await _seed_empty_project(client, team_id=team.id) + theirs = await _seed_empty_project(client, team_id=team.id) + headers = _bearer_for(user) + base = datetime(2026, 5, 20, tzinfo=UTC) + await _seed_succeeded_scan(client, project_id=theirs, created_at=base, release="4.0") + + response = await client.get( + f"/v1/projects/{mine}/releases", headers=headers, params={"release": "4.0"} + ) + assert response.status_code == 200, response.text + assert response.json()["items"] == [] + + async def test_releases_empty_when_no_succeeded_scan(client) -> None: team, user = await _seed_team_with_user(client) project_id = await _seed_empty_project(client, team_id=team.id) diff --git a/apps/backend/tests/unit/openapi_endpoints.json b/apps/backend/tests/unit/openapi_endpoints.json index 70a785e..6cde2fa 100644 --- a/apps/backend/tests/unit/openapi_endpoints.json +++ b/apps/backend/tests/unit/openapi_endpoints.json @@ -303,6 +303,7 @@ "GET /v1/projects/{project_id}/releases": [ "page", "project_id", + "release", "size" ], "GET /v1/projects/{project_id}/remediation/pull-requests": [ diff --git a/apps/frontend/src/features/projects/api/releasesApi.ts b/apps/frontend/src/features/projects/api/releasesApi.ts index 870294f..8043184 100644 --- a/apps/frontend/src/features/projects/api/releasesApi.ts +++ b/apps/frontend/src/features/projects/api/releasesApi.ts @@ -8,7 +8,7 @@ * needs to pick a snapshot to inspect (risk score, severity summary, gate * verdict, component count). * - * - GET /v1/projects/{id}/releases?page=&size= → ReleaseListResponse + * - GET /v1/projects/{id}/releases?page=&size=&release= → ReleaseListResponse * * The wire types mirror the backend's `ReleaseSnapshot` 1:1 (snake_case). * `release` is frequently `null` (the scan was triggered without a version @@ -77,6 +77,13 @@ export interface ListReleasesParams { page?: number; /** Page size (server default applies when omitted). */ size?: number; + /** + * Exact version-label filter (e.g. "4.0"). A label identifies at most one + * live release, so this is the "which snapshot is 4.0?" lookup rather than a + * search. Whitespace is trimmed on both sides; an unknown label yields an + * empty list, not an error. + */ + release?: string; } export async function listProjectReleases( @@ -86,6 +93,9 @@ export async function listProjectReleases( const query: Record = {}; if (params.page != null) query.page = params.page; if (params.size != null) query.size = params.size; + if (params.release != null && params.release.trim().length > 0) { + query.release = params.release.trim(); + } const { data } = await api.get( `/v1/projects/${projectId}/releases`, { params: query }, diff --git a/apps/frontend/tests/unit/features/projects/releasesApi.test.ts b/apps/frontend/tests/unit/features/projects/releasesApi.test.ts index f296b04..5eab08b 100644 --- a/apps/frontend/tests/unit/features/projects/releasesApi.test.ts +++ b/apps/frontend/tests/unit/features/projects/releasesApi.test.ts @@ -68,6 +68,19 @@ describe("releasesApi", () => { expect.objectContaining({ params: { page: 3, size: 25 } }), ); }); + + it("forwards the version-label filter, trimmed", async () => { + await listProjectReleases("proj-1", { release: " 4.0 " }); + const call = mockedGet.mock.calls.at(-1)!; + expect(call[1].params).toEqual({ release: "4.0" }); + }); + + it("omits an absent or blank label so the list stays unfiltered", async () => { + await listProjectReleases("proj-1", { release: " " }); + expect(mockedGet.mock.calls.at(-1)![1].params).not.toHaveProperty("release"); + await listProjectReleases("proj-1", {}); + expect(mockedGet.mock.calls.at(-1)![1].params).not.toHaveProperty("release"); + }); }); describe("releaseLabel", () => { diff --git a/docs-site/docs/user-guide/projects.md b/docs-site/docs/user-guide/projects.md index ad1aec3..d4086d1 100644 --- a/docs-site/docs/user-guide/projects.md +++ b/docs-site/docs/user-guide/projects.md @@ -236,6 +236,12 @@ Click a release row directly to navigate to the **Components** tab with the snap Downloads follow the pin too. With a release pinned, the **NOTICE** you download is composed from that release's licenses, not from the newest scan — so the attribution document you shipped with an earlier release stays retrievable after later scans succeed. Over the API this is `GET /v1/projects/{id}/notice?scan_id=`; as with every other snapshot-anchored read, an id that is not one of this project's succeeded scans returns `404`. +### Looking a version up by name + +Over the API, `GET /v1/projects/{id}/releases?release=4.0` returns the snapshot carrying that version label. Because a labelled scan supersedes any earlier scan claiming the same label, the answer is at most one row — read its `scan_id` and pin it on the detail endpoints (`?scan_id=`) to read that release's components, licences, findings, SBOM, or NOTICE. An unknown label returns an empty list rather than a `404`: not having shipped 9.9 yet is a normal answer, and a `404` would be indistinguishable from "no such project". + +The match is exact, not a search — `4.0` does not find `4.0.1`. Surrounding whitespace is trimmed on both sides. + The companion **Compare** screen (the **Compare** button on the Releases-tab toolbar, enabled once the project has at least two releases) takes two snapshot ids — a **base** and a **target** — and shows what changed between them: added / removed / version-changed components, introduced / resolved vulnerabilities, and the risk-score, per-severity, license-tier, and build-gate deltas. A **swap** control flips base and target. The button defaults to comparing the newest release (target) against the one before it (base), and the two ids live in the URL (`?base=&target=`) so a specific comparison can be shared. It is the canonical diff view for "what changed between release X and release Y". ## The Reports tab {#the-reports-tab} diff --git a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/projects.md b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/projects.md index 54ed76c..480f53f 100644 --- a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/projects.md +++ b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/projects.md @@ -235,6 +235,12 @@ Overview 탭의 리스크 게이지 옆에는 **Project info** 카드가 있습 다운로드도 핀을 따릅니다. 릴리스를 핀한 상태에서 **NOTICE**를 내려받으면 최신 스캔이 아니라 그 릴리스의 라이선스로 문서를 구성합니다. 이전 릴리스와 함께 출하한 고지 파일을 이후 스캔이 성공한 뒤에도 그대로 받을 수 있다는 뜻입니다. API로는 `GET /v1/projects/{id}/notice?scan_id=`이며, 스냅샷을 앵커로 삼는 다른 조회와 마찬가지로 이 프로젝트의 성공한 스캔이 아닌 id를 주면 `404`를 반환합니다. +### 버전 이름으로 찾기 + +API에서는 `GET /v1/projects/{id}/releases?release=4.0`이 그 버전 라벨을 단 스냅샷을 돌려줍니다. 라벨이 붙은 스캔은 같은 라벨을 쓰던 이전 스캔을 밀어내므로 답은 많아야 한 행입니다. 그 행의 `scan_id`를 읽어 상세 엔드포인트에 `?scan_id=`로 고정하면 해당 릴리스의 컴포넌트·라이선스·취약점·SBOM·NOTICE를 읽을 수 있습니다. 없는 라벨은 `404`가 아니라 빈 목록을 돌려줍니다. 아직 9.9를 출시하지 않은 것은 정상적인 답이고, `404`로 답하면 "그런 프로젝트가 없다"와 구분되지 않기 때문입니다. + +검색이 아니라 정확히 일치하는 값을 찾습니다. `4.0`으로는 `4.0.1`을 찾지 못합니다. 앞뒤 공백은 양쪽 모두 제거하고 비교합니다. + Releases 탭 툴바의 **Compare** 버튼(릴리스가 둘 이상일 때 활성화)으로 진입하는 **Compare** 화면은 두 스냅샷 id — **base**와 **target** — 를 받아 그 사이에 무엇이 변했는지 보여줍니다: 추가·제거·버전 변경된 컴포넌트, 새로 발생·해소된 취약점, 그리고 리스크 점수·severity별·라이선스 티어·빌드 게이트 델타. **swap** 컨트롤로 base와 target을 뒤바꿉니다. 버튼은 최신 릴리스(target)를 그 직전 릴리스(base)와 비교하도록 기본 설정되며, 두 id는 URL(`?base=&target=`)에 담겨 특정 비교를 공유할 수 있습니다. "릴리스 X와 Y 사이에 무엇이 변했는가"의 정식 diff 뷰입니다. ## Reports 탭 {#reports-탭}