From 93904ff964b504874e6820b66451c9667f5256fa Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 27 Jul 2026 13:42:07 +0000 Subject: [PATCH 1/3] Rename work item remote fields to ticket fields --- backend/druks/contrib/ship/models.py | 46 +++---- backend/druks/contrib/ship/schemas.py | 12 +- backend/druks/contrib/ship/subscribers.py | 4 +- backend/druks/contrib/ship/workflows.py | 20 +-- ...b81a9d4e_rename_work_item_ticket_fields.py | 66 +++++++++ backend/tests/conftest.py | 1 + backend/tests/ship/factories.py | 2 +- backend/tests/ship/test_api_work_items.py | 31 ++++- backend/tests/ship/test_build_dispatch.py | 27 +++- backend/tests/ship/test_build_durable.py | 2 +- backend/tests/ship/test_lane_reactions.py | 10 +- backend/tests/ship/test_subject_lifecycle.py | 10 +- backend/tests/ship/test_ticketing.py | 20 +-- backend/tests/ship/test_webhooks_jira.py | 2 +- .../tests/ship/test_webhooks_pull_request.py | 6 +- .../tests/test_work_item_ticket_migration.py | 129 ++++++++++++++++++ .../src/extensions/ship/AgentCallPage.tsx | 6 +- frontend/src/extensions/ship/WorkItemPage.tsx | 14 +- .../src/extensions/ship/WorkItemsPage.tsx | 4 +- frontend/src/extensions/ship/api.ts | 4 +- frontend/src/extensions/ship/slug.ts | 6 +- 21 files changed, 326 insertions(+), 96 deletions(-) create mode 100644 backend/migrations/versions/f2c8b81a9d4e_rename_work_item_ticket_fields.py create mode 100644 backend/tests/test_work_item_ticket_migration.py diff --git a/backend/druks/contrib/ship/models.py b/backend/druks/contrib/ship/models.py index bc7a8e28..1c3aa4e7 100644 --- a/backend/druks/contrib/ship/models.py +++ b/backend/druks/contrib/ship/models.py @@ -176,10 +176,10 @@ class WorkItem(StoredSubject): __tablename__ = "work_items" __table_args__ = ( Index("work_items_repo_idx", "repo", "pr_number"), - # One WorkItem per (source, remote_key) — one row per ticket in the remote + # One WorkItem per (source, ticket_key) — one row per ticket in the remote # tracker. ``source`` is part of the key so Linear "ABC-1" and Jira "ABC-1" # don't collide once we support multiple providers. - Index("work_items_remote_unique", "source", "remote_key", unique=True), + Index("work_items_ticket_unique", "source", "ticket_key", unique=True), Index("work_items_project_idx", "project_id"), Index("work_items_status_idx", "status"), ) @@ -189,15 +189,15 @@ class WorkItem(StoredSubject): ) project: Mapped[Project] = relationship(lazy="joined") # Which remote tracker the ticket lives in: ``linear`` / ``github`` / - # future ``jira``. Combined with ``remote_key`` to uniquely identify + # future ``jira``. Combined with ``ticket_key`` to uniquely identify # a ticket. source: Mapped[str] = mapped_column(default="github") title: Mapped[str] = mapped_column(default="") # Human-readable issue key in the source: ``ACME-270`` / ``#42`` / # ``JIRA-123``. Every item is born from a ticket, so every item has one; # Linear's GraphQL accepts the identifier wherever it accepts the UUID. - remote_key: Mapped[str] - remote_url: Mapped[str | None] + ticket_key: Mapped[str] + ticket_url: Mapped[str | None] # The PR-target repo. Still on WorkItem (not derived from project) # because a Project can hold N repos but every WorkItem PRs into one. repo: Mapped[str] @@ -214,7 +214,7 @@ class WorkItem(StoredSubject): updated_at: Mapped[datetime] = mapped_column(default=Base.utc_now) def get_label(self) -> str: - return self.remote_key + return self.ticket_key @classmethod def create( @@ -223,8 +223,8 @@ def create( project_id: int, source: str = "github", title: str, - remote_key: str, - remote_url: str | None = None, + ticket_key: str, + ticket_url: str | None = None, repo: str, ) -> "WorkItem": session = db_session() @@ -232,8 +232,8 @@ def create( project_id=project_id, source=source, title=title, - remote_key=remote_key, - remote_url=remote_url, + ticket_key=ticket_key, + ticket_url=ticket_url, repo=repo, ) session.add(item) @@ -281,7 +281,7 @@ async def ship(self) -> None: build = self.get_status(workflow=Build) if build.is_parked: await Build.cancel(self, failure="pr merged while parked") - await self.set_remote_status(TicketStatus.DONE) + await self.set_ticket_status(TicketStatus.DONE) async def close_external(self) -> None: # The attempt was abandoned, not the ticket, so the ticket returns to the @@ -297,20 +297,20 @@ async def close_external(self) -> None: await get_github_client(load_settings()).delete_branch(self.repo, self.branch) except Exception: # noqa: BLE001 — cleanup only logger.warning("Skipped branch cleanup for %s.", self.repo, exc_info=True) - await self.set_remote_status(TicketStatus.READY_FOR_AGENT) + await self.set_ticket_status(TicketStatus.READY_FOR_AGENT) @classmethod - def get_for_remote_key( + def get_for_ticket_key( cls, *, source: str, - remote_key: str, + ticket_key: str, ) -> "WorkItem | None": - """Look up a WorkItem by its source + remote_key pair. + """Look up a WorkItem by its source + ticket_key pair. - The (source, remote_key) unique constraint guarantees at most + The (source, ticket_key) unique constraint guarantees at most one live row; we return that row or None if no match exists.""" - stmt = select(cls).where(cls.source == source, cls.remote_key == remote_key).limit(1) + stmt = select(cls).where(cls.source == source, cls.ticket_key == ticket_key).limit(1) return db_session().scalars(stmt).first() @classmethod @@ -341,7 +341,7 @@ def set_status( self.updated_at = Base.utc_now() db_session().flush() - async def set_remote_status(self, status: TicketStatus) -> None: + async def set_ticket_status(self, status: TicketStatus) -> None: # No-op for sources without a configured tracker (github, absent creds). if not is_tracker_source(self.source): return @@ -356,7 +356,7 @@ async def set_remote_status(self, status: TicketStatus) -> None: except TrackerNotConfigured: return - ticket = Ticket.ref(self.source, self.remote_key) + ticket = Ticket.ref(self.source, self.ticket_key) async with tracker: try: await tracker.set_status(ticket, status) @@ -364,7 +364,7 @@ async def set_remote_status(self, status: TicketStatus) -> None: logger.warning( "Could not sync %s ticket %s to %s.", self.source, - self.remote_key, + self.ticket_key, status.value, exc_info=True, ) @@ -373,7 +373,7 @@ def update( self, *, title: str = _KEEP, - remote_url: str | None = _KEEP, + ticket_url: str | None = _KEEP, pr_number: int | None = _KEEP, branch: str | None = _KEEP, build_run_id: str | None = _KEEP, @@ -381,8 +381,8 @@ def update( ) -> None: if title is not _KEEP: self.title = title - if remote_url is not _KEEP: - self.remote_url = remote_url + if ticket_url is not _KEEP: + self.ticket_url = ticket_url if pr_number is not _KEEP: self.pr_number = pr_number if branch is not _KEEP: diff --git a/backend/druks/contrib/ship/schemas.py b/backend/druks/contrib/ship/schemas.py index 24d1c052..fcc85661 100644 --- a/backend/druks/contrib/ship/schemas.py +++ b/backend/druks/contrib/ship/schemas.py @@ -94,7 +94,7 @@ class Links(BaseResponse): @classmethod def from_work_item(cls, item: WorkItem) -> "Links": pr = f"https://github.com/{item.repo}/pull/{item.pr_number}" if item.pr_number else None - return cls(repo=f"https://github.com/{item.repo}", pr=pr, ticket=item.remote_url) + return cls(repo=f"https://github.com/{item.repo}", pr=pr, ticket=item.ticket_url) class WorkItemSummary(SubjectSummary): @@ -108,8 +108,8 @@ class WorkItemSummary(SubjectSummary): # whose Linear project doesn't map to one. project_name: str title: str - remote_key: str - remote_url: str | None = None + ticket_key: str + ticket_url: str | None = None pr_number: int | None = None branch: str | None = None created_at: datetime @@ -124,8 +124,8 @@ def from_work_item(cls, item: WorkItem) -> "WorkItemSummary": repo=item.repo, project_name=item.project.name, title=item.title, - remote_key=item.remote_key, - remote_url=item.remote_url, + ticket_key=item.ticket_key, + ticket_url=item.ticket_url, pr_number=item.pr_number, branch=item.branch, created_at=item.created_at, @@ -154,7 +154,7 @@ def from_work_item(cls, item: WorkItem) -> "DashboardItem": return cls( key=f"code:{item.id}", source_id=item.id, - ticket_ref=item.remote_key, + ticket_ref=item.ticket_key, title=item.title, repo=item.repo, pr_number=item.pr_number, diff --git a/backend/druks/contrib/ship/subscribers.py b/backend/druks/contrib/ship/subscribers.py index 1076a5aa..528eadf8 100644 --- a/backend/druks/contrib/ship/subscribers.py +++ b/backend/druks/contrib/ship/subscribers.py @@ -28,12 +28,12 @@ async def pr_open_mirrors_onto_item( async def build_start_marks_ticket_in_progress(*, subject: WorkItem, **_: object) -> None: # Every (re)start and gate-resume of a build means the ticket is in progress — # including the return from a rework loop that had parked it In Review. - await subject.set_remote_status(TicketStatus.IN_PROGRESS) + await subject.set_ticket_status(TicketStatus.IN_PROGRESS) @subscribe(WorkflowEvent.PARKED, workflow=Build, gate=ReviewWork, subject=WorkItem) async def review_park_marks_ticket_in_review(*, subject: WorkItem, **_: object) -> None: - await subject.set_remote_status(TicketStatus.IN_REVIEW) + await subject.set_ticket_status(TicketStatus.IN_REVIEW) @subscribe(WorkflowEvent.FAILED, workflow=Build, subject=WorkItem) diff --git a/backend/druks/contrib/ship/workflows.py b/backend/druks/contrib/ship/workflows.py index 2591cebb..9b563ea3 100644 --- a/backend/druks/contrib/ship/workflows.py +++ b/backend/druks/contrib/ship/workflows.py @@ -94,9 +94,9 @@ class Settings(BaseModel): async def dispatch(cls, *, ticket: dict) -> str | None: # The tracker funnel's entry: a ticket at the trigger status opens a build. # Resolve-or-refresh the item, then start (start() dedups a live run). - item = WorkItem.get_for_remote_key(source=ticket["source"], remote_key=ticket["identifier"]) + item = WorkItem.get_for_ticket_key(source=ticket["source"], ticket_key=ticket["identifier"]) if item: - item.update(title=ticket["title"], remote_url=ticket["url"]) + item.update(title=ticket["title"], ticket_url=ticket["url"]) else: repo = ProjectRepo.lookup(project_name=ticket["project_name"], labels=ticket["labels"]) if repo: @@ -104,8 +104,8 @@ async def dispatch(cls, *, ticket: dict) -> str | None: project_id=repo.project_id, source=ticket["source"], title=ticket["title"] or ticket["identifier"], - remote_key=ticket["identifier"], - remote_url=ticket["url"], + ticket_key=ticket["identifier"], + ticket_url=ticket["url"], repo=repo.full_name, ) else: @@ -118,9 +118,9 @@ async def dispatch(cls, *, ticket: dict) -> str | None: account_id=assignee.id if assignee else None, repo=item.repo, source=item.source, - ticket_ref=item.remote_key, - remote_title=item.title, - remote_url=item.remote_url, + ticket_ref=item.ticket_key, + ticket_title=item.title, + ticket_url=item.ticket_url, task_owner_email=email, task_owner_name=ticket["assignee_name"], ) @@ -139,13 +139,13 @@ async def run_multistep( issue_number: int | None = None, source: str = "github", # Human-readable ticket reference ("ACME-270", "#42"). Same value the - # WorkItem keeps as ``remote_key``; carried separately on the input + # WorkItem keeps as ``ticket_key``; carried separately on the input # so the workflow can render it into prompts / PR titles without a # WorkItem fetch. ticket_ref: str | None = None, # Ticket title as intake received it; the implementer uses it for the PR title. - remote_title: str | None = None, - remote_url: str | None = None, + ticket_title: str | None = None, + ticket_url: str | None = None, task_owner_email: str | None = None, task_owner_name: str | None = None, ) -> None: diff --git a/backend/migrations/versions/f2c8b81a9d4e_rename_work_item_ticket_fields.py b/backend/migrations/versions/f2c8b81a9d4e_rename_work_item_ticket_fields.py new file mode 100644 index 00000000..a18c8127 --- /dev/null +++ b/backend/migrations/versions/f2c8b81a9d4e_rename_work_item_ticket_fields.py @@ -0,0 +1,66 @@ +"""rename work item ticket fields + +Revision ID: f2c8b81a9d4e +Revises: d3a5c71f8e40 +Create Date: 2026-07-27 14:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f2c8b81a9d4e" +down_revision: str | Sequence[str] | None = "d3a5c71f8e40" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_index("work_items_remote_unique", table_name="work_items") + op.alter_column( + "work_items", + "remote_key", + existing_type=sa.String(), + existing_nullable=False, + new_column_name="ticket_key", + ) + op.alter_column( + "work_items", + "remote_url", + existing_type=sa.String(), + existing_nullable=True, + new_column_name="ticket_url", + ) + op.create_index( + "work_items_ticket_unique", + "work_items", + ["source", "ticket_key"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("work_items_ticket_unique", table_name="work_items") + op.alter_column( + "work_items", + "ticket_key", + existing_type=sa.String(), + existing_nullable=False, + new_column_name="remote_key", + ) + op.alter_column( + "work_items", + "ticket_url", + existing_type=sa.String(), + existing_nullable=True, + new_column_name="remote_url", + ) + op.create_index( + "work_items_remote_unique", + "work_items", + ["source", "remote_key"], + unique=True, + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b2f41475..def5d314 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -114,6 +114,7 @@ def registry_state(): "test_harness_login_persistence", "test_extension_migrations", "test_proof_extension_migration", + "test_work_item_ticket_migration", } diff --git a/backend/tests/ship/factories.py b/backend/tests/ship/factories.py index c73fe984..d6a456e2 100644 --- a/backend/tests/ship/factories.py +++ b/backend/tests/ship/factories.py @@ -11,7 +11,7 @@ def make_test_work_item(*, repo: str, **kwargs): if not project: project = Project.create(name=repo) ProjectRepo.create(project_id=project.id, full_name=repo) - kwargs.setdefault("remote_key", f"TEST-{uuid7()}") + kwargs.setdefault("ticket_key", f"TEST-{uuid7()}") return WorkItem.create(project_id=project.id, repo=repo, **kwargs) diff --git a/backend/tests/ship/test_api_work_items.py b/backend/tests/ship/test_api_work_items.py index dc8f53b5..a1f5e299 100644 --- a/backend/tests/ship/test_api_work_items.py +++ b/backend/tests/ship/test_api_work_items.py @@ -86,7 +86,11 @@ def test_subject_list_shows_active_and_excludes_handed_off(client: TestClient, d def test_subject_detail_composes_summary_status_and_timeline(client: TestClient, druks_db): item = make_test_work_item( - title="detail", repo="ClawHaven/acme-app", source="linear", remote_key="ACME-5" + title="detail", + repo="ClawHaven/acme-app", + source="linear", + ticket_key="ACME-5", + ticket_url="https://linear.app/acme/issue/ACME-5/detail", ) WorkItem.get(item.id).update(pr_number=8) run = seed_build_run( @@ -99,9 +103,26 @@ def test_subject_detail_composes_summary_status_and_timeline(client: TestClient, seed_call(druks_db, run, "generate_plan") detail = client.get(f"/api/ship/work_item/{item.id}").json() - assert detail["summary"]["id"] == str(item.id) - assert detail["summary"]["remoteKey"] == "ACME-5" - assert detail["summary"]["links"]["pr"] == "https://github.com/ClawHaven/acme-app/pull/8" + summary = detail["summary"] + assert summary["id"] == str(item.id) + assert summary["ticketKey"] == "ACME-5" + assert summary["ticketUrl"] == "https://linear.app/acme/issue/ACME-5/detail" + assert summary["links"]["ticket"] == "https://linear.app/acme/issue/ACME-5/detail" + assert summary["links"]["pr"] == "https://github.com/ClawHaven/acme-app/pull/8" + assert set(summary) == { + "id", + "source", + "repo", + "projectName", + "title", + "ticketKey", + "ticketUrl", + "prNumber", + "branch", + "createdAt", + "updatedAt", + "links", + } # Status is the platform's, aggregated from the item's runs — parked on a gate. assert detail["status"]["state"] == "parked" assert detail["status"]["gate"] == "review_plan" @@ -229,7 +250,7 @@ def test_update_stamps_build_run_id(druks_db): def test_timeline_shows_every_build_attempt(druks_db): # Each build attempt is its own run; the timeline shows them all, with a # failed attempt's failure carried on its run. - item = make_test_work_item(repo="ClawHaven/acme-app", title="x", remote_key="ACME-1") + item = make_test_work_item(repo="ClawHaven/acme-app", title="x", ticket_key="ACME-1") run1 = seed_build_run(druks_db, work_item_id=item.id, state="failed", failure="boom") run2 = seed_build_run(druks_db, work_item_id=item.id, state="failed") seed_call(druks_db, run1, "generate_plan", status="failed", last_error="boom") diff --git a/backend/tests/ship/test_build_dispatch.py b/backend/tests/ship/test_build_dispatch.py index 7138868e..0096d1cb 100644 --- a/backend/tests/ship/test_build_dispatch.py +++ b/backend/tests/ship/test_build_dispatch.py @@ -9,18 +9,20 @@ async def test_dispatch_pulls_cancelled_item_back_onto_the_board(druks_db, monke """A cancelled item rests in History; dispatching its build must clear the handoff status so the active board shows the run (and its gates) instead of a stale "Cancelled" row.""" - item = make_test_work_item(repo="o/r", title="t", remote_key="ACME-1") + item = make_test_work_item(repo="o/r", title="t", ticket_key="ACME-1") item.set_status(HandoffStatus.CANCELLED) seed_run(druks_db, kind=Build.kind, run_id="run-1") + started = {} async def fake_start(cls, **kwargs): + started.update(kwargs) return "run-1" monkeypatch.setattr(Build, "start", classmethod(fake_start)) run_id = await Build.dispatch( ticket={ "source": item.source, - "identifier": item.remote_key, + "identifier": item.ticket_key, "status": "Ready", "title": item.title, "url": "https://tracker.test/ACME-1", @@ -34,6 +36,17 @@ async def fake_start(cls, **kwargs): assert run_id == "run-1" assert item.build_run_id == "run-1" assert item.status is None + assert started == { + "subject": item, + "account_id": None, + "repo": "o/r", + "source": item.source, + "ticket_ref": "ACME-1", + "ticket_title": "t", + "ticket_url": "https://tracker.test/ACME-1", + "task_owner_email": None, + "task_owner_name": None, + } async def test_redispatch_to_a_new_run_clears_prior_attempt_branch_and_pr( @@ -44,7 +57,7 @@ async def test_redispatch_to_a_new_run_clears_prior_attempt_branch_and_pr( resolve this item onto the new run.""" seed_run(druks_db, kind=Build.kind, run_id="run-old") seed_run(druks_db, kind=Build.kind, run_id="run-new") - item = make_test_work_item(repo="o/r", title="t", remote_key="ACME-2") + item = make_test_work_item(repo="o/r", title="t", ticket_key="ACME-2") item.update(build_run_id="run-old", pr_number=7, branch="agent/old") async def fake_start(cls, **kwargs): @@ -54,7 +67,7 @@ async def fake_start(cls, **kwargs): await Build.dispatch( ticket={ "source": item.source, - "identifier": item.remote_key, + "identifier": item.ticket_key, "status": "Ready", "title": item.title, "url": "https://tracker.test/ACME-2", @@ -74,7 +87,7 @@ async def test_duplicate_dispatch_keeps_the_live_attempt_routing(druks_db, monke """A duplicate dispatch dedups to the live run — start() hands back its id — so the item's branch/PR must survive, or PR routing and board links break.""" seed_run(druks_db, kind=Build.kind, run_id="run-live") - item = make_test_work_item(repo="o/r", title="t", remote_key="ACME-3") + item = make_test_work_item(repo="o/r", title="t", ticket_key="ACME-3") item.update(build_run_id="run-live", pr_number=7, branch="agent/live") async def dedup_start(cls, **kwargs): @@ -84,7 +97,7 @@ async def dedup_start(cls, **kwargs): await Build.dispatch( ticket={ "source": item.source, - "identifier": item.remote_key, + "identifier": item.ticket_key, "status": "Ready", "title": item.title, "url": "https://tracker.test/ACME-3", @@ -103,7 +116,7 @@ async def dedup_start(cls, **kwargs): def test_update_clears_nullable_with_none_and_skips_omitted(druks_db) -> None: """update() tells a clear from a skip: pr_number=None clears the column, while leaving branch out preserves it.""" - item = make_test_work_item(repo="o/r", title="t", remote_key="ACME-4") + item = make_test_work_item(repo="o/r", title="t", ticket_key="ACME-4") item.update(pr_number=9, branch="agent/keep") item.update(pr_number=None) diff --git a/backend/tests/ship/test_build_durable.py b/backend/tests/ship/test_build_durable.py index d331485f..fb51edee 100644 --- a/backend/tests/ship/test_build_durable.py +++ b/backend/tests/ship/test_build_durable.py @@ -82,7 +82,7 @@ def _seed_work_item(engine, *, repo: str): project = Project(name=name) session.add(project) session.flush() - item = WorkItem(project_id=project.id, repo=repo, title="rt", remote_key=name) + item = WorkItem(project_id=project.id, repo=repo, title="rt", ticket_key=name) session.add(item) session.commit() session.refresh(item) diff --git a/backend/tests/ship/test_lane_reactions.py b/backend/tests/ship/test_lane_reactions.py index 7c6964de..65445d80 100644 --- a/backend/tests/ship/test_lane_reactions.py +++ b/backend/tests/ship/test_lane_reactions.py @@ -17,7 +17,7 @@ async def test_run_running_puts_item_back_on_board(druks_db): - item = make_test_work_item(repo="acme/widget", title="t", source="linear", remote_key="ACME-1") + item = make_test_work_item(repo="acme/widget", title="t", source="linear", ticket_key="ACME-1") item.set_status(HandoffStatus.CANCELLED) await publish(WorkflowEvent.RUNNING, subject=item.identity, kind=Build.kind) @@ -31,8 +31,8 @@ async def test_build_lifecycle_reaches_the_tracker(druks_db, monkeypatch): async def _push(self, status): pushed.append(status) - monkeypatch.setattr(WorkItem, "set_remote_status", _push) - item = make_test_work_item(repo="acme/widget", title="t", source="linear", remote_key="ACME-7") + monkeypatch.setattr(WorkItem, "set_ticket_status", _push) + item = make_test_work_item(repo="acme/widget", title="t", source="linear", ticket_key="ACME-7") subject = item.identity await publish(WorkflowEvent.RUNNING, subject=subject, kind=Build.kind) @@ -45,7 +45,7 @@ async def _push(self, status): async def test_pr_review_answers_through_the_review_gate(druks_db, monkeypatch): - item = make_test_work_item(repo="acme/widget", title="t", source="linear", remote_key="ACME-9") + item = make_test_work_item(repo="acme/widget", title="t", source="linear", ticket_key="ACME-9") item.update(pr_number=12, branch="agent/acme-9") run = Run( id=str(uuid7()), @@ -94,7 +94,7 @@ async def answer(subject, **reply): async def test_pr_open_reaches_the_work_item(druks_db): - item = make_test_work_item(repo="acme/widget", title="t", source="linear", remote_key="ACME-8") + item = make_test_work_item(repo="acme/widget", title="t", source="linear", ticket_key="ACME-8") await publish( "pr.opened", diff --git a/backend/tests/ship/test_subject_lifecycle.py b/backend/tests/ship/test_subject_lifecycle.py index bcf7cc00..ba1112d7 100644 --- a/backend/tests/ship/test_subject_lifecycle.py +++ b/backend/tests/ship/test_subject_lifecycle.py @@ -44,7 +44,7 @@ async def test_gate_answer_resumes_only_a_run_parked_on_its_gate(druks_db, monke # A subject can carry runs of several workflows at once; the gate names which one # answers, so a newer run of another kind never hides the parked one. A timed-out # run keeps its stale ``input_gate``, so parked-ness decides, not that column. - subject = _work_item(remote_key="ENG-748-A") + subject = _work_item(ticket_key="ENG-748-A") parked = _subject_run( druks_db, subject=subject, @@ -63,7 +63,7 @@ async def resume(self, **reply): await OperatorReply.answer(subject, action="approve") assert resumed == [parked.id] - timed_out = _work_item(remote_key="ENG-748-B") + timed_out = _work_item(ticket_key="ENG-748-B") _subject_run( druks_db, subject=timed_out, @@ -81,7 +81,7 @@ async def test_workflow_cancel_takes_its_own_kind_and_passes_over_idle_subjects( ): # Webhooks redeliver, and a PR can close long after its build ended: cancelling what # is already gone is the no-op the caller expects, not an error. - subject = _work_item(remote_key="ENG-748-C") + subject = _work_item(ticket_key="ENG-748-C") build = _subject_run(druks_db, subject=subject, kind=Build.kind, state="running") _subject_run(druks_db, subject=subject, kind=Profile.kind, state="running", order=1) cancelled = [] @@ -94,14 +94,14 @@ async def cancel(self, *, failure=None): await Build.cancel(subject) assert cancelled == [build.id] - idle = _work_item(remote_key="ENG-748-D") + idle = _work_item(ticket_key="ENG-748-D") _subject_run(druks_db, subject=idle, kind=Build.kind, state="finished") await Build.cancel(idle) assert cancelled == [build.id] async def test_subject_phase_reads_the_driving_running_workflow(druks_db, monkeypatch): - subject = _work_item(remote_key="ENG-748-E") + subject = _work_item(ticket_key="ENG-748-E") _subject_run( druks_db, subject=subject, diff --git a/backend/tests/ship/test_ticketing.py b/backend/tests/ship/test_ticketing.py index 36a2714d..1cc3b2bf 100644 --- a/backend/tests/ship/test_ticketing.py +++ b/backend/tests/ship/test_ticketing.py @@ -163,7 +163,7 @@ def test_linear_declares_known_exceptions(): assert httpx.HTTPError in Linear.known_exceptions -# --- WorkItem.set_remote_status: the status-push consumer ------------------- +# --- WorkItem.set_ticket_status: the status-push consumer ------------------- class _FakeTracker: @@ -186,37 +186,37 @@ async def aclose(self): @pytest.mark.asyncio -async def test_remote_state_pushes_status(druks_db, monkeypatch): +async def test_ticket_state_pushes_status(druks_db, monkeypatch): from druks.contrib.ship import models from ship.factories import make_test_work_item - item = make_test_work_item(repo="acme/widget", source="linear", remote_key="ACME-1", title="t") + item = make_test_work_item(repo="acme/widget", source="linear", ticket_key="ACME-1", title="t") fake = _FakeTracker() monkeypatch.setattr(models, "get_tracker", lambda source, **_: fake) - await item.set_remote_status(TicketStatus.DONE) + await item.set_ticket_status(TicketStatus.DONE) assert fake.calls == [("linear", "ACME-1", TicketStatus.DONE), "aclose"] @pytest.mark.asyncio -async def test_remote_state_skips_non_tracker_source(druks_db): +async def test_ticket_state_skips_non_tracker_source(druks_db): from ship.factories import make_test_work_item - item = make_test_work_item(repo="acme/widget", source="github", remote_key="#5", title="t") + item = make_test_work_item(repo="acme/widget", source="github", ticket_key="#5", title="t") # github has no tracker — a no-op that must not raise. - await item.set_remote_status(TicketStatus.DONE) + await item.set_ticket_status(TicketStatus.DONE) @pytest.mark.asyncio -async def test_remote_state_closes_on_failure(druks_db, monkeypatch): +async def test_ticket_state_closes_on_failure(druks_db, monkeypatch): from druks.contrib.ship import models from druks.core.apis.linear import LinearAPIError from ship.factories import make_test_work_item - item = make_test_work_item(repo="acme/widget", source="linear", remote_key="ACME-2", title="t") + item = make_test_work_item(repo="acme/widget", source="linear", ticket_key="ACME-2", title="t") class _Boom(_FakeTracker): known_exceptions = (LinearAPIError,) @@ -227,7 +227,7 @@ async def set_status(self, ticket, status): boom = _Boom() monkeypatch.setattr(models, "get_tracker", lambda source, **_: boom) - await item.set_remote_status(TicketStatus.DONE) + await item.set_ticket_status(TicketStatus.DONE) assert "aclose" in boom.calls # closed even on failure diff --git a/backend/tests/ship/test_webhooks_jira.py b/backend/tests/ship/test_webhooks_jira.py index 124ca03d..0ce96353 100644 --- a/backend/tests/ship/test_webhooks_jira.py +++ b/backend/tests/ship/test_webhooks_jira.py @@ -182,7 +182,7 @@ async def fake_start(cls, **kwargs): payload=_jira_payload(key="SHRP-1", status="Ready", project="Octo", labels=["Alfred"]), ) - item = WorkItem.get_for_remote_key(source="jira", remote_key="SHRP-1") + item = WorkItem.get_for_ticket_key(source="jira", ticket_key="SHRP-1") assert item.build_run_id == "run-new" assert item.repo == "octo/alfred" assert item.project_id == project.id diff --git a/backend/tests/ship/test_webhooks_pull_request.py b/backend/tests/ship/test_webhooks_pull_request.py index 39068ca1..015cca25 100644 --- a/backend/tests/ship/test_webhooks_pull_request.py +++ b/backend/tests/ship/test_webhooks_pull_request.py @@ -210,7 +210,7 @@ async def test_external_close_returns_ticket_to_resting_pool(druks_db, tmp_path, async def _record(self, status): pushed.append((self.id, status)) - monkeypatch.setattr(WorkItem, "set_remote_status", _record) + monkeypatch.setattr(WorkItem, "set_ticket_status", _record) repo, pr_number, branch = "ClawHaven/acme-app", 91, "agent/eng-20" work_item_id, _ = _park_work_item(repo=repo, pr_number=pr_number, branch=branch) @@ -233,7 +233,7 @@ async def test_external_merge_pushes_done(druks_db, tmp_path, monkeypatch): async def _record(self, status): pushed.append((self.id, status)) - monkeypatch.setattr(WorkItem, "set_remote_status", _record) + monkeypatch.setattr(WorkItem, "set_ticket_status", _record) repo, pr_number, branch = "ClawHaven/acme-app", 92, "agent/eng-21" work_item_id, _ = _park_work_item(repo=repo, pr_number=pr_number, branch=branch) @@ -325,7 +325,7 @@ async def _delete(repo, branch): async def _record(self, status): pushed.append(status) - monkeypatch.setattr(WorkItem, "set_remote_status", _record) + monkeypatch.setattr(WorkItem, "set_ticket_status", _record) repo, pr_number, branch = "ClawHaven/acme-app", 95, "agent/eng-24" work_item_id, _ = _park_work_item(repo=repo, pr_number=pr_number, branch=branch) diff --git a/backend/tests/test_work_item_ticket_migration.py b/backend/tests/test_work_item_ticket_migration.py new file mode 100644 index 00000000..158cc6d5 --- /dev/null +++ b/backend/tests/test_work_item_ticket_migration.py @@ -0,0 +1,129 @@ +import os +from pathlib import Path + +import psycopg +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect, text + +_ALEMBIC_INI = Path(__file__).resolve().parent.parent / "alembic.ini" +_PG_BASE = os.environ.get("DRUKS_TEST_PG", "postgresql://druks:druks@localhost:5432") +_DATABASE_NAME = "druks_work_item_ticket_migration_test" +_DATABASE_URL = ( + f"{_PG_BASE.replace('postgresql://', 'postgresql+psycopg://')}/{_DATABASE_NAME}" +) + + +def _postgres_is_reachable() -> bool: + try: + psycopg.connect(f"{_PG_BASE}/postgres", connect_timeout=2).close() + return True + except psycopg.Error: + return False + + +def _config() -> Config: + config = Config(str(_ALEMBIC_INI)) + config.set_main_option("sqlalchemy.url", _DATABASE_URL) + return config + + +def _recreate_database() -> None: + with psycopg.connect(f"{_PG_BASE}/postgres", autocommit=True) as connection: + connection.execute(f'DROP DATABASE IF EXISTS "{_DATABASE_NAME}" WITH (FORCE)') + connection.execute(f'CREATE DATABASE "{_DATABASE_NAME}"') + + +def _drop_database() -> None: + with psycopg.connect(f"{_PG_BASE}/postgres", autocommit=True) as connection: + connection.execute(f'DROP DATABASE IF EXISTS "{_DATABASE_NAME}" WITH (FORCE)') + + +@pytest.mark.skipif(not _postgres_is_reachable(), reason="test Postgres not reachable") +def test_work_item_ticket_fields_upgrade_and_downgrade_preserve_schema_and_data(): + _recreate_database() + engine = create_engine(_DATABASE_URL) + try: + command.upgrade(_config(), "d3a5c71f8e40") + with engine.begin() as connection: + connection.execute( + text( + """ + INSERT INTO projects (id, name, created_at, updated_at) + VALUES (1, 'druks', now(), now()) + """ + ) + ) + connection.execute( + text( + """ + INSERT INTO work_items ( + id, project_id, source, title, remote_key, remote_url, repo, + created_at, updated_at + ) + VALUES ( + 42, 1, 'linear', 'Rename the fields', 'ENG-755', + 'https://linear.app/fellaworks/issue/ENG-755/example', + 'czpython/druks', now(), now() + ) + """ + ) + ) + + command.upgrade(_config(), "head") + with engine.connect() as connection: + columns = { + column["name"]: column for column in inspect(connection).get_columns("work_items") + } + indexes = { + index["name"]: index for index in inspect(connection).get_indexes("work_items") + } + row = connection.execute( + text("SELECT ticket_key, ticket_url FROM work_items WHERE id = 42") + ).one() + + assert "remote_key" not in columns + assert "remote_url" not in columns + assert columns["ticket_key"]["nullable"] is False + assert columns["ticket_url"]["nullable"] is True + assert "work_items_remote_unique" not in indexes + assert indexes["work_items_ticket_unique"]["column_names"] == [ + "source", + "ticket_key", + ] + assert indexes["work_items_ticket_unique"]["unique"] is True + assert tuple(row) == ( + "ENG-755", + "https://linear.app/fellaworks/issue/ENG-755/example", + ) + + command.downgrade(_config(), "d3a5c71f8e40") + with engine.connect() as connection: + columns = { + column["name"]: column for column in inspect(connection).get_columns("work_items") + } + indexes = { + index["name"]: index for index in inspect(connection).get_indexes("work_items") + } + row = connection.execute( + text("SELECT remote_key, remote_url FROM work_items WHERE id = 42") + ).one() + + assert "ticket_key" not in columns + assert "ticket_url" not in columns + assert columns["remote_key"]["nullable"] is False + assert columns["remote_url"]["nullable"] is True + assert "work_items_ticket_unique" not in indexes + assert indexes["work_items_remote_unique"]["column_names"] == [ + "source", + "remote_key", + ] + assert indexes["work_items_remote_unique"]["unique"] is True + assert tuple(row) == ( + "ENG-755", + "https://linear.app/fellaworks/issue/ENG-755/example", + ) + finally: + engine.dispose() + _drop_database() diff --git a/frontend/src/extensions/ship/AgentCallPage.tsx b/frontend/src/extensions/ship/AgentCallPage.tsx index 424b3297..855b20f1 100644 --- a/frontend/src/extensions/ship/AgentCallPage.tsx +++ b/frontend/src/extensions/ship/AgentCallPage.tsx @@ -37,7 +37,7 @@ export function AgentCallPage({ workItemId, runId }: Props) { query.data ? agentCallPath( workItemId, - query.data.summary.remoteKey, + query.data.summary.ticketKey, query.data.summary.title, query.data.call.id, ) @@ -96,10 +96,10 @@ function RunView({ / - {summary.remoteKey} + {summary.ticketKey} / #{runId.slice(0, 8)} diff --git a/frontend/src/extensions/ship/WorkItemPage.tsx b/frontend/src/extensions/ship/WorkItemPage.tsx index d2fad56c..81fc10c8 100644 --- a/frontend/src/extensions/ship/WorkItemPage.tsx +++ b/frontend/src/extensions/ship/WorkItemPage.tsx @@ -37,7 +37,7 @@ export function WorkItemPage({ workItemId }: Props) { }) const data = query.data useCanonicalPath( - data ? workItemPath(data.summary.id, data.summary.remoteKey, data.summary.title) : null, + data ? workItemPath(data.summary.id, data.summary.ticketKey, data.summary.title) : null, ) // Push-driven cache: the detail stream re-emits the whole snapshot on any @@ -270,15 +270,15 @@ function InfoPanel({
source - + {wi.links.ticket ? ( {wi.source} - {` · ${wi.remoteKey}`} + {` · ${wi.ticketKey}`} ) : ( - `${wi.source}${wi.remoteKey ? ` · ${wi.remoteKey}` : ''}` + `${wi.source}${wi.ticketKey ? ` · ${wi.ticketKey}` : ''}` )}
@@ -437,8 +437,8 @@ function RightPane({ data, selection }: { data: WorkItemDetail; selection: Selec return ( <>
-
- {wi.remoteKey} +
+ {wi.ticketKey} {wi.title}
@@ -539,7 +539,7 @@ function RunHeader({ diff --git a/frontend/src/extensions/ship/WorkItemsPage.tsx b/frontend/src/extensions/ship/WorkItemsPage.tsx index da022295..6f02902a 100644 --- a/frontend/src/extensions/ship/WorkItemsPage.tsx +++ b/frontend/src/extensions/ship/WorkItemsPage.tsx @@ -26,7 +26,7 @@ function matchesQuery(row: WorkItemRow, q: string): boolean { if (!q.trim()) return true const needle = q.toLowerCase() const { summary, status } = row - return `${summary.title} ${summary.remoteKey} ${summary.repo} ${statusLine(status)}` + return `${summary.title} ${summary.ticketKey} ${summary.repo} ${statusLine(status)}` .toLowerCase() .includes(needle) } @@ -48,7 +48,7 @@ function WorkItemRowView({ return (
onOpen(row)}> - + {wi.title} diff --git a/frontend/src/extensions/ship/api.ts b/frontend/src/extensions/ship/api.ts index 32835b51..ad9935cc 100644 --- a/frontend/src/extensions/ship/api.ts +++ b/frontend/src/extensions/ship/api.ts @@ -42,8 +42,8 @@ export interface WorkItemSummary extends SubjectSummary { repo: string projectName: string title: string - remoteKey: string - remoteUrl?: string | null + ticketKey: string + ticketUrl?: string | null prNumber?: number | null branch?: string | null createdAt: string diff --git a/frontend/src/extensions/ship/slug.ts b/frontend/src/extensions/ship/slug.ts index b1d73d3d..f1edb7fa 100644 --- a/frontend/src/extensions/ship/slug.ts +++ b/frontend/src/extensions/ship/slug.ts @@ -76,7 +76,7 @@ export function workItemPath( } export function workItemPathFromSummary(item: WorkItemSummary): string { - return workItemPath(item.id, item.remoteKey, item.title) + return workItemPath(item.id, item.ticketKey, item.title) } export function dashboardItemPath(item: DashboardItem): string { @@ -87,10 +87,10 @@ export function dashboardItemPath(item: DashboardItem): string { export function agentCallPath( workItemId: number | string, - workItemRemoteKey: string | null | undefined, + workItemTicketKey: string | null | undefined, workItemTitle: string | null | undefined, callId: string, ): string { // AgentCall id is uuid7 — bare uuid as the URL handle, no slug tail. - return `${workItemPath(workItemId, workItemRemoteKey, workItemTitle)}/agent-calls/${callId}` + return `${workItemPath(workItemId, workItemTicketKey, workItemTitle)}/agent-calls/${callId}` } From 91bb6cb70adf180535b05bfc719affbf63d5ea6a Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 27 Jul 2026 14:13:32 +0000 Subject: [PATCH 2/3] Align dashboard ticket naming and trim rename tests --- backend/druks/contrib/ship/schemas.py | 4 +- backend/tests/conftest.py | 1 - backend/tests/ship/test_api_work_items.py | 14 -- backend/tests/ship/test_build_dispatch.py | 14 +- .../tests/test_work_item_ticket_migration.py | 129 ------------------ frontend/src/extensions/ship/HistoryPage.tsx | 4 +- frontend/src/extensions/ship/api.ts | 2 +- frontend/src/extensions/ship/slug.ts | 2 +- 8 files changed, 9 insertions(+), 161 deletions(-) delete mode 100644 backend/tests/test_work_item_ticket_migration.py diff --git a/backend/druks/contrib/ship/schemas.py b/backend/druks/contrib/ship/schemas.py index fcc85661..de57890c 100644 --- a/backend/druks/contrib/ship/schemas.py +++ b/backend/druks/contrib/ship/schemas.py @@ -137,7 +137,7 @@ def from_work_item(cls, item: WorkItem) -> "WorkItemSummary": class DashboardItem(BaseResponse): key: str source_id: int | str - ticket_ref: str + ticket_key: str title: str repo: str | None = None pr_number: int | None = None @@ -154,7 +154,7 @@ def from_work_item(cls, item: WorkItem) -> "DashboardItem": return cls( key=f"code:{item.id}", source_id=item.id, - ticket_ref=item.ticket_key, + ticket_key=item.ticket_key, title=item.title, repo=item.repo, pr_number=item.pr_number, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index def5d314..b2f41475 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -114,7 +114,6 @@ def registry_state(): "test_harness_login_persistence", "test_extension_migrations", "test_proof_extension_migration", - "test_work_item_ticket_migration", } diff --git a/backend/tests/ship/test_api_work_items.py b/backend/tests/ship/test_api_work_items.py index a1f5e299..f84cea30 100644 --- a/backend/tests/ship/test_api_work_items.py +++ b/backend/tests/ship/test_api_work_items.py @@ -109,20 +109,6 @@ def test_subject_detail_composes_summary_status_and_timeline(client: TestClient, assert summary["ticketUrl"] == "https://linear.app/acme/issue/ACME-5/detail" assert summary["links"]["ticket"] == "https://linear.app/acme/issue/ACME-5/detail" assert summary["links"]["pr"] == "https://github.com/ClawHaven/acme-app/pull/8" - assert set(summary) == { - "id", - "source", - "repo", - "projectName", - "title", - "ticketKey", - "ticketUrl", - "prNumber", - "branch", - "createdAt", - "updatedAt", - "links", - } # Status is the platform's, aggregated from the item's runs — parked on a gate. assert detail["status"]["state"] == "parked" assert detail["status"]["gate"] == "review_plan" diff --git a/backend/tests/ship/test_build_dispatch.py b/backend/tests/ship/test_build_dispatch.py index 0096d1cb..396f6627 100644 --- a/backend/tests/ship/test_build_dispatch.py +++ b/backend/tests/ship/test_build_dispatch.py @@ -36,17 +36,9 @@ async def fake_start(cls, **kwargs): assert run_id == "run-1" assert item.build_run_id == "run-1" assert item.status is None - assert started == { - "subject": item, - "account_id": None, - "repo": "o/r", - "source": item.source, - "ticket_ref": "ACME-1", - "ticket_title": "t", - "ticket_url": "https://tracker.test/ACME-1", - "task_owner_email": None, - "task_owner_name": None, - } + assert started["ticket_ref"] == "ACME-1" + assert started["ticket_title"] == "t" + assert started["ticket_url"] == "https://tracker.test/ACME-1" async def test_redispatch_to_a_new_run_clears_prior_attempt_branch_and_pr( diff --git a/backend/tests/test_work_item_ticket_migration.py b/backend/tests/test_work_item_ticket_migration.py deleted file mode 100644 index 158cc6d5..00000000 --- a/backend/tests/test_work_item_ticket_migration.py +++ /dev/null @@ -1,129 +0,0 @@ -import os -from pathlib import Path - -import psycopg -import pytest -from alembic import command -from alembic.config import Config -from sqlalchemy import create_engine, inspect, text - -_ALEMBIC_INI = Path(__file__).resolve().parent.parent / "alembic.ini" -_PG_BASE = os.environ.get("DRUKS_TEST_PG", "postgresql://druks:druks@localhost:5432") -_DATABASE_NAME = "druks_work_item_ticket_migration_test" -_DATABASE_URL = ( - f"{_PG_BASE.replace('postgresql://', 'postgresql+psycopg://')}/{_DATABASE_NAME}" -) - - -def _postgres_is_reachable() -> bool: - try: - psycopg.connect(f"{_PG_BASE}/postgres", connect_timeout=2).close() - return True - except psycopg.Error: - return False - - -def _config() -> Config: - config = Config(str(_ALEMBIC_INI)) - config.set_main_option("sqlalchemy.url", _DATABASE_URL) - return config - - -def _recreate_database() -> None: - with psycopg.connect(f"{_PG_BASE}/postgres", autocommit=True) as connection: - connection.execute(f'DROP DATABASE IF EXISTS "{_DATABASE_NAME}" WITH (FORCE)') - connection.execute(f'CREATE DATABASE "{_DATABASE_NAME}"') - - -def _drop_database() -> None: - with psycopg.connect(f"{_PG_BASE}/postgres", autocommit=True) as connection: - connection.execute(f'DROP DATABASE IF EXISTS "{_DATABASE_NAME}" WITH (FORCE)') - - -@pytest.mark.skipif(not _postgres_is_reachable(), reason="test Postgres not reachable") -def test_work_item_ticket_fields_upgrade_and_downgrade_preserve_schema_and_data(): - _recreate_database() - engine = create_engine(_DATABASE_URL) - try: - command.upgrade(_config(), "d3a5c71f8e40") - with engine.begin() as connection: - connection.execute( - text( - """ - INSERT INTO projects (id, name, created_at, updated_at) - VALUES (1, 'druks', now(), now()) - """ - ) - ) - connection.execute( - text( - """ - INSERT INTO work_items ( - id, project_id, source, title, remote_key, remote_url, repo, - created_at, updated_at - ) - VALUES ( - 42, 1, 'linear', 'Rename the fields', 'ENG-755', - 'https://linear.app/fellaworks/issue/ENG-755/example', - 'czpython/druks', now(), now() - ) - """ - ) - ) - - command.upgrade(_config(), "head") - with engine.connect() as connection: - columns = { - column["name"]: column for column in inspect(connection).get_columns("work_items") - } - indexes = { - index["name"]: index for index in inspect(connection).get_indexes("work_items") - } - row = connection.execute( - text("SELECT ticket_key, ticket_url FROM work_items WHERE id = 42") - ).one() - - assert "remote_key" not in columns - assert "remote_url" not in columns - assert columns["ticket_key"]["nullable"] is False - assert columns["ticket_url"]["nullable"] is True - assert "work_items_remote_unique" not in indexes - assert indexes["work_items_ticket_unique"]["column_names"] == [ - "source", - "ticket_key", - ] - assert indexes["work_items_ticket_unique"]["unique"] is True - assert tuple(row) == ( - "ENG-755", - "https://linear.app/fellaworks/issue/ENG-755/example", - ) - - command.downgrade(_config(), "d3a5c71f8e40") - with engine.connect() as connection: - columns = { - column["name"]: column for column in inspect(connection).get_columns("work_items") - } - indexes = { - index["name"]: index for index in inspect(connection).get_indexes("work_items") - } - row = connection.execute( - text("SELECT remote_key, remote_url FROM work_items WHERE id = 42") - ).one() - - assert "ticket_key" not in columns - assert "ticket_url" not in columns - assert columns["remote_key"]["nullable"] is False - assert columns["remote_url"]["nullable"] is True - assert "work_items_ticket_unique" not in indexes - assert indexes["work_items_remote_unique"]["column_names"] == [ - "source", - "remote_key", - ] - assert indexes["work_items_remote_unique"]["unique"] is True - assert tuple(row) == ( - "ENG-755", - "https://linear.app/fellaworks/issue/ENG-755/example", - ) - finally: - engine.dispose() - _drop_database() diff --git a/frontend/src/extensions/ship/HistoryPage.tsx b/frontend/src/extensions/ship/HistoryPage.tsx index 30da633e..0e705c05 100644 --- a/frontend/src/extensions/ship/HistoryPage.tsx +++ b/frontend/src/extensions/ship/HistoryPage.tsx @@ -45,7 +45,7 @@ export function HistoryPage() { if (statusFilter !== 'all' && item.status !== statusFilter) return false if (query.trim()) { const q = query.toLowerCase() - if (!`${item.title} ${item.ticketRef ?? ''}`.toLowerCase().includes(q)) return false + if (!`${item.title} ${item.ticketKey ?? ''}`.toLowerCase().includes(q)) return false } return true }) @@ -125,7 +125,7 @@ export function HistoryPage() { onClick={() => navigate(dashboardItemPath(item))} > - {item.ticketRef ?? `#${item.sourceId}`} + {item.ticketKey ?? `#${item.sourceId}`} {item.title} diff --git a/frontend/src/extensions/ship/api.ts b/frontend/src/extensions/ship/api.ts index ad9935cc..ef8862e6 100644 --- a/frontend/src/extensions/ship/api.ts +++ b/frontend/src/extensions/ship/api.ts @@ -55,7 +55,7 @@ export interface DashboardItem { /** Stable id like "code:37" — used for React keys and SSE diffs. */ key: string sourceId: number - ticketRef?: string | null + ticketKey?: string | null title: string repo?: string | null prNumber?: number | null diff --git a/frontend/src/extensions/ship/slug.ts b/frontend/src/extensions/ship/slug.ts index f1edb7fa..4f56ca1b 100644 --- a/frontend/src/extensions/ship/slug.ts +++ b/frontend/src/extensions/ship/slug.ts @@ -82,7 +82,7 @@ export function workItemPathFromSummary(item: WorkItemSummary): string { export function dashboardItemPath(item: DashboardItem): string { // History items are all work items now (scope is a work item, not a // separate kind), so this always resolves to the work-item page. - return workItemPath(item.sourceId, item.ticketRef, item.title) + return workItemPath(item.sourceId, item.ticketKey, item.title) } export function agentCallPath( From 2e1cc84e754062c85a7cff8d916daa4cd5a0acca Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 27 Jul 2026 16:30:16 +0200 Subject: [PATCH 3/3] Every work item has a ticket key; the wire mirror says so DashboardItem.ticket_key is a required column-backed value, so the frontend mirror declared it optional and carried fallbacks that could never fire. --- frontend/src/extensions/ship/HistoryPage.tsx | 4 ++-- frontend/src/extensions/ship/api.ts | 2 +- frontend/src/extensions/ship/slug.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/extensions/ship/HistoryPage.tsx b/frontend/src/extensions/ship/HistoryPage.tsx index 0e705c05..ee693630 100644 --- a/frontend/src/extensions/ship/HistoryPage.tsx +++ b/frontend/src/extensions/ship/HistoryPage.tsx @@ -45,7 +45,7 @@ export function HistoryPage() { if (statusFilter !== 'all' && item.status !== statusFilter) return false if (query.trim()) { const q = query.toLowerCase() - if (!`${item.title} ${item.ticketKey ?? ''}`.toLowerCase().includes(q)) return false + if (!`${item.title} ${item.ticketKey}`.toLowerCase().includes(q)) return false } return true }) @@ -125,7 +125,7 @@ export function HistoryPage() { onClick={() => navigate(dashboardItemPath(item))} > - {item.ticketKey ?? `#${item.sourceId}`} + {item.ticketKey} {item.title} diff --git a/frontend/src/extensions/ship/api.ts b/frontend/src/extensions/ship/api.ts index ef8862e6..dd2a6705 100644 --- a/frontend/src/extensions/ship/api.ts +++ b/frontend/src/extensions/ship/api.ts @@ -55,7 +55,7 @@ export interface DashboardItem { /** Stable id like "code:37" — used for React keys and SSE diffs. */ key: string sourceId: number - ticketKey?: string | null + ticketKey: string title: string repo?: string | null prNumber?: number | null diff --git a/frontend/src/extensions/ship/slug.ts b/frontend/src/extensions/ship/slug.ts index 4f56ca1b..d7f6977d 100644 --- a/frontend/src/extensions/ship/slug.ts +++ b/frontend/src/extensions/ship/slug.ts @@ -51,7 +51,7 @@ function joinLabels(...parts: Array): string { /** * Build the canonical ``-`` segment. Falls back to bare * ```` when there's no usable label text (no title and no ticket - * ref); the router parses the leading int regardless. + * key); the router parses the leading int regardless. */ export function itemSlug( id: number | string, @@ -69,10 +69,10 @@ export function itemSlug( export function workItemPath( id: number | string, - ticketRef?: string | null, + ticketKey?: string | null, title?: string | null, ): string { - return `/work-items/${itemSlug(id, ticketRef, title)}` + return `/work-items/${itemSlug(id, ticketKey, title)}` } export function workItemPathFromSummary(item: WorkItemSummary): string {