mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 17:10:43 +02:00
* feat(server): custom issue properties — definitions, typed values, CLI (MUL-4463)
Workspace-level property definitions (issue_property table; 7 types:
text/number/select/multi_select/date/checkbox/url) plus a typed value bag
on each issue (issue.properties JSONB keyed by definition UUID, mirroring
the metadata machinery: single-key atomic writes, 16KB cap, GIN index).
- Definitions: owner/admin only; agent actors rejected (agents propose,
humans confirm). 20 active per workspace, 50 options per select,
reserved built-in names blocked, archive instead of delete.
- Values: any member or agent; per-type validation with self-correcting
error messages that enumerate legal option ids.
- API: /api/properties CRUD + PUT/DELETE /api/issues/{id}/properties/{propertyId};
issue responses always emit the properties bag.
- CLI: multica property list/get/create/update/archive/unarchive and
multica issue property list/set/unset with name→id translation.
- Events: property:created/updated, issue_properties:changed.
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): custom properties settings tab + issue sidebar editors (MUL-4463)
- Settings → Properties: definition management mirroring the Labels tab
(list with type badges/option chips/usage counts, create/edit dialog
with option editor, archive/restore, 20-cap indicator). Admin-gated;
members see a read-only catalog.
- Issue detail sidebar: custom properties join the built-in optional
props' progressive disclosure — set values render as rows with
type-appropriate editors (select/multi-select pickers, calendar,
yes/no, inline input for text/number/url), unset ones live in the
same '+ Add property' menu behind a separator. Archived definitions
render read-only until cleared.
- Core: property types, zod schemas (lenient type strings for forward
compat), api client methods, React Query hooks with optimistic
single-key value writes, ws-updaters + realtime wiring for
property:created/updated and issue_properties:changed.
- Locales: en/zh-Hans/ja/ko strings; Issue fixtures gain properties: {}.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address MUL-4463 review round 1 — mobile CI, option guard, mutation safety, schema tolerance
- mobile: EMPTY_ISSUE_FALLBACK gains the required properties field (mobile
typecheck was the red CI check).
- server: PATCH /api/properties/{id} rejects config updates that remove
select options still referenced by issues (409 with a per-option usage
census via jsonb ?); renames keep ids and pass. Integration test included.
- core: property value mutations are serialized per workspace via mutation
scope, snapshot the bag from detail OR list caches (board surfaces have no
detail cache — the old path overwrote whole bags with one key), roll back
to the snapshot or invalidate on error, and the last settled mutation does
an authoritative detail+catalog invalidate (usage counts reconcile).
- schemas: unknown-shaped property values (future server types) are dropped
per-entry in a preprocess step instead of failing the whole IssueSchema
and blanking lists through parseWithFallback; test updated to lock the
tolerant behavior.
- realtime: reconnect invalidation covers the property catalog; every
issue_properties:changed event also refreshes catalog usage counts.
- ui: number editor accepts decimals (step=any); settings usage count
pluralizes (issue/issues) with CJK-safe plural keys.
Co-authored-by: multica-agent <github@multica.ai>
* fix(migrations): renumber issue properties to 179 and build the GIN index concurrently
main's migration sequence advanced twice under this PR (167 collision, then
an upstream renumber wave that claimed 178), so issue properties now sits at
179 — verified against main's current tip by the prefix-uniqueness lint.
The properties GIN index moves to its own single-statement migration (180)
using CREATE INDEX CONCURRENTLY — a plain CREATE INDEX on the hot issue
table would block writes for the duration of the build. Mirrors the
119_user_created_at_index pattern; full-chain dry-run on a fresh database
passes through 180.
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): custom-property list surfaces — filter, cards, sort, board grouping (MUL-4463 M2)
Brings custom properties to the issue list surfaces on top of the M1
definitions/values core:
- Filter: per-definition sections in the Filter dropdown (select /
multi_select options with color dots and counts; checkbox as Yes/No
pseudo-options). OR within a definition, AND across definitions;
client-side in applyIssueFilters, mirrored into filterAssigneeGroups
for the assignee-grouped board. Included in active-filter count and
Clear all.
- Cards: per-property Display toggles (cardPropertyIds) render value
chips on board cards and list rows via CustomPropertyValueDisplay.
- Sort: SortField gains property:<id> for number/date definitions.
Server keeps position order (fixed sort enum); the surface controller
re-sorts client-side, swimlane/gantt reuse the same comparator.
Date-only strings compare lexically; missing values sort last.
- Board grouping: IssueGrouping gains property:<id> for select
definitions — one column per option (definition order) plus a
trailing No-value column, option-colored headings. Drag-drop moves
position via UpdateIssue and applies the value through
useSetIssueProperty/useUnsetIssueProperty (properties are not part
of UpdateIssueRequest). Stale persisted property groupings fall back
to status columns.
View-store: propertyFilters + cardPropertyIds persisted via the
partialize allowlist; clearFilters resets property filters; new fields
deep-merge cleanly into pre-existing persisted snapshots.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address MUL-4463 review round 2 — desc sort, option bucketing, archived-state reconciliation
- sort: direction now applies to value comparison only; issues without a
value sort last in BOTH directions (the whole-array reverse flipped them
to the front on desc). Test covers the desc+missing case.
- board: values referencing an option removed from the definition bucket
into the No-value column instead of vanishing (unmatched column ids
dropped the issue entirely). Defense-in-depth behind the new server-side
in-use guard; drag-utils test locks both behaviors.
- controller: persisted propertyFilters keyed by archived/deleted
definitions are stripped before reaching the filter predicates, and a
persisted property sort on a non-active definition degrades to manual
order — previously both kept silently applying while the header claimed
otherwise. The filter badge counts only active-definition filters.
Co-authored-by: multica-agent <github@multica.ai>
* feat(properties): server-side property filtering and sorting on list endpoints
Property filter/sort now execute in the database, so results are correct
across the full issue set — not just the loaded 50-per-status window
(closes MUL-4493 item 1's filter/sort half; requested on MUL-4463).
- New `properties` query param on ListIssues and ListGroupedIssues:
JSON {definitionId: [values]} compiled to an AND-of-ORs containment
check (double NOT EXISTS over jsonb_array_elements). One value expands
to every storage shape it could match — string (select), array element
(multi_select), boolean (checkbox) — so the handler stays type-agnostic.
Guarded at 20 definitions / 50 values.
- `sort=property:<definitionId>` resolves the definition and orders by a
typed expression (numeric CASE cast for number, NULLIF text for
date/text/url); missing values sort last in both directions. Malformed
ids 400; unknown/archived definitions degrade to position order instead
of breaking stale clients.
- Frontend: the property filter and property sort ride the IssueSortParam
window bag, so every surface (workspace + my-issues variants), query
key, and per-status load-more page carries them automatically. The
client-side re-sort layer is gone; applyIssueFilters keeps its property
predicate as an optimistic-update backstop.
- Regression test seeds 55 issues and proves a match at position 55 is
returned by a filtered 50-row page, plus sort order/missing-last,
AND-across-definitions, and the 400/fallback sort paths.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address review round 3 — cache reconcile, merged-scope order, GIN-indexable filter, pool loader
- Cache reconciliation: property value writes (mutation settle + WS event)
now invalidate every issue window whose server-side shape depends on
property values — queries filtered by `properties` or sorted by
`property:<id>` (detected via query-key predicate), covering flat lists,
assignee groups, and my-issues variants. Windows without property params
keep the cheap in-place patch. Fixes stale ordering/membership/counts
under staleTime:Infinity.
- My Issues "All" scope: merged assigned/created/involves results are
re-sorted with a comparator mirroring the server ORDER BY semantics
(including property sorts and missing-last, created_at DESC tiebreak) in
both the flat and assignee-grouped merge paths — relation concatenation
no longer overrides the user's sort.
- Filter predicate rebuilt as plain bind-parameter containment ORs
(AND across definitions): EXPLAIN now shows BitmapOr over
idx_issue_properties_gin (the correlated jsonb_array_elements form
defeated the index). Alternatives capped at 256 bind params.
- Property-grouped board gains a pool loader strip: one sentinel per
status that still has server rows, keeping every issue reachable until
per-column pagination lands (MUL-4493).
- Windowing regression test hardened: explicit positions + an assertion
that the unfiltered first page excludes the target (the old fixture tied
at position 0 and the created_at DESC tiebreak put the target on page
one, proving nothing).
- Rollback safety: /api/properties 404 (old server) degrades to an empty
catalog instead of a query error, which also keeps property params from
ever being sent to pre-property servers; migration 179's CHECK
constraints switch to NOT VALID + VALIDATE so the exclusive lock is
instantaneous.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): harden concurrency and cache coordination from clean-room review
Backend (MUL-4762 F1/F4/F5):
- withPropertyLock: pg_advisory_xact_lock helper; definition create/update
and value writes now serialize config-vs-value and cap-vs-insert races
(workspace-level 'props:' lock + per-definition 'prop:' lock, ordered).
- propertySortExpr degrades archived definitions to position sort.
Frontend (F2/F3/F6):
- onIssuePropertiesChanged invalidates plain assignee-group caches too.
- Property value mutations cancel list refetches in onMutate and roll back
only the touched key against the current bag (concurrent WS writes to
other keys survive a failed write).
- useUpdateIssue reconcile drops the stale properties bag from the server
snapshot; the property pipeline owns that field.
- Surface controller passes persisted property filters/sorts through
until the catalog query settles (cold cache no longer strips them).
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): open_only branch honors the properties filter
ListOpenIssues takes the parsed AND-of-ORs containment groups as a single
jsonb properties_filter param and unrolls them with a static double
NOT EXISTS; previously the open_only path parsed the properties param and
silently dropped it (clean-room review F7a).
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): toast on failed board drag to a property column
Property-column drags rolled the card back silently on failure; mirror
the status/assignee drag path (use-issue-surface-actions) so the
snap-back is explained (clean-room review F3, drag half).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
644 lines
25 KiB
TypeScript
644 lines
25 KiB
TypeScript
import { keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query";
|
||
import { api } from "../api";
|
||
import type {
|
||
GroupedIssuesResponse,
|
||
Issue,
|
||
IssueStatus,
|
||
ListGroupedIssuesParams,
|
||
ListIssuesParams,
|
||
ListIssuesCache,
|
||
} from "../types";
|
||
import { ALL_STATUSES } from "./config";
|
||
|
||
export interface IssueSortParam {
|
||
sort_by?: ListIssuesParams["sort_by"];
|
||
sort_direction?: ListIssuesParams["sort_direction"];
|
||
date_field?: ListIssuesParams["date_field"];
|
||
date_start?: ListIssuesParams["date_start"];
|
||
date_end?: ListIssuesParams["date_end"];
|
||
/** Server-side custom-property filter (definition id → accepted values).
|
||
* Lives in the sort/window bag so every list surface, query key, and
|
||
* load-more page carries it automatically. */
|
||
properties?: ListIssuesParams["properties"];
|
||
}
|
||
|
||
export const issueKeys = {
|
||
all: (wsId: string) => ["issues", wsId] as const,
|
||
/** PREFIX for invalidation — no sort. */
|
||
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
|
||
/** FULL KEY for queryOptions — includes sort. */
|
||
listSorted: (wsId: string, sort?: IssueSortParam) =>
|
||
[...issueKeys.list(wsId), sort ?? {}] as const,
|
||
assigneeGroupsAll: (wsId: string) =>
|
||
[...issueKeys.all(wsId), "assignee-groups"] as const,
|
||
assigneeGroups: (wsId: string, filter: AssigneeGroupedIssuesFilter) =>
|
||
[...issueKeys.assigneeGroupsAll(wsId), filter] as const,
|
||
/** All "my issues" queries — use for bulk invalidation. */
|
||
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
|
||
/** PREFIX for per-scope invalidation — no sort. */
|
||
myList: (wsId: string, scope: string, filter: MyIssuesFilter) =>
|
||
[...issueKeys.myAll(wsId), scope, filter] as const,
|
||
/** FULL KEY for queryOptions — includes sort. */
|
||
myListSorted: (wsId: string, scope: string, filter: MyIssuesFilter, sort?: IssueSortParam) =>
|
||
[...issueKeys.myList(wsId, scope, filter), sort ?? {}] as const,
|
||
myAssigneeGroupsAll: (wsId: string) =>
|
||
[...issueKeys.myAll(wsId), "assignee-groups"] as const,
|
||
myAssigneeGroups: (
|
||
wsId: string,
|
||
scope: string,
|
||
filter: AssigneeGroupedIssuesFilter,
|
||
) => [...issueKeys.myAssigneeGroupsAll(wsId), scope, filter] as const,
|
||
/** All Project Gantt queries — prefix-match key for cross-project invalidation. */
|
||
projectGanttAll: (wsId: string) =>
|
||
[...issueKeys.all(wsId), "project-gantt"] as const,
|
||
/**
|
||
* Per-project Gantt issue list (scheduled-only). Uses its own cache key
|
||
* rather than reusing the bucketed `myList` cache so WS handlers and
|
||
* cache helpers don't have to special-case a non-bucketed shape under
|
||
* the `my` prefix.
|
||
*/
|
||
projectGantt: (wsId: string, projectId: string) =>
|
||
[...issueKeys.projectGanttAll(wsId), projectId] as const,
|
||
detail: (wsId: string, id: string) =>
|
||
[...issueKeys.all(wsId), "detail", id] as const,
|
||
/** Resolve a bare issue identifier (e.g. "MUL-123") to an issue. */
|
||
identifier: (wsId: string, identifier: string) =>
|
||
[...issueKeys.all(wsId), "identifier", identifier] as const,
|
||
children: (wsId: string, id: string) =>
|
||
[...issueKeys.all(wsId), "children", id] as const,
|
||
/** Prefix for invalidating all batched-children queries in a workspace. */
|
||
childrenByParentsAll: (wsId: string) =>
|
||
[...issueKeys.all(wsId), "children-by-parents"] as const,
|
||
/** Full key — includes sorted parent ids for cache stability. */
|
||
childrenByParents: (wsId: string, parentIds: readonly string[]) =>
|
||
[...issueKeys.childrenByParentsAll(wsId), parentIds] as const,
|
||
childProgress: (wsId: string) =>
|
||
[...issueKeys.all(wsId), "child-progress"] as const,
|
||
/** Prefix-match keys for invalidating the per-issue caches below across
|
||
* all issues. These keys carry no wsId, so `issueKeys.all(wsId)` does NOT
|
||
* cover them — WS reconnect recovery must invalidate these `*All`
|
||
* prefixes explicitly, or missed events leave them stale forever under
|
||
* the staleTime: Infinity default (#3953). */
|
||
timelineAll: () => ["issues", "timeline"] as const,
|
||
/** Full-issue timeline (single TanStack Query, no cursor). */
|
||
timeline: (issueId: string) =>
|
||
[...issueKeys.timelineAll(), issueId] as const,
|
||
/** Prefix across all issues — WS task lifecycle events invalidate here so
|
||
* an open composer's trigger preview refreshes when an agent's queue
|
||
* state changes (the dedup guard makes the answer queue-dependent). */
|
||
commentTriggerPreviewAll: () => ["issues", "comment-trigger-preview"] as const,
|
||
/** PREFIX for invalidation — the composer hook appends parent + content signature. */
|
||
commentTriggerPreview: (issueId: string) =>
|
||
[...issueKeys.commentTriggerPreviewAll(), issueId] as const,
|
||
/** Prefix across all issue-trigger previews (assign/status/create/batch).
|
||
* WS task lifecycle events invalidate here so the answer revalidates when an
|
||
* agent's queue state changes (the status source's pending dedup makes it
|
||
* queue-dependent, mirroring commentTriggerPreviewAll). */
|
||
issueTriggerPreviewAll: () => ["issues", "issue-trigger-preview"] as const,
|
||
/** PREFIX — the picker hook appends a signature of the prospective write. */
|
||
issueTriggerPreview: (signature: string) =>
|
||
[...issueKeys.issueTriggerPreviewAll(), signature] as const,
|
||
reactionsAll: () => ["issues", "reactions"] as const,
|
||
reactions: (issueId: string) =>
|
||
[...issueKeys.reactionsAll(), issueId] as const,
|
||
subscribersAll: () => ["issues", "subscribers"] as const,
|
||
subscribers: (issueId: string) =>
|
||
[...issueKeys.subscribersAll(), issueId] as const,
|
||
usageAll: () => ["issues", "usage"] as const,
|
||
usage: (issueId: string) => [...issueKeys.usageAll(), issueId] as const,
|
||
attachmentsAll: () => ["issues", "attachments"] as const,
|
||
/** Issue-level attachments — used by the description editor so its
|
||
* inline file-card / image NodeViews can re-sign download URLs at
|
||
* click time. */
|
||
attachments: (issueId: string) =>
|
||
[...issueKeys.attachmentsAll(), issueId] as const,
|
||
/** Prefix-match key for invalidating tasks across all issues — used by
|
||
* the global WS task: prefix path so any task lifecycle event refreshes
|
||
* every per-issue list, regardless of which issue is currently mounted. */
|
||
tasksAll: () => ["issues", "tasks"] as const,
|
||
/** Per-issue task list (issue-detail Execution log section). */
|
||
tasks: (issueId: string) => [...issueKeys.tasksAll(), issueId] as const,
|
||
};
|
||
|
||
export type MyIssuesFilter = Pick<
|
||
ListIssuesParams,
|
||
| "assignee_id"
|
||
| "assignee_ids"
|
||
| "assignee_types"
|
||
| "creator_id"
|
||
| "project_id"
|
||
| "involves_user_id"
|
||
>;
|
||
|
||
export type AssigneeGroupedIssuesFilter = Omit<
|
||
ListGroupedIssuesParams,
|
||
"group_by" | "limit" | "offset" | "group_assignee_type" | "group_assignee_id"
|
||
>;
|
||
|
||
/** Page size per status column. */
|
||
export const ISSUE_PAGE_SIZE = 50;
|
||
|
||
/**
|
||
* Statuses fetched and paginated into the list/board cache — every lifecycle
|
||
* status, `cancelled` included. `cancelled` is a first-class default status
|
||
* (MUL-4290), so it lives in the cache and renders like any other column;
|
||
* there is no separate "visible board" subset. This constant governs
|
||
* fetch/cache membership.
|
||
*/
|
||
export const PAGINATED_STATUSES: readonly IssueStatus[] = ALL_STATUSES;
|
||
|
||
/** Flatten a bucketed response to a single Issue[] for consumers that want the whole list. */
|
||
export function flattenIssueBuckets(data: ListIssuesCache) {
|
||
const out = [];
|
||
for (const status of PAGINATED_STATUSES) {
|
||
const bucket = data.byStatus[status];
|
||
if (bucket) out.push(...bucket.issues);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async function fetchFirstPages(filter: MyIssuesFilter = {}, sort?: IssueSortParam): Promise<ListIssuesCache> {
|
||
const responses = await Promise.all(
|
||
PAGINATED_STATUSES.map((status) =>
|
||
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...sort, ...filter }),
|
||
),
|
||
);
|
||
const byStatus: ListIssuesCache["byStatus"] = {};
|
||
PAGINATED_STATUSES.forEach((status, i) => {
|
||
const res = responses[i]!;
|
||
byStatus[status] = { issues: res.issues, total: res.total };
|
||
});
|
||
return { byStatus };
|
||
}
|
||
|
||
/**
|
||
* "All my issues" — union of three server filters:
|
||
* assignee_id=me OR creator_id=me OR involves_user_id=me
|
||
*
|
||
* The backend has no OR-across-user-filters today, so we run the three
|
||
* existing single-filter fetches in parallel and dedupe on the client by
|
||
* issue id within each status bucket. Order within each bucket preserves
|
||
* the first-seen position (each sub-fetch is already server-sorted).
|
||
*
|
||
* Personal lists are bounded (tens to a few hundred issues across all
|
||
* three relations), so 3× the request count is acceptable — a single
|
||
* fetchFirstPages already runs 7 status fetches in parallel, so the total
|
||
* here is 21 small parallel requests. Easy enough; no need to add a new
|
||
* backend query just for this scope.
|
||
*
|
||
* `total` per bucket is set to the merged length, not the true server
|
||
* total — pagination on the "All" scope is out of scope; the first
|
||
* 50-per-status × 3 widening (deduped) is what the page renders.
|
||
*/
|
||
const MERGE_PRIORITY_RANK: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 };
|
||
|
||
/**
|
||
* Comparator mirroring the server's ORDER BY semantics (including
|
||
* `property:<id>` sorts and missing-values-last). The merged "All" scope
|
||
* concatenates three independently-ordered queries, so without a re-sort the
|
||
* relation order (assigned → created → involves) would override the sort the
|
||
* user picked — e.g. assigned=9 rendering before created=1 (review round 3).
|
||
*/
|
||
function compareIssuesForSort(a: Issue, b: Issue, sort?: IssueSortParam): number {
|
||
const by = sort?.sort_by ?? "position";
|
||
const dir = by !== "position" && sort?.sort_direction === "desc" ? -1 : 1;
|
||
const tieBreak = () =>
|
||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); // created_at DESC, server parity
|
||
|
||
const missingAware = (av: string | null, bv: string | null): number => {
|
||
if (!av && !bv) return tieBreak();
|
||
if (!av) return 1;
|
||
if (!bv) return -1;
|
||
return dir * av.localeCompare(bv) || tieBreak();
|
||
};
|
||
|
||
if (by.startsWith("property:")) {
|
||
const propertyId = by.slice("property:".length);
|
||
const av = a.properties?.[propertyId];
|
||
const bv = b.properties?.[propertyId];
|
||
const aMissing = av === undefined || Array.isArray(av) || typeof av === "boolean";
|
||
const bMissing = bv === undefined || Array.isArray(bv) || typeof bv === "boolean";
|
||
if (aMissing && bMissing) return tieBreak();
|
||
if (aMissing) return 1;
|
||
if (bMissing) return -1;
|
||
if (typeof av === "number" && typeof bv === "number") return dir * (av - bv) || tieBreak();
|
||
return dir * String(av).localeCompare(String(bv)) || tieBreak();
|
||
}
|
||
switch (by) {
|
||
case "priority":
|
||
return dir * ((MERGE_PRIORITY_RANK[a.priority] ?? 9) - (MERGE_PRIORITY_RANK[b.priority] ?? 9)) || tieBreak();
|
||
case "title":
|
||
return dir * a.title.localeCompare(b.title) || tieBreak();
|
||
case "created_at":
|
||
return dir * (new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||
case "start_date":
|
||
return missingAware(a.start_date, b.start_date);
|
||
case "due_date":
|
||
return missingAware(a.due_date, b.due_date);
|
||
case "position":
|
||
default:
|
||
return a.position - b.position || tieBreak();
|
||
}
|
||
}
|
||
|
||
async function fetchAllMyFirstPages(userId: string, sort?: IssueSortParam): Promise<ListIssuesCache> {
|
||
const [byAssignee, byCreator, byInvolves] = await Promise.all([
|
||
fetchFirstPages({ assignee_id: userId }, sort),
|
||
fetchFirstPages({ creator_id: userId }, sort),
|
||
fetchFirstPages({ involves_user_id: userId }, sort),
|
||
]);
|
||
const byStatus: ListIssuesCache["byStatus"] = {};
|
||
for (const status of PAGINATED_STATUSES) {
|
||
const seen = new Set<string>();
|
||
const merged: Issue[] = [];
|
||
for (const cache of [byAssignee, byCreator, byInvolves]) {
|
||
const bucket = cache.byStatus[status];
|
||
if (!bucket) continue;
|
||
for (const issue of bucket.issues) {
|
||
if (seen.has(issue.id)) continue;
|
||
seen.add(issue.id);
|
||
merged.push(issue);
|
||
}
|
||
}
|
||
merged.sort((a, b) => compareIssuesForSort(a, b, sort));
|
||
byStatus[status] = { issues: merged, total: merged.length };
|
||
}
|
||
return { byStatus };
|
||
}
|
||
|
||
/**
|
||
* Sibling of {@link fetchAllMyFirstPages} for the assignee-grouped board
|
||
* view. Runs the three single-filter grouped queries in parallel and
|
||
* merges groups by (assignee_type, assignee_id), deduping issues within
|
||
* each group. Extra filters from the page (statuses, priorities, etc.)
|
||
* pass through unchanged.
|
||
*/
|
||
async function fetchAllMyAssigneeGroups(
|
||
userId: string,
|
||
filter: AssigneeGroupedIssuesFilter,
|
||
sort?: IssueSortParam,
|
||
): Promise<GroupedIssuesResponse> {
|
||
const variants: AssigneeGroupedIssuesFilter[] = [
|
||
{ ...filter, assignee_id: userId },
|
||
{ ...filter, creator_id: userId },
|
||
{ ...filter, involves_user_id: userId },
|
||
];
|
||
const responses = await Promise.all(
|
||
variants.map((f) =>
|
||
api.listGroupedIssues({
|
||
group_by: "assignee",
|
||
limit: ISSUE_PAGE_SIZE,
|
||
offset: 0,
|
||
...sort,
|
||
...f,
|
||
}),
|
||
),
|
||
);
|
||
const groupKey = (g: GroupedIssuesResponse["groups"][number]) =>
|
||
`${g.assignee_type ?? "_"}::${g.assignee_id ?? "_"}`;
|
||
const merged = new Map<string, GroupedIssuesResponse["groups"][number]>();
|
||
for (const res of responses) {
|
||
for (const group of res.groups) {
|
||
const key = groupKey(group);
|
||
const existing = merged.get(key);
|
||
if (!existing) {
|
||
merged.set(key, {
|
||
...group,
|
||
issues: [...group.issues],
|
||
total: group.issues.length,
|
||
});
|
||
continue;
|
||
}
|
||
const seen = new Set(existing.issues.map((i) => i.id));
|
||
for (const issue of group.issues) {
|
||
if (seen.has(issue.id)) continue;
|
||
seen.add(issue.id);
|
||
existing.issues.push(issue);
|
||
}
|
||
existing.total = existing.issues.length;
|
||
}
|
||
}
|
||
const groups = [...merged.values()];
|
||
for (const group of groups) {
|
||
group.issues.sort((a, b) => compareIssuesForSort(a, b, sort));
|
||
}
|
||
return { groups };
|
||
}
|
||
|
||
/**
|
||
* CACHE SHAPE NOTE: The raw cache stores {@link ListIssuesCache} (buckets keyed
|
||
* by status, each with `{ issues, total }`), and `select` flattens it to
|
||
* `Issue[]` for consumers. Mutations and ws-updaters must use
|
||
* `setQueryData<ListIssuesCache>(...)` and preserve the byStatus shape.
|
||
*
|
||
* Fetches the first page of each paginated status in parallel. Use
|
||
* {@link useLoadMoreByStatus} to paginate a specific status into the cache.
|
||
*/
|
||
export function issueListOptions(wsId: string, sort?: IssueSortParam) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.listSorted(wsId, sort),
|
||
queryFn: () => fetchFirstPages({}, sort),
|
||
select: flattenIssueBuckets,
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
}
|
||
|
||
export function issueAssigneeGroupsOptions(
|
||
wsId: string,
|
||
filter: AssigneeGroupedIssuesFilter,
|
||
sort?: IssueSortParam,
|
||
) {
|
||
return queryOptions<GroupedIssuesResponse>({
|
||
queryKey: issueKeys.assigneeGroups(wsId, { ...filter, ...sort }),
|
||
queryFn: () =>
|
||
api.listGroupedIssues({
|
||
group_by: "assignee",
|
||
limit: ISSUE_PAGE_SIZE,
|
||
offset: 0,
|
||
...sort,
|
||
...filter,
|
||
}),
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Server-filtered issue list for the My Issues page.
|
||
* Each scope gets its own cache entry so switching tabs is instant after first load.
|
||
*/
|
||
export function myIssueListOptions(
|
||
wsId: string,
|
||
scope: string,
|
||
filter: MyIssuesFilter,
|
||
// Required when scope === "all" — the user id whose three relations
|
||
// (assignee, creator, agents+squads) we union over. For every other
|
||
// scope the filter object already carries the relevant id and userId
|
||
// is ignored.
|
||
userId?: string,
|
||
sort?: IssueSortParam,
|
||
) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.myListSorted(wsId, scope, filter, sort),
|
||
queryFn: () =>
|
||
scope === "all" && userId
|
||
? fetchAllMyFirstPages(userId, sort)
|
||
: fetchFirstPages(filter, sort),
|
||
select: flattenIssueBuckets,
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Page size for the scheduled-issue fetch. The Gantt view always pulls every
|
||
* scheduled issue (no client pagination), so this is just the chunk size we
|
||
* use to walk the server's `(limit, offset)` window until we hit `total`.
|
||
*/
|
||
export const PROJECT_GANTT_PAGE_LIMIT = 500;
|
||
|
||
/**
|
||
* Paranoia cap on the loop in {@link fetchProjectGanttIssues}. Real projects
|
||
* shouldn't come close to this — a single project carrying 50k scheduled
|
||
* issues is already a product problem, not a Gantt-rendering one — but the
|
||
* guard prevents a buggy server `total` from spinning the loop forever.
|
||
*/
|
||
export const PROJECT_GANTT_MAX_ISSUES = 10_000;
|
||
|
||
async function fetchProjectGanttIssues(projectId: string) {
|
||
const issues = [];
|
||
let offset = 0;
|
||
while (offset < PROJECT_GANTT_MAX_ISSUES) {
|
||
const res = await api.listIssues({
|
||
project_id: projectId,
|
||
scheduled: true,
|
||
limit: PROJECT_GANTT_PAGE_LIMIT,
|
||
offset,
|
||
});
|
||
issues.push(...res.issues);
|
||
if (res.issues.length < PROJECT_GANTT_PAGE_LIMIT) break;
|
||
if (issues.length >= res.total) break;
|
||
offset += PROJECT_GANTT_PAGE_LIMIT;
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/**
|
||
* One-shot fetch of every scheduled issue (`start_date` or `due_date` set)
|
||
* for a project. The Project Gantt view consumes this directly — no status
|
||
* bucketing, no client-side pagination, no Load-all affordance — because
|
||
* the scheduled subset is bounded enough to come back in a small handful of
|
||
* requests.
|
||
*
|
||
* Backed by `GET /api/issues?scheduled=true&project_id=…`; the SQL filter
|
||
* mirrors the same `(start_date IS NOT NULL OR due_date IS NOT NULL)`
|
||
* predicate the Gantt view applies on the client. Pages are walked until
|
||
* `total` is reached so an oversized project can't silently lose bars past
|
||
* the first page.
|
||
*/
|
||
export function projectGanttIssuesOptions(wsId: string, projectId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.projectGantt(wsId, projectId),
|
||
queryFn: () => fetchProjectGanttIssues(projectId),
|
||
});
|
||
}
|
||
|
||
export function myIssueAssigneeGroupsOptions(
|
||
wsId: string,
|
||
scope: string,
|
||
filter: AssigneeGroupedIssuesFilter,
|
||
// See myIssueListOptions for the userId contract — only consulted when
|
||
// scope === "all", and powers the 3-fetch grouped union.
|
||
userId?: string,
|
||
sort?: IssueSortParam,
|
||
) {
|
||
return queryOptions<GroupedIssuesResponse>({
|
||
queryKey: issueKeys.myAssigneeGroups(wsId, scope, { ...filter, ...sort }),
|
||
queryFn: () =>
|
||
scope === "all" && userId
|
||
? fetchAllMyAssigneeGroups(userId, filter, sort)
|
||
: api.listGroupedIssues({
|
||
group_by: "assignee",
|
||
limit: ISSUE_PAGE_SIZE,
|
||
offset: 0,
|
||
...sort,
|
||
...filter,
|
||
}),
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
}
|
||
|
||
export function issueDetailOptions(wsId: string, id: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.detail(wsId, id),
|
||
queryFn: () => api.getIssue(id),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Resolve a bare issue identifier ("MUL-123") to its issue, or `null`.
|
||
*
|
||
* Backs the Linear-style autolink: the backend `q` search matches an
|
||
* identifier on issue NUMBER only (prefix-agnostic — `MUL-123` and `TES-123`
|
||
* both hit number 123), so the exact `identifier === value` filter here is
|
||
* what enforces the workspace prefix. A non-existent or wrong-prefix
|
||
* identifier resolves to `null` and renders as plain text.
|
||
*
|
||
* Server state → TanStack Query; the key includes `wsId` and the identifier,
|
||
* so identical identifiers across the app share one request. Caller gates
|
||
* `enabled` (identifier shape + workspace prefix).
|
||
*/
|
||
export function issueIdentifierOptions(wsId: string, identifier: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.identifier(wsId, identifier),
|
||
queryFn: async ({ signal }) => {
|
||
const res = await api.searchIssues({
|
||
q: identifier,
|
||
limit: 10,
|
||
include_closed: true,
|
||
signal,
|
||
});
|
||
return res.issues.find((i) => i.identifier === identifier) ?? null;
|
||
},
|
||
// Identifier→issue mapping is effectively immutable; avoid refetch churn
|
||
// when the same key renders across many comments/messages.
|
||
staleTime: 5 * 60_000,
|
||
});
|
||
}
|
||
|
||
export function childIssueProgressOptions(wsId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.childProgress(wsId),
|
||
queryFn: () => api.getChildIssueProgress(),
|
||
select: (data) => {
|
||
const map = new Map<string, { done: number; total: number }>();
|
||
for (const entry of data.progress) {
|
||
map.set(entry.parent_issue_id, { done: entry.done, total: entry.total });
|
||
}
|
||
return map;
|
||
},
|
||
});
|
||
}
|
||
|
||
export function childIssuesOptions(wsId: string, id: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.children(wsId, id),
|
||
queryFn: () => api.listChildIssues(id).then((r) => r.issues),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Server cap on parent_ids per `GET /api/issues/children` request — must
|
||
* match `listChildrenByParentsLimit` in server/internal/handler/issue.go.
|
||
* Exceeding it returns 400, so the client chunks larger requests.
|
||
*/
|
||
export const CHILDREN_BY_PARENTS_CHUNK_SIZE = 200;
|
||
|
||
/**
|
||
* Batched variant of {@link childIssuesOptions}: fetches children for all
|
||
* given parents in `GET /api/issues/children?parent_ids=…` requests, chunked
|
||
* to {@link CHILDREN_BY_PARENTS_CHUNK_SIZE} parents each. The queryFn also
|
||
* hydrates each parent's per-parent issueKeys.children cache so other
|
||
* surfaces (issue-detail sub-issues panel, set-parent modal) hit the primed
|
||
* cache instead of re-fetching. Hydration happens in queryFn (not a
|
||
* useEffect) to avoid the setQueryData → re-render → effect loop.
|
||
*
|
||
* Used by SwimLaneView to resolve parent lanes without an N-request fan-out.
|
||
* parentIds must be sorted + deduplicated by the caller for a stable cache key.
|
||
*/
|
||
async function fetchAndHydrateChildrenByParents(
|
||
qc: QueryClient,
|
||
wsId: string,
|
||
parentIds: readonly string[],
|
||
) {
|
||
// Chunk to respect the server cap (parallel, since chunks are independent).
|
||
const chunks: string[][] = [];
|
||
for (let i = 0; i < parentIds.length; i += CHILDREN_BY_PARENTS_CHUNK_SIZE) {
|
||
chunks.push([...parentIds.slice(i, i + CHILDREN_BY_PARENTS_CHUNK_SIZE)]);
|
||
}
|
||
const responses = await Promise.all(chunks.map((c) => api.listChildrenByParents(c)));
|
||
const grouped = new Map<string, Issue[]>();
|
||
for (const response of responses) {
|
||
for (const issue of response.issues) {
|
||
if (!issue.parent_issue_id) continue;
|
||
const bucket = grouped.get(issue.parent_issue_id);
|
||
if (bucket) {
|
||
bucket.push(issue);
|
||
} else {
|
||
grouped.set(issue.parent_issue_id, [issue]);
|
||
}
|
||
}
|
||
}
|
||
for (const [parentId, children] of grouped) {
|
||
// Only hydrate if the per-parent cache is empty — don't overwrite a
|
||
// fresher result that another query (e.g. issue-detail) may have written.
|
||
// This relies on useUpdateIssue.onMutate writing into the per-parent
|
||
// cache (not creating an empty one) — if that contract changes, batch
|
||
// hydration here would silently stop seeding new lanes.
|
||
const existing = qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId));
|
||
if (!existing || existing.length === 0) {
|
||
qc.setQueryData(issueKeys.children(wsId, parentId), children);
|
||
}
|
||
}
|
||
return grouped;
|
||
}
|
||
|
||
export function childrenByParentsOptions(
|
||
wsId: string,
|
||
parentIds: readonly string[],
|
||
qc: QueryClient,
|
||
) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.childrenByParents(wsId, parentIds),
|
||
queryFn: () => fetchAndHydrateChildrenByParents(qc, wsId, parentIds),
|
||
enabled: parentIds.length > 0,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Single-fetch timeline options. The endpoint returns the full ordered set of
|
||
* comments + activities for an issue (server caps at 2000 as a safety net).
|
||
* Cursor pagination was removed in #1929 — at observed data sizes (p99 ~30
|
||
* entries per issue) it added complexity without a UX win and broke reply
|
||
* threads at page boundaries.
|
||
*/
|
||
export function issueTimelineOptions(issueId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.timeline(issueId),
|
||
queryFn: () => api.listTimeline(issueId),
|
||
});
|
||
}
|
||
|
||
export function issueReactionsOptions(issueId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.reactions(issueId),
|
||
queryFn: async () => {
|
||
const issue = await api.getIssue(issueId);
|
||
return issue.reactions ?? [];
|
||
},
|
||
});
|
||
}
|
||
|
||
export function issueSubscribersOptions(issueId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.subscribers(issueId),
|
||
queryFn: () => api.listIssueSubscribers(issueId),
|
||
});
|
||
}
|
||
|
||
export function issueUsageOptions(issueId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.usage(issueId),
|
||
queryFn: () => api.getIssueUsage(issueId),
|
||
});
|
||
}
|
||
|
||
// Backs the description editor's fresh-sign download flow: NodeViews resolve
|
||
// an attachment id by matching the markdown URL against this list. The list
|
||
// is workspace-private metadata and lives on the same cache lifetime as the
|
||
// rest of the issue detail surface.
|
||
export function issueAttachmentsOptions(issueId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.attachments(issueId),
|
||
queryFn: () => api.listAttachments(issueId),
|
||
});
|
||
}
|