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
13 changes: 13 additions & 0 deletions apps/backend/api/v1/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
31 changes: 29 additions & 2 deletions apps/backend/services/release_snapshot_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -150,15 +150,26 @@ 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.

One query for the page (ordered ``created_at DESC, id DESC`` — stable across
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
Expand All @@ -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)
Expand Down Expand Up @@ -322,13 +342,20 @@ 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.

Returns ``(items, total)`` where each item is a plain dict shaped to
: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
Expand Down Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions apps/backend/tests/integration/test_release_snapshots_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions apps/backend/tests/unit/openapi_endpoints.json
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@
"GET /v1/projects/{project_id}/releases": [
"page",
"project_id",
"release",
"size"
],
"GET /v1/projects/{project_id}/remediation/pull-requests": [
Expand Down
12 changes: 11 additions & 1 deletion apps/frontend/src/features/projects/api/releasesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -86,6 +93,9 @@ export async function listProjectReleases(
const query: Record<string, unknown> = {};
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<ReleaseListResponse>(
`/v1/projects/${projectId}/releases`,
{ params: query },
Expand Down
13 changes: 13 additions & 0 deletions apps/frontend/tests/unit/features/projects/releasesApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
6 changes: 6 additions & 0 deletions docs-site/docs/user-guide/projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ Overview 탭의 리스크 게이지 옆에는 **Project info** 카드가 있습

다운로드도 핀을 따릅니다. 릴리스를 핀한 상태에서 **NOTICE**를 내려받으면 최신 스캔이 아니라 그 릴리스의 라이선스로 문서를 구성합니다. 이전 릴리스와 함께 출하한 고지 파일을 이후 스캔이 성공한 뒤에도 그대로 받을 수 있다는 뜻입니다. API로는 `GET /v1/projects/{id}/notice?scan_id=<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-탭}
Expand Down
Loading