diff --git a/backend/druks/contrib/review/datastructures.py b/backend/druks/contrib/review/datastructures.py
index b042cd55..9ea0d7c2 100644
--- a/backend/druks/contrib/review/datastructures.py
+++ b/backend/druks/contrib/review/datastructures.py
@@ -38,6 +38,7 @@ def url(self) -> str:
def get_summary(self) -> ReviewSummary:
return ReviewSummary(
id=self.id,
+ label=self.label,
repo=self.repo,
pr_number=self.number,
pull_request_url=self.url,
diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py
index b195e143..df69efbf 100644
--- a/backend/druks/durable/schemas.py
+++ b/backend/druks/durable/schemas.py
@@ -1,7 +1,15 @@
from datetime import datetime
from typing import TYPE_CHECKING, Annotated, Any, Literal
-from pydantic import AliasPath, BeforeValidator, ConfigDict, Field, SerializeAsAny, computed_field
+from pydantic import (
+ AliasPath,
+ BeforeValidator,
+ ConfigDict,
+ Field,
+ SerializeAsAny,
+ StringConstraints,
+ computed_field,
+)
from druks.schemas import BaseResponse
@@ -124,6 +132,11 @@ def from_run(
# takes either and is always a string.
SubjectId = Annotated[str, BeforeValidator(str)]
+# The one line a board row and a detail page show a subject as. Blank is rejected
+# rather than rendered: a title nobody can read is the thing this field exists to
+# prevent.
+SubjectLabel = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
+
class SubjectSummary(BaseResponse):
# The base an extension's subject header subclasses; ``id`` keys the subject's
@@ -132,6 +145,7 @@ class SubjectSummary(BaseResponse):
model_config = ConfigDict(from_attributes=True)
id: SubjectId
+ label: SubjectLabel
class SubjectStatus(BaseResponse):
diff --git a/backend/tests/test_generic_subjects.py b/backend/tests/test_generic_subjects.py
index 6f48018c..7757223b 100644
--- a/backend/tests/test_generic_subjects.py
+++ b/backend/tests/test_generic_subjects.py
@@ -26,7 +26,7 @@ class Thing(StoredSubject):
__tablename__ = "faketest_things"
def get_summary(self) -> _ThingSummary:
- return _ThingSummary(id=self.id, title=TITLES[self.id])
+ return _ThingSummary(id=self.id, label=self.label, title=TITLES[self.id])
@classmethod
def list_summaries(cls) -> list[_ThingSummary]:
@@ -44,7 +44,7 @@ def get_for_subject_id(cls, subject_id: str) -> "Ticket | None":
return
def get_summary(self) -> _ThingSummary:
- return _ThingSummary(id=self.id, title=self.id.rpartition("#")[2])
+ return _ThingSummary(id=self.id, label=self.label, title=self.id.rpartition("#")[2])
@classmethod
def list_summaries(cls) -> list[_ThingSummary]:
@@ -128,7 +128,19 @@ def test_a_subject_id_is_a_string_whatever_the_row_is_keyed_by(druks_db):
# Only the id widens: a title that arrives as a number is still a mistake.
with pytest.raises(ValidationError):
- _ThingSummary(id=7, title=7)
+ _ThingSummary(id=7, label="7", title=7)
+
+
+def test_a_summary_carries_the_subjects_own_label(druks_db):
+ assert SubjectSummary.model_validate(Thing(id=7)).label == "thing 7"
+ assert SubjectSummary.model_validate(Ticket(id="owner/repo#7")).label == "owner/repo#7"
+
+ # A title nobody can read is what this field exists to prevent, so a missing
+ # one and a blank one fail the same way.
+ with pytest.raises(ValidationError):
+ _ThingSummary(id=1, title="First")
+ with pytest.raises(ValidationError):
+ _ThingSummary(id=1, label=" ", title="First")
def test_status_aggregates_across_runs_and_timeline_spans_them(client: TestClient, druks_db):
@@ -141,7 +153,7 @@ def test_status_aggregates_across_runs_and_timeline_spans_them(client: TestClien
_seed_call(druks_db, live, agent="implement", status="running")
detail = client.get("/api/faketest/thing/1").json()
- assert detail["summary"] == {"id": "1", "title": "First"}
+ assert detail["summary"] == {"id": "1", "label": "thing 1", "title": "First"}
assert detail["status"]["state"] == "running"
assert [entry["kind"] for entry in detail["timeline"]] == ["faketest.prepare", "faketest.flow"]
# Calls group under their own run, not the subject at large.
@@ -232,7 +244,7 @@ def test_an_id_spanning_separators_reaches_the_board_and_its_page(client: TestCl
assert [row["summary"]["id"] for row in board["rows"]] == ["owner/repo#7"]
detail = client.get("/api/faketest/ticket/owner/repo%237").json()
- assert detail["summary"] == {"id": "owner/repo#7", "title": "7"}
+ assert detail["summary"] == {"id": "owner/repo#7", "label": "owner/repo#7", "title": "7"}
assert detail["status"]["state"] == "parked"
assert [entry["kind"] for entry in detail["timeline"]] == ["faketest.flow"]
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index a8fafc34..524e0e56 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -34,6 +34,7 @@ export type RunState =
// timeline, and detail URL.
export interface SubjectSummary {
id: string
+ label: string
}
export interface SubjectStatus {
diff --git a/frontend/src/lib/summary.ts b/frontend/src/lib/summary.ts
index c09d5c0f..b765a654 100644
--- a/frontend/src/lib/summary.ts
+++ b/frontend/src/lib/summary.ts
@@ -3,12 +3,13 @@ import { relTimeFromIso } from './format'
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T/
// A subject summary's scalar fields as label/text pairs — what the generic board
-// row and subject facts render. ``id`` is the row key, not a field. Typed on the
-// wire's base shape; the extension's extra fields are what this walks.
+// row and subject facts render. ``id`` and ``label`` are the row key and the title,
+// not fields. Typed on the wire's base shape; the extension's extra fields are
+// what this walks.
export function summaryEntries(summary: object): [string, string][] {
const entries: [string, string][] = []
for (const [key, value] of Object.entries(summary)) {
- if (key === 'id' || value === null || value === undefined || value === '') continue
+ if (key === 'id' || key === 'label' || value === null || value === undefined || value === '') continue
if (!['string', 'number', 'boolean'].includes(typeof value)) continue
const label = key.replace(/([A-Z])/g, ' $1').toLowerCase()
const text =
diff --git a/frontend/src/pages/ExtensionHomePage.tsx b/frontend/src/pages/ExtensionHomePage.tsx
index 691ff421..ddd04437 100644
--- a/frontend/src/pages/ExtensionHomePage.tsx
+++ b/frontend/src/pages/ExtensionHomePage.tsx
@@ -66,7 +66,7 @@ function SubjectBoard({ extension, subjectType }: { extension: string; subjectTy
onClick={() => navigate(`/${extension}/${subjectType}/${row.summary.id}`)}
>
- {row.summary.id}
+ {row.summary.label}
{summaryEntries(row.summary)
.map(([key, value]) => `${key} ${value}`)
diff --git a/frontend/src/pages/SubjectPage.tsx b/frontend/src/pages/SubjectPage.tsx
index 947b574f..54128ed4 100644
--- a/frontend/src/pages/SubjectPage.tsx
+++ b/frontend/src/pages/SubjectPage.tsx
@@ -74,7 +74,7 @@ export function SubjectPage({ extension, subjectType, subjectId }: Props) {
return (
- {data.summary.id}
+ {data.summary.label}
{summaryEntries(data.summary).map(([key, value]) => (
{value}