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
12 changes: 12 additions & 0 deletions apps/backend/api/v1/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
ConcurrentScanLimitExceeded,
ScanError,
ScanInProgressConflict,
normalize_ref,
trigger_scan,
)
from services.source_archive_service import (
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions apps/backend/api/v1/scans.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
get_scan,
list_scans_for_actor,
list_scans_for_project,
normalize_ref,
)

router = APIRouter(prefix="/v1", tags=["scans"])
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions apps/backend/schemas/release_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
)
Expand Down Expand Up @@ -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": {
Expand Down
10 changes: 9 additions & 1 deletion apps/backend/services/release_snapshot_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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": {
Expand Down
18 changes: 14 additions & 4 deletions apps/backend/services/scan_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 = (
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions apps/backend/tests/integration/test_release_snapshots_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/tests/unit/openapi_endpoints.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@
"GET /v1/projects/{project_id}/releases": [
"page",
"project_id",
"ref",
"release",
"size"
],
Expand Down Expand Up @@ -346,6 +347,7 @@
"GET /v1/projects/{project_id}/scans": [
"page",
"project_id",
"ref",
"size"
],
"GET /v1/projects/{project_id}/scans/{scan_id}/conformance": [
Expand Down
16 changes: 15 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=&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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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(
Expand All @@ -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<ReleaseListResponse>(
`/v1/projects/${projectId}/releases`,
{ params: query },
Expand Down
16 changes: 16 additions & 0 deletions apps/frontend/src/features/projects/components/ReleasesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ export function ReleasesTab({ projectId, onViewSnapshot }: ReleasesTabProps) {
<th className="px-3 py-2 font-medium">
{t("releases.col.release")}
</th>
<th className="px-3 py-2 font-medium">
{t("releases.col.branch")}
</th>
<th className="px-3 py-2 font-medium">
{t("releases.col.date")}
</th>
Expand Down Expand Up @@ -243,6 +246,19 @@ function ReleaseRow({ release, locale, onView }: ReleaseRowProps) {
{label}
</span>
</td>
<td className="px-3 py-2" data-testid="release-row-branch">
{release.ref ? (
<span className="font-mono text-xs text-muted-foreground">
{release.ref}
</span>
) : (
// An ad-hoc scan carried no ref. Say so rather than leaving the cell
// blank, which reads as missing data.
<span className="text-xs text-muted-foreground">
{t("releases.branch_none")}
</span>
)}
</td>
<td
className="px-3 py-2 text-xs text-muted-foreground"
data-testid="release-row-date"
Expand Down
6 changes: 4 additions & 2 deletions apps/frontend/src/locales/en/project_detail.json
Original file line number Diff line number Diff line change
Expand Up @@ -1029,7 +1029,8 @@
"risk": "Risk",
"severity": "Severity",
"gate": "Gate",
"actions": "Actions"
"actions": "Actions",
"branch": "Branch"
},
"severity_abbr": {
"critical": "C",
Expand All @@ -1043,7 +1044,8 @@
},
"errors": {
"load_failed": "Could not load releases. Please try again."
}
},
"branch_none": "ad-hoc"
},
"release_switcher": {
"menu_label": "Release",
Expand Down
6 changes: 4 additions & 2 deletions apps/frontend/src/locales/ko/project_detail.json
Original file line number Diff line number Diff line change
Expand Up @@ -1029,7 +1029,8 @@
"risk": "리스크",
"severity": "심각도",
"gate": "게이트",
"actions": "작업"
"actions": "작업",
"branch": "브랜치"
},
"severity_abbr": {
"critical": "C",
Expand All @@ -1043,7 +1044,8 @@
},
"errors": {
"load_failed": "릴리스를 불러오지 못했습니다. 다시 시도해 주세요."
}
},
"branch_none": "지정 없음"
},
"release_switcher": {
"menu_label": "릴리스",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading
Loading