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
46 changes: 23 additions & 23 deletions backend/druks/contrib/ship/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
Expand All @@ -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]
Expand All @@ -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(
Expand All @@ -223,17 +223,17 @@ 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()
item = cls(
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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -356,15 +356,15 @@ 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)
except (ValueError, *tracker.known_exceptions):
logger.warning(
"Could not sync %s ticket %s to %s.",
self.source,
self.remote_key,
self.ticket_key,
status.value,
exc_info=True,
)
Expand All @@ -373,16 +373,16 @@ 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,
project_id: int = _KEEP,
) -> 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:
Expand Down
14 changes: 7 additions & 7 deletions backend/druks/contrib/ship/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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_key=item.ticket_key,
title=item.title,
repo=item.repo,
pr_number=item.pr_number,
Expand Down
4 changes: 2 additions & 2 deletions backend/druks/contrib/ship/subscribers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 10 additions & 10 deletions backend/druks/contrib/ship/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,18 @@ 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:
item = WorkItem.create(
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:
Expand All @@ -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"],
)
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
2 changes: 1 addition & 1 deletion backend/tests/ship/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
17 changes: 12 additions & 5 deletions backend/tests/ship/test_api_work_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -99,9 +103,12 @@ 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"
# 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"
Expand Down Expand Up @@ -229,7 +236,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")
Expand Down
Loading