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
1 change: 1 addition & 0 deletions backend/druks/contrib/review/datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion backend/druks/durable/schemas.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -132,6 +145,7 @@ class SubjectSummary(BaseResponse):
model_config = ConfigDict(from_attributes=True)

id: SubjectId
label: SubjectLabel


class SubjectStatus(BaseResponse):
Expand Down
22 changes: 17 additions & 5 deletions backend/tests/test_generic_subjects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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]:
Expand Down Expand Up @@ -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):
Expand All @@ -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.
Expand Down Expand Up @@ -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"]

Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type RunState =
// timeline, and detail URL.
export interface SubjectSummary {
id: string
label: string
}

export interface SubjectStatus {
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/lib/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/ExtensionHomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ function SubjectBoard({ extension, subjectType }: { extension: string; subjectTy
onClick={() => navigate(`/${extension}/${subjectType}/${row.summary.id}`)}
>
<StatusGlyph state={row.status.state} />
<span className="row-title">{row.summary.id}</span>
<span className="row-title">{row.summary.label}</span>
<span className="subject-row-meta mono dim">
{summaryEntries(row.summary)
.map(([key, value]) => `${key} ${value}`)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/SubjectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function SubjectPage({ extension, subjectType, subjectId }: Props) {
return (
<Page scroll="internal" className="ins page-subject" header={crumb}>
<Facts className="subject-facts">
<Fact k="id">{data.summary.id}</Fact>
<Fact k={label}>{data.summary.label}</Fact>
{summaryEntries(data.summary).map(([key, value]) => (
<Fact key={key} k={key}>
{value}
Expand Down