* perf(issues): resolve a page's duplicate originals in one read (MUL-7349)
Filling duplicate_of read each distinct original with its own primary-key
lookup, so a page of duplicates of different issues cost one round trip
per row. Pages now pass the originals their rows point at to
newStatusCategoryFiller, which loads them with one ListIssueRefsInWorkspace
read, the same way labelsByIssue loads a page's labels. List, open issues,
grouped, search and table rows do this; a response built on its own still
resolves its original, so a caller that passes nothing is slower, never
wrong.
A pointer left on a reopened issue is not looked up at all, and an
original that no longer exists is looked up once and left off.
Co-authored-by: multica-agent <[email protected]>
* fix(issues): stop logging stale duplicate pointers as unmarks (MUL-7349)
Servers from before the duplicate column reopened duplicates and deleted
originals without clearing the pointer. After an upgrade, the next write
to such an issue clears it, and the activity log compared raw pointers,
so it logged "removed duplicate mark" for a mark that no longer counted
and dropped the real status row in its place.
Event publishers now send only live marks: a pointer counts while the
issue is cancelled, the same rule reads apply. Separately, the status row
is dropped only when a duplicate row was actually written to stand in for
it; when the original is gone there is no duplicate row, and the reopen
used to leave no activity at all.
Co-authored-by: multica-agent <[email protected]>
* fix(issues): keep duplicate marks in background issue snapshots (MUL-7349)
IssueToMap hardcoded duplicate_of to null on the assumption that no
background publisher renders a marked issue. PublishAttachmentsChanged
does: a channel /issue with media re-reads the issue after a download of
up to 45 seconds, and a mark made in that window was broadcast as null.
Clients patch their cache with that snapshot, so the list and board
marker disappeared until the next refetch.
IssueToMapResolved now resolves duplicate_of the way the HTTP rendering
does; only a cancelled issue with a pointer costs a read.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(issues): make duplicate marks traceable and one click from the original (MUL-7349)
- Log duplicate_marked / duplicate_unmarked on the duplicate and
duplicate_added / duplicate_removed on its original, with the other issue
linked in the activity feed; the delete and GitHub merge paths now carry
both ends of the mark so those rows are written too.
- Expose duplicate_of_issue_id on issue responses (only while cancelled) so
list rows, board cards and table cells can show an arrow to the original
that opens it directly.
- Banner links as a whole to the original and shows its status; the undo
action becomes the secondary "Not a duplicate" with a toast.
- Sidebar duplicates show reporters and a count; the status picker reads
"Change original" on an issue that already is a duplicate; the mark
picker opens with same-title and recently viewed candidates.
* fix(issues): keep the duplicate pointer in step with CI shape tests (MUL-7349)
- Add duplicate_of_issue_id to the CLI --fields whitelist, which the
list-endpoint shape test checks against a real response.
- Project the column in the search parity test's legacy query copy and
scan it, so both queries read through the one scanner.
- Mount the row marker's query and navigation hooks only when the issue
is a duplicate, so surfaces rendered without a QueryClient (the table's
inline title) and thousands of ordinary rows subscribe to nothing.
* fix(issues): resolve a duplicate's original on the server and log one row per mark change (MUL-7349)
- Issue responses carry duplicate_of {id, identifier, title, status}, filled
beside the status category only while the issue is cancelled and the
original still exists; the bare pointer is no longer exposed. Rows read it
directly, so the marker makes no request and is a real link where the row
is not one.
- A mark change suppresses the generic status_changed row; the unmarked row
records where the status went. Covered through the real UpdateIssue path,
with the test server wiring activity listeners like main.go.
- The picker applies one selectability predicate to search results and
suggestions, so an existing duplicate can no longer be chosen.
* feat(issue-status): board fetches by category; archive retires without migration (MUL-6243)
Product decisions from the issue thread:
1. Board fetches by CATEGORY, not per status. PAGINATED_STATUSES becomes
PAGINATED_CATEGORIES and the cache buckets by category, so the board's
fan-out stays fixed at 7 parallel requests however many custom statuses a
workspace defines — instead of one more request per status added. Adds
status_category / status_categories filters to the issue list endpoints,
backed by the existing issue_effective_status() SQL function.
3. Archiving retires a status from FUTURE use only. It no longer refuses while
issues still carry it: those issues keep their status and keep resolving to
their category's behavior (Effective ignores archived_at), while Resolve
still rejects the status so nothing new can be assigned to it. The exclusive
catalog lock stays — it is what makes "no NEW assignment after archive"
exact rather than approximate.
Also lands the frontend catalog foundation: packages/core/issue-statuses with
useIssueStatuses(wsId) (React Query, keyed by wsId per the state rules) and a
pure buildIssueStatusCatalog for non-React callers, plus IssueStatusCategory as
a distinct type from the status key and status_category on issue payloads.
NOT included, and deliberately: widening IssueStatus to string. That change
forces every Record<IssueStatus, …> presentation lookup in packages/views to
resolve a key to its category first, and it is the first task of the follow-up
PR. Everything here is the groundwork that makes it mechanical.
Co-authored-by: multica-agent <[email protected]>
* fix(issue-status): restore the status index, make category authoritative, keep archived resolvable
Review round 1 on #7084 (MUL-6243):
1. Category filtering wrapped issue.status in issue_effective_status(), which
makes the (workspace_id, status) index unusable — and the board switched to
it unconditionally, so every workspace paid for it immediately. Categories
are now expanded to concrete status keys (issuestatus.ExpandCategories) and
filtered with an indexed status = ANY(...). Measured on a 100k-row table
with the production index: seq scan cost 27013 -> index-only scan cost 6.07.
A workspace with no custom statuses expands each category to exactly its own
key, reproducing the pre-feature query. Archived keys are included, since
issues left on them still belong in their category's column.
2. status_category was only filled for built-ins, so a custom-status issue
arriving over the websocket was silently dropped by addIssueToBuckets and a
cross-category update was skipped by the cache coordinator, drifting
off-window totals permanently. List, grouped, get, create, update and batch
responses now carry an authoritative category via a per-request Resolver
(archived included), and the client invalidates instead of no-oping when a
category is unresolvable.
3. The catalog was fetched without archived entries, so after archiving a
status the issues still on it lost their name and colour and had their
category guessed as todo — contradicting the decision that archiving leaves
existing issues untouched. Resolution now includes archived entries;
activeStatuses / inCategory expose only assignable ones to pickers.
Co-authored-by: multica-agent <[email protected]>
* fix(issue-status): category on every payload exit, one resolver per request, no same-category duplication
Review round 2 on #7084 (MUL-6243):
1. Category fill was incomplete and N+1. The create WEBSOCKET payload was
unfilled — only the creator's HTTP response was, which is too late for the
other tabs the event exists to serve. GetIssue, the open_only list, search,
the duplicate-conflict response, the table rows, the GitHub PR update and
the onboarding shim were also unfilled. Grouped and batch called the
single-response helper per row, so each custom-status row hit the catalog
separately.
Every exit now goes through newStatusCategoryFiller, a request-scoped
closure over ONE Resolver: built-in statuses still cost no query, and a page
of custom-status rows costs one catalog read rather than one per row.
2. A status KEY change within one category duplicated the card and inflated the
total. patchIssueInBuckets compared patch.status (a key) against loc.status
(a bucket CATEGORY), so in_review -> a custom human_review took the
cross-bucket path, then deleted from and re-inserted into the same bucket
from a pre-delete snapshot. It now resolves the destination category first
and compares category to category; same-category changes replace in place
and leave the total alone.
The destination category is resolved from the patch alone — the merged issue
inherits the previous status_category, which would have kept a genuinely
moved card in its old column. patchNeedsInvalidation lets the loaded branch
of the cache coordinator invalidate instead of silently no-oping on a status
it cannot resolve.
Also drops the unrelated share_link.sql.go churn from an sqlc version
difference, per the review nit.
The four cache cases were verified to fail against the previous logic (card
duplicated, total +1) before the fix was restored.
Co-authored-by: multica-agent <[email protected]>
* fix(issue-status): normalize status_category on every cache write; one filler per create
Review round 3 on #7084 (MUL-6243):
Must-fix — a cached entity kept the STALE status_category after a status
change. The server now sends a category on every issue, so a cached entity is
{status: "todo", status_category: "todo"}; an optimistic patch carries only
{status: "done"}, and a bare {...issue, ...patch} moved the card to the done
bucket while the entity still claimed todo. A single update self-heals from the
server response, but the batch API returns only {updated: n} and does not
refetch bucketed lists, so the inconsistency was permanent — and the next
off-window count then decremented the wrong bucket, recreating total drift.
normalizeStatusPatch now rewrites status_category to match status before any
cache write, applied at applyIssueChange's entry (covering the flat, table and
detail spreads), in patchIssueInBuckets, and in the children mutation. An
unresolvable custom key drops the inherited category rather than keeping it;
patchNeedsInvalidation still routes it to a refetch.
Test fixtures now carry status_category for built-ins, matching what the server
actually sends — without that the regression was invisible.
Non-blocking items also addressed:
- CreateIssue builds one filler and shares it between the broadcast payload and
the HTTP response, so a custom-status create reads the catalog once.
- Background events via IssueToMap now resolve an authoritative category
(IssueToMapWithCategory) for the task, issue and autopilot publishers. The
channel router keeps plain IssueToMap and says why: it always creates with the
built-in "todo", where key IS category.
The "one resolver serves a whole page" assertion was REMOVED rather than kept:
pg_stat counters lag, so a delta around one request passes even with a per-row
filler. Verified that directly before dropping it. The comment now states what
is and is not pinned instead of implying coverage that does not exist.
Co-authored-by: multica-agent <[email protected]>
* fix(issue-status): address review nits
- Normalize the single-update children optimistic patch too, matching the batch
path. It self-healed on onSettled, but it broke the entity/bucket invariant
for the duration of the request.
- Rename TestListEndpointsCarryStatusCategory: the old name claimed it covered
"without per row queries", which the test explicitly does not assert.
- Drop the unrelated gofmt churn in empty_claim_cache.go.
- Add a background-event assertion: IssueToMap leaves a custom status
uncategorized, IssueToMapWithCategory resolves it — the reason the publishers
use the latter.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>