From 98f945e8673098acade61367d1180ab9c0517437 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Tue, 4 Aug 2026 15:57:02 +0900 Subject: [PATCH 1/2] feat(releases): surface the branch a snapshot came from, and filter by it Scan.ref was stamped at scan-create and then invisible: no response field, no filter, nothing in the UI. A project scanning several branches showed one interleaved list with no way to tell the rows apart or narrow to one. ReleaseSnapshot now carries ref, the Releases table has a Branch column, and both the releases and project-scans lists take ?ref= (bare or fully-qualified, normalized like scan triggers). Releases covers succeeded scans only; the scans list keeps a branch's failed attempts visible. --- apps/backend/api/v1/projects.py | 12 ++++ apps/backend/api/v1/scans.py | 13 ++++ apps/backend/schemas/release_snapshot.py | 10 +++ .../services/release_snapshot_service.py | 10 ++- apps/backend/services/scan_service.py | 18 +++-- .../integration/test_release_snapshots_api.py | 66 +++++++++++++++++++ .../backend/tests/unit/openapi_endpoints.json | 2 + .../src/features/projects/api/releasesApi.ts | 16 ++++- .../projects/components/ReleasesTab.tsx | 16 +++++ .../src/locales/en/project_detail.json | 6 +- .../src/locales/ko/project_detail.json | 6 +- .../features/projects/ReleasesTab.test.tsx | 23 +++++++ .../features/projects/releasesApi.test.ts | 9 +++ docs-site/docs/user-guide/projects.md | 9 +++ .../current/user-guide/projects.md | 9 +++ 15 files changed, 215 insertions(+), 10 deletions(-) diff --git a/apps/backend/api/v1/projects.py b/apps/backend/api/v1/projects.py index ec885ba..35df537 100644 --- a/apps/backend/api/v1/projects.py +++ b/apps/backend/api/v1/projects.py @@ -90,6 +90,7 @@ ConcurrentScanLimitExceeded, ScanError, ScanInProgressConflict, + normalize_ref, trigger_scan, ) from services.source_archive_service import ( @@ -681,6 +682,16 @@ async def list_project_releases_endpoint( "``?scan_id=``. An unknown label returns an empty list, not a 404." ), ), + ref: str | None = Query( + default=None, + max_length=255, + description=( + "Optional branch filter. Accepts a bare branch (``main``) or a " + "fully-qualified ref (``refs/heads/main``, ``refs/pull/12/merge``), " + "normalized the same way scan triggers normalize it, so a branch " + "reaches its own snapshots either way." + ), + ), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> Response: @@ -695,6 +706,7 @@ async def list_project_releases_endpoint( page=page, size=size, release=release, + ref=normalize_ref(ref), ) except ProjectError as exc: return _problem_for_project_error(request, exc) diff --git a/apps/backend/api/v1/scans.py b/apps/backend/api/v1/scans.py index 5a29a9b..6f94902 100644 --- a/apps/backend/api/v1/scans.py +++ b/apps/backend/api/v1/scans.py @@ -45,6 +45,7 @@ get_scan, list_scans_for_actor, list_scans_for_project, + normalize_ref, ) router = APIRouter(prefix="/v1", tags=["scans"]) @@ -449,6 +450,17 @@ async def list_scans_endpoint( project_id: uuid.UUID, page: int = Query(default=1, ge=1), size: int = Query(default=20, ge=1, le=100), + ref: str | None = Query( + default=None, + max_length=255, + description=( + "Optional branch filter. Accepts a bare branch (``main``) or a " + "fully-qualified ref (``refs/heads/main``, ``refs/pull/12/merge``), " + "normalized the same way scan triggers normalize it. Unlike the " + "releases list this covers every status, so it answers \"what has " + "this branch done lately\" including failures." + ), + ), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> Response: @@ -459,6 +471,7 @@ async def list_scans_endpoint( actor=actor, page=page, size=size, + ref=normalize_ref(ref), ) except ScanError as exc: return _problem_for_scan_error(request, exc) diff --git a/apps/backend/schemas/release_snapshot.py b/apps/backend/schemas/release_snapshot.py index 6a80d8a..c1c9de7 100644 --- a/apps/backend/schemas/release_snapshot.py +++ b/apps/backend/schemas/release_snapshot.py @@ -71,6 +71,15 @@ class ReleaseSnapshot(BaseModel): "CI scans." ), ) + ref: str | None = Field( + default=None, + description=( + "Normalized git ref this snapshot was scanned from (``main``, " + "``v1.2.3``, ``pr-12``), or null for an ad-hoc scan that carried no " + "ref. Distinct from ``release``: the ref is where the code came " + "from and keeps moving, the label names a shipped unit and does not." + ), + ) created_at: datetime = Field( description="When this scan was created (snapshots are ordered newest-first by this)." ) @@ -112,6 +121,7 @@ class ReleaseListResponse(BaseModel): { "scan_id": "7822b62d-9156-423d-9df6-5e51f546fbe8", "release": "v1.2.3", + "ref": "v1.2.3", "created_at": "2026-05-22T10:00:00Z", "risk_score": 92.9, "severity_summary": { diff --git a/apps/backend/services/release_snapshot_service.py b/apps/backend/services/release_snapshot_service.py index 22d1c9a..91c6818 100644 --- a/apps/backend/services/release_snapshot_service.py +++ b/apps/backend/services/release_snapshot_service.py @@ -151,6 +151,7 @@ async def _paged_succeeded_scans( page: int, size: int, release: str | None = None, + ref: str | None = None, ) -> tuple[list[Scan], int]: """Return ``(page_of_succeeded_scans, total)`` newest-first. @@ -191,6 +192,11 @@ async def _paged_succeeded_scans( .where(cast(Scan.status, String) == "succeeded") .where(Scan.superseded_at.is_(None)) ) + if ref is not None: + # Already normalized by the caller, matching the value the scan-create + # path stamped — so ``refs/heads/main`` and ``main`` reach one branch. + items_stmt = items_stmt.where(Scan.ref == ref) + count_stmt = count_stmt.where(Scan.ref == ref) 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. @@ -343,6 +349,7 @@ async def list_release_snapshots( page: int = 1, size: int = 20, release: str | None = None, + ref: str | None = None, ) -> tuple[list[dict[str, Any]], int]: """List a project's release snapshots (succeeded scans), newest-first. @@ -383,7 +390,7 @@ async def list_release_snapshots( ) scans, total = await _paged_succeeded_scans( - session, project_id=project_id, page=page, size=size, release=release + session, project_id=project_id, page=page, size=size, release=release, ref=ref ) if not scans: return [], total @@ -412,6 +419,7 @@ async def list_release_snapshots( { "scan_id": scan.id, "release": _release_label(scan), + "ref": scan.ref, "created_at": scan.created_at, "risk_score": risk_score, "severity_summary": { diff --git a/apps/backend/services/scan_service.py b/apps/backend/services/scan_service.py index e5a6a0f..ec4c76b 100644 --- a/apps/backend/services/scan_service.py +++ b/apps/backend/services/scan_service.py @@ -980,8 +980,15 @@ async def list_scans_for_project( actor: CurrentUser, page: int = 1, size: int = 20, + ref: str | None = None, ) -> tuple[list[Scan], int]: - """Return (scans, total) ordered by created_at desc, paginated.""" + """Return (scans, total) ordered by created_at desc, paginated. + + ``ref`` narrows the history to one normalized branch. The caller passes an + already-normalized value (``normalize_ref``) so ``refs/heads/main`` and + ``main`` reach the same rows the scan-create path stamped. Applied to the + count as well, so a filtered page does not report the unfiltered total. + """ page = max(page, 1) size = max(min(size, 100), 1) @@ -991,9 +998,10 @@ async def list_scans_for_project( f"actor is not a member of team {project.team_id}", ) - total_result = await session.execute( - select(func.count()).select_from(Scan).where(Scan.project_id == project_id) - ) + count_stmt = select(func.count()).select_from(Scan).where(Scan.project_id == project_id) + if ref is not None: + count_stmt = count_stmt.where(Scan.ref == ref) + total_result = await session.execute(count_stmt) total = int(total_result.scalar_one()) rows_stmt = ( @@ -1008,6 +1016,8 @@ async def list_scans_for_project( .limit(size) .offset((page - 1) * size) ) + if ref is not None: + rows_stmt = rows_stmt.where(Scan.ref == ref) rows_result = await session.execute(rows_stmt) rows = list(rows_result.scalars().all()) return rows, total diff --git a/apps/backend/tests/integration/test_release_snapshots_api.py b/apps/backend/tests/integration/test_release_snapshots_api.py index 2b6104e..6bb4a1e 100644 --- a/apps/backend/tests/integration/test_release_snapshots_api.py +++ b/apps/backend/tests/integration/test_release_snapshots_api.py @@ -440,6 +440,72 @@ async def test_releases_release_filter_does_not_cross_projects(client) -> None: assert response.json()["items"] == [] +async def test_releases_expose_and_filter_by_branch(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) + base = datetime(2026, 5, 20, tzinfo=UTC) + main_scan = await _seed_succeeded_scan( + client, project_id=project_id, created_at=base, ref="main" + ) + await _seed_succeeded_scan( + client, project_id=project_id, created_at=base + timedelta(days=1), ref="pr-12" + ) + + unfiltered = await client.get(f"/v1/projects/{project_id}/releases", headers=headers) + assert unfiltered.json()["total"] == 2 + assert {row["ref"] for row in unfiltered.json()["items"]} == {"main", "pr-12"} + + # A fully-qualified ref must reach the same rows the bare branch stamped. + for query in ("main", "refs/heads/main"): + filtered = await client.get( + f"/v1/projects/{project_id}/releases", headers=headers, params={"ref": query} + ) + assert filtered.status_code == 200, filtered.text + assert filtered.json()["total"] == 1 + assert [r["scan_id"] for r in filtered.json()["items"]] == [str(main_scan)] + + +async def test_releases_ref_is_null_for_adhoc_scans(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) + await _seed_succeeded_scan( + client, project_id=project_id, created_at=datetime(2026, 5, 20, tzinfo=UTC) + ) + + response = await client.get(f"/v1/projects/{project_id}/releases", headers=headers) + assert response.status_code == 200, response.text + assert response.json()["items"][0]["ref"] is None + + +async def test_project_scans_list_filters_by_branch_including_failures(client) -> None: + # The releases list only shows succeeded scans; the scans list is where a + # branch's failed attempts have to remain visible. + 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) + await _seed_succeeded_scan(client, project_id=project_id, created_at=base, ref="main") + await _seed_succeeded_scan( + client, + project_id=project_id, + created_at=base + timedelta(days=1), + ref="main", + status="failed", + ) + await _seed_succeeded_scan( + client, project_id=project_id, created_at=base + timedelta(days=2), ref="pr-12" + ) + + response = await client.get( + f"/v1/projects/{project_id}/scans", headers=headers, params={"ref": "main"} + ) + assert response.status_code == 200, response.text + assert response.json()["total"] == 2 + assert {row["ref"] for row in response.json()["items"]} == {"main"} + + 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 2c156cf..78be542 100644 --- a/apps/backend/tests/unit/openapi_endpoints.json +++ b/apps/backend/tests/unit/openapi_endpoints.json @@ -304,6 +304,7 @@ "GET /v1/projects/{project_id}/releases": [ "page", "project_id", + "ref", "release", "size" ], @@ -346,6 +347,7 @@ "GET /v1/projects/{project_id}/scans": [ "page", "project_id", + "ref", "size" ], "GET /v1/projects/{project_id}/scans/{scan_id}/conformance": [ diff --git a/apps/frontend/src/features/projects/api/releasesApi.ts b/apps/frontend/src/features/projects/api/releasesApi.ts index 8043184..26419bb 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=&release= → ReleaseListResponse + * - GET /v1/projects/{id}/releases?page=&size=&release=&ref= → ReleaseListResponse * * The wire types mirror the backend's `ReleaseSnapshot` 1:1 (snake_case). * `release` is frequently `null` (the scan was triggered without a version @@ -50,6 +50,12 @@ export interface ReleaseSnapshot { * em-dash so the row is never blank. */ release: string | null; + /** + * Normalized git ref the scan ran against (`main`, `pr-12`), or `null` for an + * ad-hoc scan that carried none. Distinct from `release`: the ref is where + * the code came from and keeps moving; the label names a shipped unit. + */ + ref: string | null; /** ISO-8601 instant the scan was created. */ created_at: string; /** Computed 0..100 risk score for this snapshot, or `null` when unscored. */ @@ -84,6 +90,11 @@ export interface ListReleasesParams { * empty list, not an error. */ release?: string; + /** + * Branch filter. Accepts a bare branch or a fully-qualified ref — the server + * normalizes both to the value scans were stamped with. + */ + ref?: string; } export async function listProjectReleases( @@ -96,6 +107,9 @@ export async function listProjectReleases( if (params.release != null && params.release.trim().length > 0) { query.release = params.release.trim(); } + if (params.ref != null && params.ref.trim().length > 0) { + query.ref = params.ref.trim(); + } const { data } = await api.get( `/v1/projects/${projectId}/releases`, { params: query }, diff --git a/apps/frontend/src/features/projects/components/ReleasesTab.tsx b/apps/frontend/src/features/projects/components/ReleasesTab.tsx index 5665587..d803475 100644 --- a/apps/frontend/src/features/projects/components/ReleasesTab.tsx +++ b/apps/frontend/src/features/projects/components/ReleasesTab.tsx @@ -172,6 +172,9 @@ export function ReleasesTab({ projectId, onViewSnapshot }: ReleasesTabProps) { {t("releases.col.release")} + + {t("releases.col.branch")} + {t("releases.col.date")} @@ -243,6 +246,19 @@ function ReleaseRow({ release, locale, onView }: ReleaseRowProps) { {label} + + {release.ref ? ( + + {release.ref} + + ) : ( + // An ad-hoc scan carried no ref. Say so rather than leaving the cell + // blank, which reads as missing data. + + {t("releases.branch_none")} + + )} + { "Release access denied — surfaced verbatim.", ); }); + + it("shows the branch a snapshot was scanned from", async () => { + mockedList.mockResolvedValue( + listResponse([snapshot("scan-branch", { ref: "release/1.x" })]), + ); + renderTab(); + await waitFor(() => { + expect(screen.getByTestId("release-row-branch")).toBeInTheDocument(); + }); + expect(screen.getByTestId("release-row-branch").textContent).toBe( + "release/1.x", + ); + }); + + it("labels an ad-hoc snapshot rather than leaving the branch cell blank", async () => { + mockedList.mockResolvedValue(listResponse([snapshot("scan-adhoc")])); + renderTab(); + await waitFor(() => { + expect(screen.getByTestId("release-row-branch")).toBeInTheDocument(); + }); + expect(screen.getByTestId("release-row-branch").textContent).toBe("ad-hoc"); + }); }); diff --git a/apps/frontend/tests/unit/features/projects/releasesApi.test.ts b/apps/frontend/tests/unit/features/projects/releasesApi.test.ts index 5eab08b..75800a0 100644 --- a/apps/frontend/tests/unit/features/projects/releasesApi.test.ts +++ b/apps/frontend/tests/unit/features/projects/releasesApi.test.ts @@ -75,6 +75,15 @@ describe("releasesApi", () => { expect(call[1].params).toEqual({ release: "4.0" }); }); + it("forwards the branch filter, trimmed, and omits it when blank", async () => { + await listProjectReleases("proj-1", { ref: " refs/heads/main " }); + expect(mockedGet.mock.calls.at(-1)![1].params).toEqual({ + ref: "refs/heads/main", + }); + await listProjectReleases("proj-1", { ref: " " }); + expect(mockedGet.mock.calls.at(-1)![1].params).not.toHaveProperty("ref"); + }); + 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"); diff --git a/docs-site/docs/user-guide/projects.md b/docs-site/docs/user-guide/projects.md index a12f9bb..1d10014 100644 --- a/docs-site/docs/user-guide/projects.md +++ b/docs-site/docs/user-guide/projects.md @@ -229,6 +229,7 @@ Every time a scan reaches a terminal `succeeded` status the portal records a **r | Column | What it shows | |---|---| | **Snapshot** | The scan completion time (`yyyy-mm-dd HH:MM`) + relative time. | +| **Branch** | The normalized git ref the scan ran against (`main`, `pr-12`), or `ad-hoc` when the trigger carried no ref. | | **Scan kind** | `source` or `container`. | | **Severity counts** | Critical / High / Medium / Low at snapshot time. | | **License mix** | Allowed / Conditional / Forbidden bars at snapshot time. | @@ -244,6 +245,14 @@ Over the API, `GET /v1/projects/{id}/releases?release=4.0` returns the snapshot The match is exact, not a search — `4.0` does not find `4.0.1`. Surrounding whitespace is trimmed on both sides. +### Filtering by branch + +Both `GET /v1/projects/{id}/releases?ref=main` and `GET /v1/projects/{id}/scans?ref=main` narrow to one branch. A bare branch and a fully-qualified ref (`refs/heads/main`, `refs/pull/12/merge`) both work — they are normalized the same way scan triggers normalize them, so a branch reaches its own rows either way. + +The two lists answer different questions. Releases covers succeeded scans only, so it is the branch's usable history; the scans list covers every status, so it is where a branch's failed attempts stay visible. + +Branch and version are separate axes and neither replaces the other. A branch keeps moving — the newest scan on it supersedes the previous one — while a version names a shipped unit and is displaced only by another scan claiming that same version. + 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 01b1c6c..290a821 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 @@ -228,6 +228,7 @@ Overview 탭의 리스크 게이지 옆에는 **Project info** 카드가 있습 | 컬럼 | 표시 내용 | |---|---| | **Snapshot** | 스캔 완료 시각 (`yyyy-mm-dd HH:MM`) + 상대 시간. | +| **Branch** | 스캔이 대상으로 삼은 정규화된 git ref(`main`, `pr-12`). 트리거에 ref가 없었으면 `지정 없음`. | | **Scan kind** | `source` 또는 `container`. | | **Severity counts** | 스냅샷 시점의 Critical / High / Medium / Low. | | **License mix** | 스냅샷 시점의 Allowed / Conditional / Forbidden 바. | @@ -243,6 +244,14 @@ API에서는 `GET /v1/projects/{id}/releases?release=4.0`이 그 버전 라벨 검색이 아니라 정확히 일치하는 값을 찾습니다. `4.0`으로는 `4.0.1`을 찾지 못합니다. 앞뒤 공백은 양쪽 모두 제거하고 비교합니다. +### 브랜치로 거르기 + +`GET /v1/projects/{id}/releases?ref=main`과 `GET /v1/projects/{id}/scans?ref=main` 모두 브랜치 하나로 좁힙니다. 짧은 브랜치명과 전체 형식(`refs/heads/main`, `refs/pull/12/merge`) 둘 다 받습니다. 스캔 트리거와 같은 방식으로 정규화하므로 어느 쪽으로 적어도 그 브랜치의 행에 닿습니다. + +두 목록은 답하는 질문이 다릅니다. Releases는 성공한 스캔만 담으므로 그 브랜치에서 쓸 수 있는 이력이고, 스캔 목록은 모든 상태를 담으므로 실패한 시도가 남아 있는 곳입니다. + +브랜치와 버전은 별개의 축이며 서로를 대신하지 않습니다. 브랜치는 계속 움직여서 새 스캔이 이전 것을 대체하지만, 버전은 출시한 단위의 이름이고 같은 버전을 주장하는 다른 스캔에만 밀려납니다. + Releases 탭 툴바의 **Compare** 버튼(릴리스가 둘 이상일 때 활성화)으로 진입하는 **Compare** 화면은 두 스냅샷 id — **base**와 **target** — 를 받아 그 사이에 무엇이 변했는지 보여줍니다: 추가·제거·버전 변경된 컴포넌트, 새로 발생·해소된 취약점, 그리고 리스크 점수·severity별·라이선스 티어·빌드 게이트 델타. **swap** 컨트롤로 base와 target을 뒤바꿉니다. 버튼은 최신 릴리스(target)를 그 직전 릴리스(base)와 비교하도록 기본 설정되며, 두 id는 URL(`?base=&target=`)에 담겨 특정 비교를 공유할 수 있습니다. "릴리스 X와 Y 사이에 무엇이 변했는가"의 정식 diff 뷰입니다. ## Reports 탭 {#reports-탭} From 34646c66710557cb8f5178490b70f35d0c9107ad Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Tue, 4 Aug 2026 16:20:39 +0900 Subject: [PATCH 2/2] fix(test): add ref to the remaining ReleaseSnapshot fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five test factories construct the wire type literally, so adding a required field breaks them. Local `tsc --noEmit` misses this — the CI typecheck runs `tsc -b`, which builds the test project too. --- apps/frontend/tests/unit/features/projects/ComparePage.test.tsx | 1 + .../tests/unit/features/projects/ProjectDetailPage.test.tsx | 1 + .../tests/unit/features/projects/ReleaseSwitcher.test.tsx | 1 + apps/frontend/tests/unit/features/projects/releasesApi.test.ts | 1 + .../tests/unit/features/projects/useLatestRelease.test.tsx | 1 + 5 files changed, 5 insertions(+) diff --git a/apps/frontend/tests/unit/features/projects/ComparePage.test.tsx b/apps/frontend/tests/unit/features/projects/ComparePage.test.tsx index 50fde99..d925903 100644 --- a/apps/frontend/tests/unit/features/projects/ComparePage.test.tsx +++ b/apps/frontend/tests/unit/features/projects/ComparePage.test.tsx @@ -77,6 +77,7 @@ function snapshot( return { scan_id: scanId, release: null, + ref: null, created_at: "2026-05-22T10:00:00Z", risk_score: 50, severity_summary: { critical: 0, high: 0, medium: 0, low: 0 }, diff --git a/apps/frontend/tests/unit/features/projects/ProjectDetailPage.test.tsx b/apps/frontend/tests/unit/features/projects/ProjectDetailPage.test.tsx index 3496569..1b63073 100644 --- a/apps/frontend/tests/unit/features/projects/ProjectDetailPage.test.tsx +++ b/apps/frontend/tests/unit/features/projects/ProjectDetailPage.test.tsx @@ -92,6 +92,7 @@ function release( return { scan_id: scanId, release: null, + ref: null, created_at: "2026-05-22T10:00:00Z", risk_score: 80, severity_summary: { critical: 10, high: 0, medium: 0, low: 0 }, diff --git a/apps/frontend/tests/unit/features/projects/ReleaseSwitcher.test.tsx b/apps/frontend/tests/unit/features/projects/ReleaseSwitcher.test.tsx index f6ad377..7e9318e 100644 --- a/apps/frontend/tests/unit/features/projects/ReleaseSwitcher.test.tsx +++ b/apps/frontend/tests/unit/features/projects/ReleaseSwitcher.test.tsx @@ -59,6 +59,7 @@ function snapshot( return { scan_id: scanId, release: null, + ref: null, created_at: "2026-05-22T10:00:00Z", risk_score: 80, severity_summary: { critical: 10, high: 0, medium: 0, low: 0 }, diff --git a/apps/frontend/tests/unit/features/projects/releasesApi.test.ts b/apps/frontend/tests/unit/features/projects/releasesApi.test.ts index 75800a0..45f1ba0 100644 --- a/apps/frontend/tests/unit/features/projects/releasesApi.test.ts +++ b/apps/frontend/tests/unit/features/projects/releasesApi.test.ts @@ -26,6 +26,7 @@ function snapshot(overrides: Partial = {}): ReleaseSnapshot { return { scan_id: "scan-1", release: null, + ref: null, created_at: "2026-05-22T10:00:00Z", risk_score: 50, severity_summary: { critical: 1, high: 0, medium: 0, low: 0 }, diff --git a/apps/frontend/tests/unit/features/projects/useLatestRelease.test.tsx b/apps/frontend/tests/unit/features/projects/useLatestRelease.test.tsx index 7eb4021..cd2b6f8 100644 --- a/apps/frontend/tests/unit/features/projects/useLatestRelease.test.tsx +++ b/apps/frontend/tests/unit/features/projects/useLatestRelease.test.tsx @@ -24,6 +24,7 @@ function snapshot(scanId: string): ReleaseSnapshot { return { scan_id: scanId, release: "v1.0.0", + ref: null, created_at: "2026-05-22T10:00:00Z", risk_score: 50, severity_summary: { critical: 0, high: 0, medium: 0, low: 0 },