A re-pitch of the workflow surface. Replaces the rejected "Repos → Issues drill-down" with a single cross-org triage view sized for 100s of repos, 1000s of issues, 50+ users across multiple orgs.
Slice 1, slice 1.5 (P0 inbox bugs), and slice 2 are all landed
on codeless/triage-slice-2. The triage workbench at
#/workflow/triage now ships with: multi-identity manager,
Teams + People rail, full CAS write path (POST /issues,
PATCH /issues/by-id, POST /issues/by-id/comments),
GET /issues/by-id/timeline, GET /repos/by-id/sync-status +
POST /repos/by-id/sync, GET /reports/issues, start / due
dates (PATCH /issues/by-id/dates + GET /issues/{id}/dates
with best-effort Projects v2 mirror), POST /me/inbox/bulk,
keyset-paginated /me/queue, every new handler registered in
DevPulseApi, plus the FE polish set (dates picker + Due
column + Due-this-week / Overdue smart views, resizable pane
splitters, dark-mode pass, PinDto.label resolved client-side
against GET /repos, ⌘K palette, group-by / sort dropdowns,
bulk inbox actions, identity manager at #/account/identities).
Tech debt cleared:
- The three pre-existing
dp-fetchertest failures (reconciler::tests::not_modified_keeps_since_and_etag_and_writes_no_events,reconciler::tests::pr_list_synthesises_deliveries_that_flow_through_apply_path,phase2_smoke::missed_webhook_detected_by_reconciler) were caused by the §13.7 org-scoped pass (teams / members) added toReconciler::do_tick: the wiremock fixtures only mount/repos/octo/hello/pulls, so each tick produced two unmocked-endpoint errors. Fixed in place by calling.with_org_kinds(&[])in the reconciler test fixture and at everyReconciler::newsite inphase2_smoke.rs(the Backfill sites already only takewith_kinds). All three tests are green; no quarantine needed. PinDto.labelshipped via client-side join againstGET /repos(stage 10/11) — seetriage-page.tsx.
Verified (stage 12):
cargo test --workspace— all green (dp-fetcher87/87, no failures anywhere).cd frontend && pnpm typecheck— clean.make build— clean (single 1.09 MB bundle warning is pre-existing; slice 3 can chase code-splitting if needed).
Explicitly deferred to slice 3 (do not retry in slice 2):
- Projects v2 pull-back — the slice-2 mirror is write-only
(
addProjectV2ItemById+updateProjectV2ItemFieldValue), there is no inbound sync of project-side date edits yet. Stub task type exists; populating it needs the §6 projection table and a webhook subscription. - Saved-view CRUD as a first-class object — slice 2 reuses
pins + tags as the saved-view substrate per §14.6. A real
dp_saved_viewstable with shareable URLs is slice 3. - SLA / digest / on-call rotation handoff — none of the §3.11 "ageing" or notification surfaces are wired; the inbox is pull-only.
- Mentions ingestion —
Mentionedsmart view is still a slice-3 placeholder; needs the §6dp_issue_mentionsprojection populated by a worker pass over comment bodies. - Bundle code-splitting and the Reports charting library pick — deferred per §11 of the original plan.
The slice-3 brief lives in §15 below.
Slice 1 of the §8 plan landed, plus the first half of slice 2.
The triage shell is now reachable at #/workflow/triage (also
the default #/workflow redirect) and the old Repos / Issues
pages are still mounted as siblings under the Workflow group in
the sidebar.
Backend (cargo build + tests green):
- Migration
crates/dp-store-pg/migrations/dp/0011_triage_spine.sql— addsdp_issues.author/state_reason, GIN indexes onlabels/assignees, and the newdp_user_issue_statetable (user_id, issue_id, last_seen_version BIGINT, status TEXT CHECK ('inbox','snoozed','done'), snoozed_until, updated_at) with partial indexes. dp-domain— newinbox.rs(InboxStatus,UserIssueState,InboxIssueRow);IssueListFilterextended with array filters (repo_ids,org_ids,assignees,labels,author,state_reason,updated_since,untriaged_only) plus the four newStoremethods (list_inbox_issues,count_inbox_issues,mark_issues_seen,set_inbox_state).dp-store-pg— implementations for all new methods; 15-bind guarded SQL ((cardinality($N::uuid[]) = 0 OR ... = ANY($N))($N::jsonb IS NULL OR labels @> $N));row_to_inbox_issue_rowdecoder computesunread = version > last_seen_version.
dp-rest—IssueDto.unread(optional),ListIssuesQuerywith CSV array deserializers,filter_from_queryhelper,GET /me/queue(issues_read::me_queue), newinbox.rsexposingPOST /me/inbox/seen(cap 200) andPATCH /me/inbox/{issue_id}, both gated on(issues, read).dp-server—inbox_routermerged into protected; also registered the missingissues+tagsresources inregister_dev_pulse_resourcesso the policy engine stops returningunknown_resourcefor the new routes.
Frontend (pnpm typecheck + make build green):
frontend/src/api/client.ts— extendedIssueDto/IssueListItemwith optionalunread, newInboxStatusSchema/UserIssueStateDtoSchema/MarkSeenRequest/SetInboxStateRequest,buildIssueListQshelper,sendNoContentprivate,listMyQueue/markInboxSeen/setInboxStatemethods.frontend/src/workflow/mocks.ts—mockInboxStatemap +mockListMyQueue/mockMarkInboxSeen/mockSetInboxStatefixtures.frontend/src/workflow/use-workflow-data.ts—useMyQueue,useMarkInboxSeen(silent failure),useSetInboxState, plusworkflowKeys.myQueue.frontend/src/workflow/issues-page.tsx— addedview(list/inbox/untriaged) URL state, view toggle rail, unread-dot column, inline Snooze 1d / Done buttons, mark-seen-on-open, keyboard shortcuts (jkEnterEsceh?) + help dialog.frontend/src/workflow/triage-page.tsx(new, ~570 lines) — Linear-style 3-pane shell: left rail VIEWS (mine/untriaged/snoozed/all) + Pinned repos, middle dense<ol>/<li>with unread dots / state pills / hover-revealed inbox actions, right peek panel (xl only) embeddingIssueEditCardinline (no Sheet flash), keyboard parity with the issues page, help dialog.frontend/src/routes.ts—WorkflowTabextended withtriage(now the default),TriageView+workflowTriageRoute(...)helper.frontend/src/app.tsx—WorkflowPaneswitch routestriage→<TriagePage />.frontend/src/layout/app-shell.tsx—NAV_MAINexposes Triage above Repos / Issues (IconInbox); the live inbox badge follows#/workflow/triage(testidworkflow-triage-inbox-badge);WORKFLOW_TITLEcarriestriage: "Triage".frontend/src/components/nav-main.tsx—NavMainSubItemgains an optionalbadge?: ReactNodeslot (testidnav-sub-badge), right-aligned.frontend/vite.config.ts— proxy table now forwards/issues,/repos,/me,/pins,/tagsto dp-server on:8731. Without this the dev server returned the SPAindex.htmlfor/me/queueand the client failed withUnexpected token '<', "<!doctype "... is not valid JSON.
cargo test -p dp-domain -p dp-rest -p dp-store-pg -p dp-server --lib— all green.cd frontend && pnpm typecheck— clean.make build— clean.
Backend (slice 1.5 — P0 bugs found in peer review):
- Fix
list_inbox_issuesto actually return mine. Today's query returns "all open everywhere" rather than the caller's identity-set. Until this lands, every screenshot lies. Block on §3.0 identity-set semantics so we don't rewrite the predicate twice. - Unread must not count my own writes. §3.8: split
dp_issues.versionso unread compares against anexternal_versionthat only bumps on reconciler-applied changes — not on the caller's own CAS commits. Today alice edits a row, walks away, comes back, and sees her own edit as unread. Migration in §6. -
POST /me/inbox/seenbody must carry the version the client actually saw ([{ issue_id, version }]), and the server upsertslast_seen_version = LEAST(version_seen, current_external_version). The current[Uuid]shape races any write that lands between row-open and bump.
Backend (slice 2):
- Multi-identity model (NEW — see §3.0). Add
dp_user_identities(user_id, github_user_id, github_login, linked_at, verified_via)migration; backfill from the existingdp_users.github_idprimary identity. ExtendGithubOrgsStamperto stampPrincipal.extra.github.{logins,user_ids,orgs}(sets, not scalars). Addidentitiesresource toregister_dev_pulse_resources. Add endpoints:GET /me/identities,POST /me/identities/link/{start, callback},DELETE /me/identities/{github_user_id}(refuse last-identity removal),POST /admin/users/{user_id}/identities. Adddev-pulse link-identityCLI for admin link. - Tighten "My queue" to identity-set semantics.
list_inbox_issuescurrently returns every open issue with nodone/snoozedrow — i.e. it's "all open everywhere" not "mine". Rewrite to: open AND (assignees ?| caller.github_loginsORauthor = ANY(caller.github_logins)OR repo is indp_user_pins(kind='repo')OR repo is linked to adp_tagsrow the caller owns/follows) AND inbox status filter. The §5.4 SQL sketch is the target shape. - Start / due dates on issues (NEW — see §3.10). Add
dp_issue_dates(per-issue,start_date DATE,due_date DATE, both optional + checkstart <= due) and the optionaldp_repo_project_linktable (Projects v2 board node id + the two Date field node ids the operator picks as Start / Due). ImplementPATCH /issues/{id}/dates(local-only write; fail-soft mirror to Projects v2 via GraphQLaddProjectV2ItemById+updateProjectV2ItemFieldValuewhen the repo is linked). ExtendIssueDtowith optionalstart_date/due_date. AddDue this week+Overduesmart views and the listDuecolumn. Defer the pull-back path (Projects v2 →dp_issue_dates) to slice 3 — slice 2 is push-only. Native-GitHub story: plain issues have no date fields; Milestones share onedue_onacross many issues; only Projects v2 has per-issue dates and only via GraphQL. The hybrid above keeps dates optional everywhere and lets operators opt into mirror per repo. - Timeline endpoint:
GET /issues/{id}/timelinebacked bydp_activity_events+dp_event_actorsso the peek panel can stop pretending it has comments. - Sync visibility endpoints: per-repo last-synced-at + reconciler-run summaries so the list can render the "synced 4m ago" affordances from the §14.3 mock.
- Tag-link surfaces in the issue list (the §6 saved-views
story leans on
dp_tag_linksjoins; not yet exposed inIssueDto). - OpenAPI:
me_queue/inboxhandlers are still absent fromDevPulseApi. Matches the existinglist_issuesomission but should be closed before slice 3.
Frontend (slice 2 finish + slice 3):
- Identity manager page (
#/account/identities, slice 2) — list linked GitHub accounts with login + linked-at, "Link another GitHub account" button (kicks off the OAuth round-trip from §3.0.2), unlink confirm dialog. Surface the live identity set in the user menu so operators can see "you are alice-acme + alice + alice-oncall" without leaving the page. - Command palette
⌘K(§14.5) — jump-to repo / issue / saved view; currently only?exists. - Snoozed view backing:
TriagePage::filterFor("snoozed")currently returns an empty synthetic page; needs a realGET /me/inbox?status=snoozed(or astatusfilter on/me/queue) and a wake-up affordance. - Group-by + sort dropdowns on the middle pane (the §14.3 "phoenix/api · 12 open · synced 4m ago" grouped rows from the mock; right now the list is flat).
- Saved views = pins/tags rendered as first-class entries in the left rail with their own counts (§14.6). The Pinned repos section exists; tags + count badges do not.
- Resizable pane splitters (§14.1). Current shell uses a
fixed
grid-cols-[14rem_minmax(28rem,1fr)_minmax(28rem,32rem)]. - Bulk actions on selection (
xto toggle, thene/hon a multi-select). Single-row actions only today. - Reports surface that finally pays back
dp_activity_events(the §1 promise — deferred to slice 3 in the PR plan). - Polish: dark-mode pass on the new TriagePage; the inline
peek panel uses tokens but hasn't been eyeballed against
the theme tokens used in
IssueEditCard.
Tech debt found during the slice:
- The
dp-fetchertest failures observed at the start of the session were pre-existing local mods tocrates/dp-fetcher/src/{client/mod.rs, reconciler/mod.rs, reconciler/synth.rs}— unrelated to triage but flagged as "fixed" by the operator. Worth a follow-up to confirm CI is green onmain. PinDtohas no human-readablelabelfield, so the left-rail Pinned repos entries currently showtarget_id.slice(0,8)as a placeholder. Either denormalize the repo slug onto the pin row or do a client-side join against the repo list.
The drill-down I built (frontend/src/workflow/repos-page.tsx → frontend/src/workflow/issues-page.tsx) is a worse GitHub. It makes the operator click through the org and the repo before they can do work. Linear's lesson: never make the user navigate to their work — start them on it.
The replacement is a single Triage page, landing route
#/workflow, three resizable panes:
┌──────────────┬──────────────────────────────────────────────┬────────────────┐
│ Views │ filter pills • group ▾ • sort ▾ • ⌘K │ Peek panel │
│ │──────────────────────────────────────────────│ │
│ ★ My queue 7 │ ▾ phoenix/api · 12 open · synced 4m ago │ body │
│ Assigned │ #482 Flaky retry on 503 ● open @alice 2h│ + timeline │
│ Untriaged │ • #481 Webhook backfill ● open @bob 4h│ + comment box │
│ Mentioned │ ▾ phoenix/web · 5 open · synced 11m ago │ │
│ Created │ #99 401 on logout ● open — 6h│ │
│ Pins │ │ │
│ nube/api │ 1–25 of 187 [Prev] [Next] │ │
│ Tags │ │ │
│ on-call │ │ │
│ Orgs (3) │ │ │
└──────────────┴──────────────────────────────────────────────┴────────────────┘
(Bold dot = unread since the user's last_seen_version; the
7 next to My queue is the inbox badge.)
Everything dev-pulse already has (cross-org reach, the §8.2 CAS
write path, pins, tags, dp_activity_events) finally has a
single surface that uses it — plus three small additions that
turn it from "a list page" into Linear: a per-user inbox
(unread / snoozed / done), per-repo sync visibility, and a
Reports surface that finally pays back dp_activity_events.
This proposal realizes the workbench already specified, it does not redesign it. The spec exists; the implementation has been wrong.
| Doc / code | What it locks down |
|---|---|
| SCOPE-PROJECTS.md §14.1 "Three-pane workbench (the shell)" | Navigator / list / detail layout, single #/workflow route |
| SCOPE-PROJECTS.md §14.2 Navigator pane | Smart views (My queue, Assigned, Mentioned, Created) + pins + tags + orgs |
| SCOPE-PROJECTS.md §14.3 Issue list pane | Grouping, sort, pagination, density |
| SCOPE-PROJECTS.md §14.4 Issue detail pane | Peek panel hosting the existing IssueEditCard |
SCOPE-PROJECTS.md §14.5 Command palette ⌘K |
Jump-to / global actions |
| SCOPE-PROJECTS.md §14.6 Saved views | Pins / tags ARE the saved views |
| SCOPE-PROJECTS.md §14.7 Keyboard model | j/k Enter Esc c e a l s g i |
| SCOPE-PROJECTS.md §14.9 Minimum first slice | What lands in one PR |
| SCOPE-PROJECTS.md §8.2 / §8.3 | CAS write path + stale-version reload UX (already implemented) |
| SCOPE-PROJECTS.md §13.5 | Pin cap, sidebar render cap, tag scope cap |
| SCOPE-PROJECTS.md §13.6 | issues: write permission gate |
| SCOPE.md §6 "Activity signals tracked" | What goes on the timeline |
| crates/dp-store-pg/migrations/dp/0001_init.sql | dp_issues, dp_activity_events, dp_event_actors schema |
| crates/dp-store-pg/migrations/dp/0005_user_pins_tags_tag_links.sql | dp_user_pins, dp_tags, dp_tag_links schema |
| crates/dp-rest/src/issues_read.rs | GET /issues (just landed, paginated) |
| crates/dp-rest/src/repos.rs | GET /repos (just landed, paginated) |
| crates/dp-rest/src/issues.rs | §8 write-path helpers (acquire/commit/rollback) — handlers not yet wired |
Everything below is what the "Triage" surface owns. Anything not listed stays out for the first three slices.
dev-pulse is a multi-user operator console (50+ devs across 4 orgs in the reference deployment). The §3.2 smart views and the per-user inbox in §3.8 only make sense if "me" is well-defined — and "me" is not a single GitHub login.
A real operator commonly has:
- a work GitHub account (
alice-acme) — member ofacme-co,acme-platform. - a personal GitHub account (
alice) — member ofacme-ossand assigned to drive-by issues there. - a bot / on-call account (
alice-oncall) used during rotations.
All three map to one dp-pulse user. Their My queue / Assigned / Mentioned / Created views must union across the set, otherwise the inbox is a lie.
-- One dp-user can claim many GitHub identities.
CREATE TABLE dp_user_identities (
user_id UUID NOT NULL REFERENCES dp_users(id) ON DELETE CASCADE,
github_user_id BIGINT NOT NULL, -- stable across renames
github_login TEXT NOT NULL, -- denormalized for joins
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
linked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
verified_via TEXT NOT NULL
CHECK (verified_via IN ('oauth','admin_link','rotation')),
PRIMARY KEY (user_id, github_user_id),
UNIQUE (github_user_id) -- one GH account → one dp-user
);
CREATE INDEX dp_user_identities_login_idx ON dp_user_identities (github_login);
-- Exactly one primary identity per dp-user.
CREATE UNIQUE INDEX dp_user_identities_primary_idx
ON dp_user_identities (user_id) WHERE is_primary;
-- §3.0.1.a Per-identity provenance on memberships so unlink can
-- subtract *only* orgs no remaining identity still covers.
CREATE TABLE dp_membership_identities (
user_id UUID NOT NULL,
org_id UUID NOT NULL REFERENCES dp_orgs(id) ON DELETE CASCADE,
github_user_id BIGINT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, org_id, github_user_id),
FOREIGN KEY (user_id, github_user_id)
REFERENCES dp_user_identities (user_id, github_user_id) ON DELETE CASCADE
);
CREATE INDEX dp_membership_identities_org_idx
ON dp_membership_identities (org_id, user_id);dp_orgs ↔ dp_users already exists via dp_memberships
(user_id, org_id, role, home_org, joined_at). One dp-user can
already belong to many orgs — that part is not new. What's
new is the multi-identity layer above it, plus the
dp_membership_identities provenance join so we can answer
"does alice still reach acme-co after unlinking
alice-acme?" without re-querying GitHub.
Single source of truth. dp_users.github_id (the UNIQUE
single-identity column on 0001_init.sql) is deprecated by
this change. Migration 0013 backfills dp_user_identities from
it (one row per existing user, is_primary = TRUE,
verified_via = 'oauth') and migration 0014 (one release
later, after every read site has migrated) drops the column.
In the interim every read goes through
dp_user_identities WHERE is_primary — no dual reads. The
primary identity is mutable via
PATCH /me/identities/{github_user_id}/primary.
- First login → OAuth callback creates
dp_usersrow + primarydp_user_identitiesrow (is_primary = TRUE). No change from today's UX. - Add identity → operator clicks "Link another GitHub
account" in
#/account/identities. Server inserts a row indp_identity_link_pending(nonce UUID, session_id, dp_user_id, expires_at)and redirects to GitHub OAuth withstate = nonce(opaque; never the session id directly — see §3.0.2.a). Callback consumes the nonce, verifies the session still binds to the same dp-user, and inserts the newdp_user_identitiesrow. Reject ifgithub_user_idalready claimed by a different dp-user (return 409 + auditIDENTITY_CLAIM_CONFLICT). - Admin link (break-glass) →
dev-pulse link-identity --user <uuid> --github-login <login>for the CLI-seeded admin to fix mis-attributions. Writesverified_via = 'admin_link'and emits a high-visibility audit row that surfaces in the target user's own audit log (so alice sees "your account was linked toalice-oncallby admin bob at T"). The user can one-click unlink an admin-linked identity without re-OAuth. - Unlink →
DELETE /me/identities/{github_user_id}; refuse ifis_primary(operator mustPATCH .../primaryto another identity first) or if it would leave the user with zero identities. Drives §3.0.2.b membership subtraction. - Transfer (admin-only) →
POST /admin/identities/{github_user_id}/transfer { to_user }. Moves the identity to another dp-user (covers "alice quit, bob inheritsalice-oncall" without unlink-then-link racing).
The link round-trip never puts the session id on the wire. The
state parameter is an opaque UUID looked up server-side
against dp_identity_link_pending; the row is consumed on
callback and bound to the session via the cookie the browser
still presents. This is the standard CSRF-safe OAuth pattern.
dp_memberships rows are derived from
dp_membership_identities rows. The invariant:
A
dp_memberships(user_id, org_id)row exists iff at least onedp_membership_identitiesrow exists for the same(user_id, org_id).
Three transitions touch it:
- Link / re-stamp identity
i— for each orgican reach, INSERTdp_membership_identities(user_id, org_id, i)(idempotent on PK). Then UPSERTdp_memberships(user_id, org_id). - Unlink identity
i— DELETEdp_membership_identities WHERE github_user_id = i(ON DELETE CASCADE fromdp_user_identitiesalready does this). For each affected(user_id, org_id), DELETEdp_membershipsonly if no provenance rows remain. - Token expiry for
i— treated like a stamp returning the empty org list for that identity: dropi's provenance rows, then collapsedp_membershipsfor any now-unprovenanced(user_id, org_id).
With this, deleting an identity cannot silently revoke a user's access to orgs her other identities still cover, and the stamper's worst-case behaviour is "membership absent until next stamp" rather than "membership ambient and wrong."
Linking, unlinking, transferring, or PATCH .../primary all
invalidate the in-flight Principal. The link/unlink/transfer
handlers append the affected dp_user_id to a principal_dirty
table; the existing principal cache key includes the row's
updated_at so the next request re-stamps before serving. No
forced re-login.
If caller.github_logins = [] (a race during first OAuth or
all identities revoked mid-session), the principal stamper
refuses to mint a principal and the request returns 401
identity_set_empty. The frontend handles this with a re-login
prompt.
Principal.extra.github is extended at session-mint by the
existing GithubOrgsStamper (see
crates/dp-server/src/auth/github_orgs.rs):
The org-gate policy rule still keys on in_allowed_org; nothing
in the policy needs to know about the identity set.
Every "me" predicate becomes a set match across identities:
| View | Old (single login) | New (identity set) |
|---|---|---|
Assigned to me |
assignees @> [caller.login] |
assignees ?| caller.github_logins |
Mentioned |
mention table mentioned_login = caller.login |
mentioned_login = ANY(caller.github_logins) |
Created by me |
author = caller.login |
author = ANY(caller.github_logins) |
My queue |
join on (user_id, issue_id) |
unchanged — keyed on the dp-user uuid, not a GH login |
The inbox table (dp_user_issue_state) is already keyed by
user_id UUID so the union just falls out — My queue never
needed a login. The other three views are the ones that get a
plural rewrite.
GET /me/identities— list linked GH identities.POST /me/identities/link/start→ OAuth round-trip start withlink_to_session = <session_id>.POST /me/identities/link/callback→ finishes link.DELETE /me/identities/{github_user_id}— unlink (refuse last-identity removal).POST /admin/users/{user_id}/identities(admin link), gated onadmin.write.
All gated on a new identities resource (read / write),
registered in
crates/dp-server/src/auth/policy.rs
the same way issues / tags just were.
- Route:
#/workflow(the legacy#/workflow/reposand#/workflow/issuesredirect here). - Three resizable panes: Views (left), List (center), Peek (right).
- Peek pane can be collapsed; list expands to full width.
- No per-page sub-navigation, no tabs across the top.
Each entry is one click → repopulates the list. The active view is reflected in the URL so links are shareable.
-
Smart views (server-resolved; "me" = the union of every GitHub identity linked to the dp-user, per §3.0):
★ My queue— issues in the caller's inbox (see §3.8): open + version newer thanlast_seen_version+ not snoozed- not marked done. Default landing. Carries an unread badge.
Keyed on
dp_user_issue_state.user_id— identity-set agnostic.
- not marked done. Default landing. Carries an unread badge.
Keyed on
Assigned to me— open issues whereassigneesoverlapscaller.github_logins(any linked identity).Untriaged— open + no assignee + no labels + age ≥ 24h, scoped tocaller.org_idsfromdp_memberships(the authoritative source — survives token churn, unlike the GitHub stamper'sorgslist). The actual "triage" view that names the page. The 24h floor drops just-opened issues whose author is still typing.Mentioned— issues whose body or whose comments mention any of the caller's linked logins. Backed by the §6 projection table; slice 3.Created by me— issues whoseauthoris any ofcaller.github_logins.
-
Pins (per-user, ordered, capped at 20 per §13.5):
- Repo pin → issues in that repo.
- Tag pin → issues whose repo is linked to that tag.
-
Tags (org-scoped or team-scoped per §7.4):
- Click a tag → issues whose repo is linked.
-
Teams (NEW — slice 2).
dp_membershipsalready carries team data. With 50 devs in 4 orgs the team is the actionable unit, not the org:My team's untriagedAssigned to anyone on my teamTeam @platform's WIP(manager peek)
Rendered between Tags and Orgs. Each team entry shows open count + stale count badges.
-
People (NEW — slice 2). Flat list of users in the caller's orgs, each row showing
open / stale / last activity. Click → list filtered to that assignee. Sortable by stale count. This is the "who's drowning, who has bandwidth" view that lets a lead actually rebalance load without leaving the page; without it, that question lives only in/reports/issues?metric=wipand never gets asked. -
Orgs — flat list of orgs the caller is a member of; click → org filter.
Sidebar render cap of 50 (§13.5) applies after expanding pins + tags + teams + people into rows; People is collapsed by default with a "show all" reveal to keep the rail under the cap at 50-dev scale.
- Filter pills above the list:
org,repo,state,state_reason,assignee,label,milestone,updated_since. Pills are composable (every pill is an AND across fields). Repeatable pills (label,assignee,repo,org) are AND within the field too — matches Linear. Each pill is removable withx. - Group by dropdown:
none | repo | assignee | label | milestone | state. Sticky group headers; each header carries the count for the visible group. - Sort dropdown:
updated_at desc(default) |created_at desc|number desc|assignee|repo. - Server-paginated (default page size 50, max 200), with
Showing X–Y of Zcounter. - Density: single-line row —
#number, title, state pill, assignees, age.repo_slugshown when group is notrepo. - Keyboard:
j/kmove selection,Enteropens peek,xtoggles row in bulk-selection,Shift+clickrange-select. - Virtualised — needs
@tanstack/react-virtualfor 1000+ row pages. - Empty state distinguishes "no matches for these filters" from "no data yet for this org" (the latter links to the Admin → Refresh page).
- Mounts the existing
IssueEditCardverbatim — the §8.2 CAS write path and the §8.3 stale-version reload UX survive with zero changes (frontend/src/workflow/issues-page.tsx ). - Adds three new sections under the form:
- Activity timeline — chronological view of every
dp_activity_eventsrow tagged with this issue (IssueOpened,IssueComment,IssueClosed, plus PR events that reference the issue number). One row per event: actor avatar + verb + relative-time. - Linked tags — chips of
dp_tagswhosedp_tag_linkspoint at thisissue_idor itsrepo_id. Click to filter the list to that tag. - Open in new tab — deep-link back to
#/workflow?issue=<uuid>&view=<active_view>.
- Activity timeline — chronological view of every
Slice-2 scope per §14.5. Three action classes:
- Jump-to —
#482 webhook→ matches issue number, title, repo slug, org login, tag name, user login. - Switch view —
view: my queue,view: phoenix tag. - Apply action to selection —
assign @alice,label oncall,close. Each action is one §8.2 CAS round per selected row, with a progress toast.
All inline edits are optimistic: the row updates locally
on click, the §8.2 CAS round-trip runs in the background, and a
409 reverts the cell with a toast ("Out of date — refresh to see the latest version"). This is the single biggest piece of
"Linear feel" — without it every click waits for the network.
The §8.3 stale-version reload UX is the rollback path.
- Slice 1: state cell only (
open ↔ closed). Clicking the pill opens a 2-item menu; the mutation goes throughPATCH /issues/{id}withexpected_versionfrom the row. - Slice 2: assignee cell + label cell — same pattern, popover pickers.
Every selection lives in the hash route so links are shareable:
#/workflow?view=my_queue&org=phoenix&state=open&group=repo&issue=<uuid>
The Router in frontend/src/app.tsx already
mounts the workflow section; the existing
workflowSelectedIssue() / workflowSelectedRepoId() helpers in
frontend/src/routes.ts grow workflowViewId(),
workflowGroupBy(), workflowFilters().
The missing Linear feature that the original draft skipped.
Without it, My queue is just "Assigned to me" plus noise — no
badge count, no e to mark done, no "new since I last looked".
State lives in a new dp_user_issue_state table (§6) keyed on
(user_id, issue_id):
last_seen_version BIGINT— compared againstdp_issues.external_version(see Own-write hazard below). Row is unread whenexternal_version > last_seen_version.status TEXT—inbox(default) |snoozed|done.snoozed_until TIMESTAMPTZ NULL— wakes back intoinboxwhen past.
Own-write hazard. dp_issues.version bumps on every CAS,
including the caller's own writes. If unread compared against
version directly, alice would mark her own edit as unread the
moment she walked away from the row. Fix: add
dp_issues.external_version BIGINT NOT NULL DEFAULT 0 (§6)
that the reconciler bumps on remote-applied changes but the
§8.2 commit path does not. Unread compares against
external_version; the §8.2 CAS still uses version for
concurrency control. Two counters, two different jobs.
UX surfaces:
- Bold dot on unread rows; sidebar badge is the inbox count.
emarks the selected row(s) done (removes from inbox).hsnoozes selected row(s) (popover: "until tomorrow / next week / custom"). Snoozed rows are hidden fromMy queue.- Opening the peek auto-bumps
last_seen_versionto theexternal_versionthe client actually saw (not server-current). Server upserts withlast_seen_version = LEAST(version_seen, current_external_version)so it never advances past what the user observed — any write landing between row-open and the seen-request stays unread. - Bulk inbox actions (slice 2):
mark-all-visible read,snooze-all-visible 1d,done-all-visible. Required for the PTO-returner who comes back to inbox-2000; single-row UX is unusable at that scale. Backed byPOST /me/inbox/seen(already bulk) and a new bulk variant ofPATCH /me/inbox/{issue_id}→POST /me/inbox/bulk { issue_ids: [Uuid], status, snoozed_until }.
New endpoints (slice 1):
POST /me/inbox/seen{ entries: [{ issue_id, version }] }→ bulk-mark read using the version the client actually saw.PATCH /me/inbox/{issue_id}{ status?, snoozed_until? }.POST /me/inbox/bulk(slice 2) — bulk status / snooze.
Auth pair: ("issues", "read") is enough; this is per-user UI
state, not an issue mutation.
dev-pulse is a local mirror; the #1 trust question is "is what I'm looking at fresh?" The current refetch-on-focus answers it implicitly. We surface it explicitly:
- Per-repo badge in the list group header and views rail:
synced 4m ago, derived from the fetcher's existinglast_synced_atcolumn ondp_repos. Goes amber > 30 min, red > 6 h. - "Sync now" action in the views-rail header — enqueues the fetcher for the active filter's repo scope. No long-poll; the user refreshes the page when ready (or the badge ticks down).
- Per-issue "out of date" banner in the peek panel if the
reconciler has noted a remote
updated_atnewer than the local row'supdated_atsince it was opened.
New endpoints (slice 2):
GET /repos/{id}/sync-status→{ last_synced_at, last_attempt_at, last_error, queued }.POST /repos/{id}/sync→ enqueues; idempotent if already queued.
| Surface | Per-issue? | Read/write? | Notes |
|---|---|---|---|
| Issue body / fields | — | — | Plain issues have no native start/due date fields. The only dates on an issue itself are created_at, updated_at, closed_at. |
| Milestones | shared | r/w via REST | due_on TIMESTAMPTZ, but per-milestone — every issue in the milestone shares the same date. Useful for sprint-level "ship by", useless for per-issue planning. |
| Projects (classic, v1) | yes | r/w | Deprecated since Aug 2024 — do not build on it. |
| Projects v2 | yes | r/w via GraphQL only | Per-project custom fields including Date type. The Linear-equivalent of "due date on an issue" is "a Projects v2 board has a Due date Date field, and the issue is added as an item to that project". This is what GitHub's own UI shows when you see a date column on an issue list — it is always a project field, never an issue field. |
So: there is no issue.due_on to round-trip. To use
"native GitHub" we have to (a) require a Projects v2 board per
repo or per org and (b) talk GraphQL to read/write the date
fields on each project item.
dev-pulse treats start/due as first-class but optional metadata that lives in local state by default, with an opt-in mirror to Projects v2 when a board is configured. Neither side blocks the other; the operator picks per repo whether to mirror, and the inbox still works with zero dates anywhere.
-- Per-issue dates; either column may be NULL.
-- One row per issue (NOT per user) — these are the issue's
-- planning dates, not the caller's personal reminders.
CREATE TABLE dp_issue_dates (
issue_id UUID PRIMARY KEY REFERENCES dp_issues(id) ON DELETE CASCADE,
start_date DATE NULL,
due_date DATE NULL,
set_by UUID NULL REFERENCES dp_users(id),
set_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Mirror bookkeeping (NULL when not mirrored)
gh_project_id TEXT NULL, -- node id of the Projects v2 board
gh_item_id TEXT NULL, -- node id of the issue's project-item
gh_start_field_id TEXT NULL, -- node id of the Date field used as Start
gh_due_field_id TEXT NULL, -- node id of the Date field used as Due
last_mirrored_at TIMESTAMPTZ NULL,
mirror_error TEXT NULL,
CHECK (start_date IS NULL OR due_date IS NULL OR start_date <= due_date)
);
CREATE INDEX dp_issue_dates_due_idx ON dp_issue_dates (due_date)
WHERE due_date IS NOT NULL;
CREATE INDEX dp_issue_dates_start_idx ON dp_issue_dates (start_date)
WHERE start_date IS NOT NULL;DATE (not TIMESTAMPTZ) because "due Tuesday" is a calendar
concept, not an instant — matches how Projects v2 stores its
Date field (no time, no tz).
CREATE TABLE dp_repo_project_link (
repo_id UUID PRIMARY KEY REFERENCES dp_repos(id) ON DELETE CASCADE,
gh_project_id TEXT NOT NULL, -- Projects v2 board node id
gh_start_field_id TEXT NULL,
gh_due_field_id TEXT NULL,
auto_add_items BOOL NOT NULL DEFAULT true, -- add issue as project item on first date set
configured_by UUID NOT NULL REFERENCES dp_users(id),
configured_at TIMESTAMPTZ NOT NULL DEFAULT now()
);If a row exists in dp_repo_project_link for the issue's repo,
writes to dp_issue_dates enqueue a background mirror task
that:
- Resolves / adds the issue as a Projects v2 item
(
addProjectV2ItemByIdmutation). - Updates the configured Date fields
(
updateProjectV2ItemFieldValuemutation, one per field). - Writes back
gh_item_id+last_mirrored_aton success, ormirror_erroron failure (the local date stays — mirror is best-effort, never blocking).
If no row exists, dates are local-only. That's the default, and it's a complete experience — the operator just doesn't get the dates surfaced on github.com.
The fetcher (slice 3, not slice 2) periodically reads project
items for repos with a dp_repo_project_link and syncs the
configured Date fields into dp_issue_dates. Last-writer
wins, keyed on set_at vs last_mirrored_at. This lets the
operator edit dates on github.com (e.g. on mobile) and have
them flow back.
PATCH /issues/{id}/dates { start_date?, due_date? } —
either field may be null to clear. Auth ("issues", "write").
Synchronous local upsert; enqueues mirror if linked. Returns
{ start_date, due_date, mirror: "local" | "queued" | "synced" | "error" }.
- Peek panel: two compact date pickers ("Start", "Due") under the title row. Empty pill reads "Add date". Past-due rows get a red badge on the row in the list pane.
- List pane: a
Duecolumn (hidden by default, toggle viag d); rows sortable by due_date asc with NULLs last. - Smart views: add
Due this weekandOverdueto the §3.2 rail (server-resolved on the new index). - Inbox bump: an issue assigned/created/mentioned to the
caller that goes past-due re-appears in
My queuewithstatus='inbox'cleared fromdone— the snooze never outlives the due date. (Done in the §3.8 inbox-state-set trigger; see thedp_user_issue_stateupsert path.)
- Projects v2 mirror requires the GitHub App or PAT to hold
projects: writeon the org. Surface theapp-install-bannertreatment from §13.6 if missing — the local date still saves, the mirror just fails-soft. dp_repo_project_linkis an admin-write surface (gated on the sameadmin.writeas the rest of §7); operators set it once per repo and forget it.
| Area | Why deferred |
|---|---|
| Drag-to-reorder pins in the rail | PUT /me/pins/order exists; ship the UI in slice 2 |
| Saved-views CRUD beyond pins/tags | §14.6 leaves "saved view as a third object" open; v1 reuses pins/tags |
| Kanban / Gantt / timeline boards | Explicit §13.9 non-goal |
| Cross-issue dependencies | Explicit §4 non-goal |
| New write verbs (milestone create, label create) | §4 non-goal — labels/milestones are read-only enumerations from GitHub |
| Real-time push (SSE/WebSocket) | §10 says scheduled ingestion only; refetch-on-focus is enough for v1 |
| Mobile layout | §13.8 calls workbench desktop-only |
✅ = exists today, no changes needed.
🟡 = exists but needs a small extension.
🆕 = needs to be added.
| Endpoint | Status | Notes |
|---|---|---|
GET /issues |
✅ | Paginated, filterable on repo_id/org_id/state/assignee/q/limit/offset. See crates/dp-rest/src/issues_read.rs |
GET /issues/{id} |
✅ | Single issue by id, used by Peek. See crates/dp-rest/src/issues_read.rs |
GET /repos/{repo_id}/issues/{number} |
✅ | Deep-link form. See crates/dp-rest/src/issues_read.rs |
GET /repos |
✅ | Paginated; populates the repo filter pill autocomplete. See crates/dp-rest/src/repos.rs |
GET /orgs |
✅ | Populates the org filter pill + Orgs rail. See crates/dp-rest/src/directory.rs |
GET /users |
✅ | Populates the assignee picker. See crates/dp-rest/src/directory.rs |
GET /me/queue |
🆕 | Server-resolved "my queue" smart view (inbox-aware). See §5.4. |
GET /issues extended filters |
🟡 | Add label, milestone, author, state_reason, updated_since, mentions, repo_ids, org_ids. See §5.5. |
GET /issues/{id}/timeline |
🆕 | Projects dp_activity_events for the issue. See §5.6. |
POST /me/inbox/seen |
🆕 | Bulk-mark issues read. See §3.8 / §5.8. |
PATCH /me/inbox/{issue_id} |
🆕 | Set inbox status / snooze. See §3.8 / §5.8. |
GET /repos/{id}/sync-status |
🆕 | Sync freshness for the badge. See §3.9 / §5.9. |
POST /repos/{id}/sync |
🆕 | Enqueue a fetcher run. See §3.9 / §5.9. |
GET /reports/issues |
🆕 | Aggregated metrics from dp_issues + dp_activity_events. See §5.10. |
| Endpoint | Status | Notes |
|---|---|---|
GET /me/pins |
✅ | Per-caller pin list. See crates/dp-rest/src/pins.rs |
GET /me/tags |
✅ | Tags visible to caller (own + scope-visible). See crates/dp-rest/src/tags.rs |
GET /tags |
✅ | Full tag list (admin / palette). See crates/dp-rest/src/tags.rs |
GET /tags/{id} |
✅ | Tag detail with linked repos/issues. See crates/dp-rest/src/tags.rs |
GET /me/app-install-banner |
✅ | §13.6 write-gate banner. See crates/dp-rest/src/app_permissions.rs |
| Endpoint | Status | Notes |
|---|---|---|
POST /issues |
🟡 | Helper exists (commit_issue_mutation) but handler not mounted — see crates/dp-rest/src/issues.rs (4 pub async fn helpers, no Router). Need an issues_write_router that wires create/patch/comment through the acquire/commit/rollback dance. |
PATCH /issues/{id} |
🟡 | Same — helper exists, handler not mounted. CAS on expected_version per §8.2. |
POST /issues/{id}/comments |
🟡 | Same — helper exists, handler not mounted. |
POST /me/pins |
✅ | Add pin (slice 2 rail editing). |
DELETE /me/pins/{kind}/{target_id} |
✅ | Remove pin. |
PUT /me/pins/order |
✅ | Atomic reorder. |
POST /tags/{id}/links |
✅ | Link a repo / issue / user / team to a tag. |
DELETE /tags/{id}/links |
✅ | Unlink. |
Composite default landing view. Server resolves the union of "things the caller probably wants to see next" in one round-trip so the list pane has data on first paint without N hooks.
GET /me/queue?limit=50&offset=0&state=open
→ 200 IssueListResponse // same envelope as GET /issues, with
// `unread: bool` added per row
Server semantics — UNION over four arms, deduped on id,
ordered by (updated_at DESC, id DESC). "Me" expands to the
full identity set per §3.0.4.
- Open issues where
assignees ?| caller.github_logins(any linked login is assigned) - Open issues whose
repo_idis in the caller's pinned repos - Open issues whose
repo_idis indp_tag_linksfor any tag the caller has pinned (named explicitly to settle §10 Q1) - Issues in the caller's inbox: rows where
dp_user_issue_state.status = 'inbox'ANDdp_issues.external_version > coalesce(last_seen_version, 0)AND(snoozed_until IS NULL OR snoozed_until < now()).
Pagination & cost. Each arm pushes ORDER BY updated_at DESC, id DESC LIMIT $cap before the UNION so a user with 100
pinned repos doesn't scan 5k arm-2 rows just to slice 50. The
outer query then re-orders + dedupes + LIMITs. Pagination is
keyset on (updated_at, id), not OFFSET — inbox UX never
needs deep pagination and OFFSET at depth 950 is wasteful.
LEFT JOIN dp_user_issue_state on (caller.id, issue.id) to
project unread = (external_version > coalesce(last_seen_version, 0)).
Always AND with org_id = ANY(caller.org_ids) (from
dp_memberships, not the stamper's transient orgs list) — the
policy layer enforces per-row authz, but the SQL stays tight too.
Auth pair: ("issues", "read"). Needs a new covering index
dp_issues (updated_at DESC, id) for the outer sort (the
existing _org_state_idx and _repo_updated_idx cover the
per-arm filters but not the cross-repo merge); added in §6.
Add to crates/dp-rest/src/issues_read.rs
as me_queue handler; mount alongside the existing
issues_read_router registrations.
Today's ListIssuesQuery (crates/dp-rest/src/issues_read.rs)
accepts repo_id, org_id, state, assignee, q, limit,
offset. The triage pills add:
| Field | Wire | Store predicate |
|---|---|---|
label |
repeatable, AND | labels @> to_jsonb($labels::text[]) (single containment, one row in the WHERE) |
assignee |
repeatable, AND | assignees @> to_jsonb($assignees::text[]) |
milestone |
string | milestone = $milestone |
author |
string | New column on dp_issues — schema change, see §6 |
state_reason |
string | state_reason = $state_reason (completed/not_planned/reopened) — needed by reporting too, see §5.10 |
updated_since |
RFC3339 | updated_at >= $since |
mentions |
string | EXISTS (SELECT 1 FROM dp_issue_mentions m WHERE m.issue_id = i.id AND m.login = $login) — slice 3, projection table per §6 |
repo_id |
repeatable | repo_id = ANY($repo_ids) |
org_id |
repeatable | org_id = ANY($org_ids) |
Wire compatibility: the current repo_id / assignee /
org_id params are scalar. The new shape accepts both — a
scalar value is treated as a one-element array on the server.
No breaking change to existing callers.
The repo_id/org_id/assignee/label changes need both the
wire shape and the store IssueListFilter extended to
Vec<…>. The existing dp_issues_org_state_idx covers the
common (org_id, state) access pattern; (repo_id, updated_at) is covered. label and assignees containment
searches over JSONB need GIN indexes — added in §6.
Powers the peek-pane timeline.
GET /issues/{id}/timeline?limit=50&offset=0
→ 200 { rows: TimelineEntry[], total, limit, offset }
TimelineEntry := {
id: Uuid,
kind: EventKind, // IssueOpened | IssueComment | IssueClosed | …
ts: DateTime<Utc>,
actors: [{ user_id, login, role }],
payload_summary: string // e.g. "added label 'oncall'"
}
Implementation:
dp_activity_eventshasrepo_id+payload JSONBbut noissue_idcolumn. The payload from the fetcher includespayload.numberfor issue-kind events (crates/dp-fetcher/src/worker/handlers.rs).- The lookup uses a guarded expression: the §6 expression index
is partial on
kind IN (…) AND payload ? 'number' AND payload->>'number' ~ '^[0-9]+$', so the cast in the WHERE clause can never raise on malformed rows. - Filter:
repo_id = $repo_id AND kind = ANY($kinds) AND payload ? 'number' AND payload->>'number' ~ '^[0-9]+$' AND (payload->>'number')::int = $number. - This is the only place dev-pulse beats GitHub — nobody else has the cross-source merged timeline. Worth doing right.
- Auth:
("issues", "read"). - Add as
get_issue_timelinein crates/dp-rest/src/issues_read.rs; store methodlist_events_for_issue(repo_id, number, limit, offset)indp_domain::store::Store, PG impl in crates/dp-store-pg/src/store.rs.
with_principal+require_permissionmiddleware (crates/dp-server/src/lib.rs).starter-authzwildcardoauth.in_allowed_org == truepolicy in crates/dp-server/policy/dev-pulse.toml — new read endpoints just need the("issues", "read")/("repos", "read")pair already declared.- §8.2 acquire / commit / rollback helpers (crates/dp-rest/src/issues.rs) — wire-up only.
- Audit vocabulary (crates/dp-rest/src/audit.rs) — issue writes already have verbs.
Backing the §3.8 inbox UX. All per-caller; no new auth pair
(reuses ("issues", "read") since this is per-user UI state).
POST /me/inbox/seen
body: { issue_ids: [Uuid] }
→ 204 // upserts last_seen_version = current version
PATCH /me/inbox/{issue_id}
body: { status?: "inbox"|"snoozed"|"done", snoozed_until?: RFC3339 | null }
→ 200 UserIssueState
Store methods (dp_domain::store::Store):
mark_issues_seen(user_id, [issue_id]),
set_inbox_state(user_id, issue_id, status, snoozed_until).
Backing §3.9. Reads dp_repos.last_synced_at /
last_sync_error (already written by the fetcher reconciler).
GET /repos/{id}/sync-status
→ 200 { last_synced_at, last_attempt_at, last_error: string | null, queued: bool }
POST /repos/{id}/sync
→ 202 { queued: true } // idempotent: no-op if already queued
Auth pair: ("repos", "read") for the GET; the POST needs
("repos", "sync") — new auth pair, gate via the existing
require_permission middleware. (The one new pair in the
proposal; the inbox endpoints reuse existing pairs.)
The surface the original draft skipped. Backed entirely by existing tables — no new ingestion, no new fetcher work.
GET /reports/issues
?metric=throughput|lead_time|wip|stale|untriaged
&group_by=repo|org|assignee|week|day
&since=RFC3339 &until=RFC3339
&org_id=… &repo_id=… (repeatable, scope filters)
→ 200 { rows: [{ bucket, value, count }], total }
Metric definitions (v1 — all expressible against dp_issues +
dp_activity_events, no new columns):
| Metric | Source | SQL shape |
|---|---|---|
throughput |
closed issues per bucket | COUNT(*) FILTER (WHERE state='closed' AND closed_at BETWEEN $since AND $until) GROUP BY bucket |
lead_time |
open → close duration (seconds) | percentile_cont(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (closed_at - created_at))) per bucket |
wip |
currently-open assigned, per-login | SELECT a.login, COUNT(*) FROM dp_issues i CROSS JOIN LATERAL jsonb_array_elements_text(i.assignees) a(login) WHERE i.state='open' GROUP BY a.login. An issue with 2 assignees counts in both WIP buckets — intentional, surfaces shared load. |
stale |
open + idle ≥30d | COUNT(*) FILTER (WHERE state='open' AND updated_at < now() - interval '30 days') |
untriaged |
open + no assignee + no label + age ≥24h | COUNT(*) FILTER (WHERE state='open' AND jsonb_array_length(assignees) = 0 AND jsonb_array_length(labels) = 0 AND created_at < now() - interval '24 hours') |
Auth pair: ("issues", "read"). Filter scope is always
intersected with caller.org_ids.
New file crates/dp-rest/src/reports.rs
mounting a reports_router; store trait grows
issue_metrics(filter, metric, group_by) returning a tagged
result enum keyed by metric kind.
Three forward-only migrations in crates/dp-store-pg/migrations/dp/:
-- 0010_triage_indexes.sql (slice 1)
-- §5.5 label filter (JSONB containment).
CREATE INDEX dp_issues_labels_gin ON dp_issues USING GIN (labels);
-- §5.5 assignee filter (already issued in PG queries, but uncovered).
CREATE INDEX dp_issues_assignees_gin ON dp_issues USING GIN (assignees);
-- §5.5 author filter. New column.
ALTER TABLE dp_issues ADD COLUMN author TEXT NULL;
CREATE INDEX dp_issues_author_idx ON dp_issues (author);
-- §5.5 state_reason filter (and §5.10 reporting). May already
-- exist as JSONB-buried; promote to a column for indexing.
-- Partial: state_reason is null on most rows and low-cardinality
-- (completed / not_planned / reopened) so a partial index is
-- much smaller without losing coverage.
ALTER TABLE dp_issues ADD COLUMN state_reason TEXT NULL;
CREATE INDEX dp_issues_state_reason_idx
ON dp_issues (state_reason) WHERE state_reason IS NOT NULL;
-- §3.8 own-write hazard: split the version counter. `version`
-- bumps on every CAS (reconciler + caller). `external_version`
-- bumps only on reconciler-applied remote changes, so unread
-- never flags the caller's own edits.
ALTER TABLE dp_issues ADD COLUMN external_version BIGINT NOT NULL DEFAULT 0;
UPDATE dp_issues SET external_version = version; -- backfill
-- §5.4 /me/queue cross-repo outer sort.
CREATE INDEX dp_issues_updated_at_idx ON dp_issues (updated_at DESC, id);
-- One-shot backfill for `author` and `state_reason` from the
-- payload the fetcher already stored on the last sync. Cheap;
-- avoids the "empty until next reconciler tick" window.
UPDATE dp_issues
SET author = COALESCE(author, payload->>'user_login'),
state_reason = COALESCE(state_reason, payload->>'state_reason')
WHERE author IS NULL OR state_reason IS NULL;-- 0011_user_issue_state.sql (slice 1, §3.8)
CREATE TABLE dp_user_issue_state (
user_id UUID NOT NULL REFERENCES dp_users(id) ON DELETE CASCADE,
issue_id UUID NOT NULL REFERENCES dp_issues(id) ON DELETE CASCADE,
last_seen_version BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'inbox'
CHECK (status IN ('inbox','snoozed','done')),
snoozed_until TIMESTAMPTZ NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, issue_id)
);
-- Inbox query: "my inbox rows that are unread or due-from-snooze".
CREATE INDEX dp_user_issue_state_inbox_idx
ON dp_user_issue_state (user_id, status)
WHERE status <> 'done';
-- updated_at trigger (the column has DEFAULT now() at INSERT
-- only; the app must not be trusted to set it on UPDATE).
CREATE OR REPLACE FUNCTION dp_user_issue_state_touch()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$;
CREATE TRIGGER dp_user_issue_state_touch_trg
BEFORE UPDATE ON dp_user_issue_state
FOR EACH ROW EXECUTE FUNCTION dp_user_issue_state_touch();-- 0012_triage_timeline_and_mentions.sql (slice 2/3)
-- §5.6 issue timeline filter. Guarded so the cast in the
-- expression index can never raise on malformed payloads.
CREATE INDEX dp_activity_events_issue_idx
ON dp_activity_events
(repo_id, ((payload->>'number')::int), ts DESC)
WHERE kind IN ('issue_opened','issue_closed','issue_comment')
AND payload ? 'number'
AND payload->>'number' ~ '^[0-9]+$';
-- §5.5 `mentions` filter projection (slice 3). Populated by the
-- fetcher when it ingests IssueComment events; backfill by
-- scanning dp_activity_events of kind=issue_comment.
CREATE TABLE dp_issue_mentions (
issue_id UUID NOT NULL REFERENCES dp_issues(id) ON DELETE CASCADE,
login TEXT NOT NULL,
source TEXT NOT NULL CHECK (source IN ('body','comment')),
PRIMARY KEY (issue_id, login, source)
);
CREATE INDEX dp_issue_mentions_login_idx ON dp_issue_mentions (login);The author / state_reason columns are the only dp_issues
touch and both are populated by the in-migration backfill from
payload already on disk. dp_user_issue_state and
dp_issue_mentions are additive.
| Path | What |
|---|---|
frontend/src/workflow/triage-page.tsx |
The single landing page; mounts the three panes. |
frontend/src/workflow/views-rail.tsx |
Left pane: smart views + pins + tags + orgs + inbox badge. |
frontend/src/workflow/issue-list.tsx |
Center pane: pills, group/sort, virtualised table, pagination, unread dots, group-header sync badge. |
frontend/src/workflow/peek-panel.tsx |
Right pane: IssueEditCard + timeline + linked tags + out-of-date banner. |
frontend/src/workflow/timeline.tsx |
Renders GET /issues/{id}/timeline rows. |
frontend/src/workflow/use-triage-state.ts |
URL <-> filter object reducer (everything is hash-routed). |
frontend/src/workflow/sync-badge.tsx |
Per-repo "synced Nm ago" pill (§3.9). |
frontend/src/workflow/shortcuts-overlay.tsx |
? keyboard cheatsheet. |
frontend/src/reports/issues-report.tsx |
Reports page (slice 3) — picks metric/group/range, renders chart + table. |
| Path | Why |
|---|---|
frontend/src/workflow/repos-page.tsx |
Replaced by the views rail (org filter) + list. |
Current contents of frontend/src/workflow/issues-page.tsx |
Replaced by issue-list.tsx + peek-panel.tsx. IssueEditCard extracted to its own file. |
| Path | Change |
|---|---|
frontend/src/workflow/issue-edit-card.tsx (new) |
Hoist IssueEditCard from issues-page.tsx so the peek panel can import it without pulling the page chrome. |
| frontend/src/routes.ts | Drop WorkflowTab; add workflowViewId(), workflowGroupBy(), workflowFilters() URL helpers. Workflow becomes a single route. |
| frontend/src/layout/app-shell.tsx | Remove the Repos/Issues sidebar sub-entries — Workflow is one nav row again, like Phase 7's original design. |
| frontend/src/api/client.ts | Add listMyQueue(), listIssueTimeline(), markInboxSeen(), setInboxState(), getRepoSyncStatus(), enqueueRepoSync(), getIssueReport(); extend ListIssuesQuery with the §5.5 fields. |
| frontend/src/workflow/use-workflow-data.ts | Add useMyQueue(), useIssueTimeline(), useInboxMutations(), useRepoSyncStatus(). |
@tanstack/react-virtual@^3— list pane virtualisation. ~3KB gzipped. Use fixed row height (single-line rows by §3.3); dynamic measurement is heavier and unnecessary. No alternative in@nube/starter-ui-kit.- A small charting lib for the Reports page (slice 3) — pick
recharts(already in the broader nube monorepo) if available, else@tremor/react. Decide at slice-3 kickoff. - Everything else (Select, Sheet, Table, dropdown menu) already
ships in
frontend/src/components/ui/.
- Migrations:
0010_triage_indexes.sql(full, includingauthor+state_reasonwith in-SQL backfill) and0011_user_issue_state.sql. - 🆕
GET /me/queuehandler + store method (inbox-aware). - 🆕 Inbox endpoints (
POST /me/inbox/seen,PATCH /me/inbox/{id}) + store methods. - 🟡 Wire
POST /issues/PATCH /issues/{id}/POST /issues/{id}/commentshandlers (helpers exist; just needs anissues_write_router). - Frontend:
triage-page.tsxwith three panes. Views rail shows Smart Views (My queuedefault with unread badge,Assigned,Untriaged,Created). List pane: filter pills, no grouping yet, paginated, virtualised, unread dots. Peek pane:IssueEditCardonly, no timeline yet; opening it auto-marks the row read. - Keyboard:
j/k/Enter/Esc,emark done,hsnooze,?cheatsheet overlay. - Inline edit: state cell, optimistic with 409 rollback.
Merge gate: open #/workflow, see your inbox count, click
an unread row, watch the dot disappear, edit the title, save —
round-trips through §8.2 and the page reflects the new version.
Flip a state pill and watch it revert if the row was stale
(§8.3 reload UX). The ? overlay lists every shortcut.
- Migration:
0012_triage_timeline_and_mentions.sql(timeline expression index + mentions projection table; mentions stay unpopulated until slice 3). - 🆕
GET /issues/{id}/timelinehandler + store method. - 🆕 Sync endpoints (
GET /repos/{id}/sync-status,POST /repos/{id}/sync) + new("repos", "sync")auth pair. - Frontend: timeline section in peek; out-of-date banner; per-repo sync badge in list group headers and views rail; "Sync now" action; pins + tags expansion in rail (with §13.5 caps); pill autocomplete for repo/assignee; group-by; sort.
- Keyboard:
xfor bulk select,cto compose,g ifor inbox jump. ⌘Kpalette — jump-to only (issue number, repo slug, user login).
- Fetcher: on every
IssueCommentingest, writedp_issue_mentionsrows; one-shot backfill scans existingdp_activity_eventsof kind=issue_comment. Comment ingestion already exists (crates/dp-fetcher/src/worker/handlers.rs). - 🟡 Extend
GET /issueswithmentionsfilter (predicate joins the projection table — see §5.5). - 🆕
GET /reports/issueshandler + store method + Reports page (frontend/src/reports/issues-report.tsx). - Bulk actions:
Aassign /Llabel /Sstate /Cclose. Client fans out §8.2 CAS calls with a concurrency limit of 4–6; 409s requeue with refreshedexpected_version. Toast tracks progress. No new backend route. - Inline edits: assignee popover, label popover.
- Saved-view chips in the rail (built on top of pins for now).
- One new auth pair only.
("repos", "sync")for the "Sync now" action. Everything else reuses("issues", "read"),("repos", "read"),("issues", "write"),("pins", *),("tags", *). Inbox endpoints are per-user UI state and ride("issues", "read"). - No new state machine. §8.2 CAS handles every issue write; bulk actions are client-side fanouts over single-issue CAS calls, not a new transaction.
- SPI growth is bounded.
Storegrows:list_issues_for_me,list_events_for_issue,mark_issues_seen,set_inbox_state,issue_metrics,repo_sync_status,enqueue_repo_sync. Domain types addUserIssueStateand a taggedIssueMetricsResult. - The fetcher changes once. Slice 3 only: write
dp_issue_mentionson comment ingest.author/state_reasonare backfilled by the migration itself. - No real-time. §10 mandates scheduled ingestion; the UI refetches on focus + on every successful mutation. Sync freshness is surfaced explicitly via the badge (§3.9) instead.
- Frontend stays in one route for workflow.
#/workflowis the only workflow URL; the Reports page lives at#/reportsand is a separate nav row, not a workflow tab.
- Smart-view definitions are server-policy. Should
My queueinclude closed-recently issues, or strictly open? Linear includes "completed in last 7 days" — proposal: match that for theAssignedview (closed_at within 7d counts), keepMy queuestrictly inbox-status (§3.8) since inbox already handles "recently changed". Resolve in slice 1. Resolved: comment ingestion exists today (crates/dp-fetcher/src/worker/handlers.rs); slice 3 only needs to write the projection table.mentionsover comment bodies needs the fetcher to land comment ingestion.- Bulk action atomicity. Slice 3's "assign 25 rows" — one
§8.2 acquire per row is correct but chatty. Plan: client
fanout with concurrency 4–6, requeue on 409. Promote to a
POST /issues:bulkenvelope only if slice 3 shows real latency (p95 > 2s for 25 rows). @tanstack/react-virtualvs Tailwind v4 styling. Check that virtualised row heights play with the existingcomponents/table.tsxshadcn primitives; if not, the list pane uses a hand-rolleddivgrid instead ofTable. Use fixed row height (single-line per §3.3) either way.- Saved-views as a third object (§14.6 open). Pins + tags
cover v1; promote to a real
dp_saved_viewstable only if slice 3 surfaces a real need (e.g. filter combinations that can't be expressed as "pin a tag"). - Snooze defaults. Linear offers "tomorrow / next week / custom". Confirm exact buckets in slice 1 design review; the schema (§3.8) is bucket-agnostic.
- Reporting chart lib.
rechartsvs@tremor/react— pick at slice 3 kickoff based on what's already vendored in the broader nube monorepo.
| Drill-down (rejected) | Triage (proposed) |
|---|---|
| 2 page loads + a modal to read an issue body | 1 page load, peek opens with Enter |
| One filter at a time (repo via URL) | Composable pills + smart views |
| Pins and tags are dead sidebar widgets | Pins and tags ARE the saved views |
dp_activity_events unused |
Powers peek timeline + Reports (the moat) |
| Each org is a separate workflow | Cross-org by default; org filter is opt-in |
| Read-only rows | Optimistic inline edits + bulk over §8.2 |
| No notion of "new since I looked" | Per-user inbox: unread / snooze / done |
| Sync freshness invisible | Per-repo "synced Nm ago" + Sync-now |
| No reporting | /reports/issues: throughput, lead time, WIP, stale, untriaged |
| Sized for "one repo, a few issues" | Sized for 100s of repos, 1000s of issues |
| Re-invents GitHub Issues | Uses what dev-pulse has that GitHub doesn't |
Slice 1, 1.5, and 2 are closed on codeless/triage-slice-2.
The branch is shippable; slice 3 is the enrichment slice
that pays back the §1 promises slice 2 explicitly deferred.
Pick up here.
-
Mentions ingestion (§3.7 + §5.5) — populate the
dp_issue_mentionsprojection table. Worker pass overdp_issues.body+dp_issue_comments.body; extract@logintokens with a single regex, resolve againstdp_identities, upsert(issue_id, login). Wire theMentionedsmart view in/me/queueand thementionsfilter inListIssuesQuery. Migration:dp_issue_mentionstable + GIN index onlogin. Triggering: backfill once over existing rows, then update from the same handlers that touch issue body / comments indp-fetcher. -
Projects v2 pull-back (§3.10 stretch) — currently the date mirror is write-only. Add a
projects_v2_itemwebhook subscription, decodefield_value.changed, and write back intodp_issue_dateswithmirror_*provenance so the inbound edit doesn't fight the local writer. The stub task type already exists; add the handler. -
Saved views as a first-class object —
dp_saved_viewstable(id, owner_user_id, name, query JSONB, shared BOOL).queryis the §5ListIssuesQueryshape.GET / POST / PATCH / DELETE /viewsplus a?view=<id>form on the triage URL. Replace the tag-backed substitute that ships in stage 11 (the tag-as-saved-view fallback stays for back-compat). -
Reports surface —
frontend/src/reports/issues-report.tsxon top of the existingGET /reports/issues. Charts: throughput (events/day), lead time (p50/p90), WIP, stale, untriaged. Per §11 of the original plan pick@tremor/reactunless the team has already vendored something else. -
SLA / digest / on-call (§3.11) — daily digest email + per-team on-call rotation. Backend:
dp_on_call_rotation- a scheduled task. Frontend: a Settings → Notifications pane. Lower priority; can split into its own PR.
- Bulk-import CSV / API for issues (still §4).
- Custom fields beyond start / due dates.
- Full-text search across issue bodies (slice 4 — needs a separate Tantivy / pg_trgm decision).
- Backend:
crates/dp-fetcher/src/worker/handlers.rs(mentions extraction),crates/dp-store-pg/migrations/dp/0018_*.sql(mentions + saved-views tables),crates/dp-rest/src/issues_read.rs(mentions filter), newcrates/dp-rest/src/views.rs. - Frontend:
frontend/src/workflow/triage-page.tsx(URL form?view=<id>), newfrontend/src/reports/issues-report.tsx,frontend/src/api/client.ts(views CRUD).
cargo test --workspacegreen.cd frontend && pnpm typecheckgreen.make buildgreen.- New
mentionsfilter has at least one happy-path integration test indp-rest. - Projects v2 pull-back has a fixture-driven handler test in
dp-fetcher(no live GitHub). - Saved-views CRUD has an OpenAPI registration so the SDK regenerates cleanly.
dp-fetcherreconciler tests need.with_org_kinds(&[])if you add new fixtures — wiremock will otherwise 404 on the org-scoped (teams / members) pass.PinDto.labelis still client-resolved; if pin counts grow past theGET /reposfirst-page window, denormalize it server-side (thetriage-page.tsxresolver was sized for the §13.5 sidebar render cap of 50)./me/queuekeyset pagination uses(updated_at, id); any new arm added to the queue should ride the same covering index (dp_issues_updated_at_idx) and respectLIMIT $cap.
{ "github": { "logins": ["alice-acme", "alice", "alice-oncall"], // identities "user_ids": [12345678, 8765, 99887766], "orgs": ["acme-co", "acme-platform", "acme-oss"], "in_allowed_org": true } }