mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 18:36:45 +08:00
* feat(issues): server-backed saved issue views API (MUL-4796) issue_view table (migrations 262-264: table + two CONCURRENTLY partial indexes) stores named filter definitions: query jsonb is the shared identity, display jsonb only seeds a user's first open. Scope model is workspace / my (with scope_variant, forced private) / project (validated scope_id, deleted with the project in the same app transaction). issue_view_preference (migration 265) holds each user's view-bar layout (hidden + order doc) per surface container, last-write-wins. CRUD + GET/PUT preference endpoints are gated on the saved_issue_views_v1 feature flag (404 while off, key published to the frontend config). Authorization: private views 404 for everyone but the owner; shared views editable by owner or workspace admin; updates use expected_revision -> 409. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(core): saved issue views client core (MUL-4796) - zod schemas + parseWithFallback for views and preferences (definition blobs stay loose records interpreted per definition_version), with malformed-response tests per the API-compat rules - issue-views module: list/create/update/delete + preference queries with wsId-scoped keys; preference upsert is optimistic (toggle-grade rollback) - active-view store (zustand client state, URL is the durable carrier) and useActiveIssueView with missing-view detection - baseline.ts: enum-sanitized FilterSnapshot of a view's query for value-level locking, delta chips, and baseline-aware resets - view-store: clearFilterDimension + resetFiltersTo actions; surface store seeding routes server blobs through mergeViewStatePersisted Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issues): three-bar priority icons with a distinct urgent badge low/medium/high share one three-bar frame (fill level = severity, ghost bars at 35%); urgent breaks out of the scale as a filled badge with an exclamation cutout — it is an interrupt, not a fourth bar. All glyphs share the same 2-14 optical frame. Board card: PickerWrapper's bare block div gave its inline-flex trigger a line box (line-height 24px > 20px button), floating icons above the text midline — the wrappers are flex containers now, and the priority trigger gets a constant size-5 box so the row's rhythm doesn't depend on the glyph. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(views): saved views UI — view bar, save/edit dialog, locked view mode (MUL-4796) View bar replaces the built-in tab row on Issues and My Issues: built-in tabs and saved views render as one flat, per-user ordered row that wraps instead of overflowing, with a menu for new-view / manage. The manage dialog does drag reorder (translate-only transform, vertical axis lock), show/hide (anchor built-in unhidable), and edit/delete for owned views. Open-view semantics: the view's query is the baseline — its values render checked-and-disabled in the filter menu, never as chips; chips show only the user's additions and every reset path (chip x, clear, menu reset, filtered-empty CTA, board/swimlane hide-column) returns to the baseline, not to empty. Display seeds once per user (view:<id> surface key) and is free afterwards. Built-in tab click exits; deleted/revoked views fall back with a toast; ?view= deep links sync on web via a platform hook. Save dialog doubles as the edit dialog (PATCH with expected_revision -> 409 toast); manager edits seed from the view definition, in-view edits from the live panel. Filter chips bar, extracted IssueFilterMenu (virtual frozen anchor), and the reworked display panel round out the header. Also: filter menu consistency fixes (closeOnClick, calendar apply, TableColumnPicker checkbox items), SelectGroup padding, dropdown anchor passthrough, four-locale i18n. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(views): neutralize workspace tab filter while a saved view is open The Members/Agents tabs are a coarse assignee-type filter carried in the surface scope, outside the filter-menu dimensions a view can express. It kept applying underneath an open view, so the same shared view returned different rows depending on which tab the user stood on when opening it — with nothing on screen to explain the difference (tabs dimmed, no chip). While a view is active the workspace scope now resolves to actorKind "all": a view's results are defined by the view alone, matching what the dimmed tabs already imply. Deliberately NOT captured into saved queries — a view condition must be visible and lockable in the filter menu, and assignee-type has no menu representation (add the menu dimension first if that need ever materializes; the loose schema takes the field additively). Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): workspace scope_variant captures and applies the assignee-type tab A workspace view now saves which built-in tab it was created from (members/agents, NULL = all) as scope_variant, alongside the existing my-scope variants. The save dialog shows a scope selector so the variant can be switched later; scope_type stays immutable. While a view is open, its own variant drives the workspace scope's assignee-type axis instead of whichever tab the user stood on. - migration 266 widens the scope_variant CHECKs (members/agents on workspace, NULL = all; my keeps its four variants; project stays NULL) - UpdateIssueView persists scope_variant; handler validates the variant against the view's scope_type on create and update - issue-surface applies activeView.scope_variant to effectiveScope, replacing the blanket tab neutralization - issues-header seeds the dialog scope from the live tab Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): project pages get a real assignee-type axis and clearer variant copy The Members/Agents tabs on project pages were decorative: they wrote the global tab store but the project query never read it. The project scope now carries actorKind like the workspace scope (assignee_types composes with project_id server-side), so the tabs filter for real, and project views join the same optional scope_variant vocabulary — saved from the tab, switchable in the dialog, applied while the view is open. - migration 266 (unmerged) widened in place: project allows members/agents, NULL = all - handler treats workspace and project variants identically; new TestProjectIssueViewVariant covers persist / normalize / reject - project scope key gains a per-tab segment (members/agents) so each tab keeps its own display state; the unrestricted tab keeps the old key - save dialog shows the variant selector for project views; the surface now provides the dialog's default variant directly (header-side injection removed) - variant labels spell out the meaning (Assigned to members/agents, Any assignee, Any relation) and project hints are variant-aware, x4 locales Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-table): project table scope honors assignee_types The table/rows channel dropped the Members/Agents narrowing on project pages: the client's project scope never sent assignee_types and the server's project branch never compiled it, so switching tabs only changed the highlight while table rows stayed put (the board/list channel already filtered). Both sides now share the workspace scope's optional assignee-type narrowing, with a compile test covering the predicate and the invalid-type rejection. Co-Authored-By: Claude Fable 5 <[email protected]> * refactor(issues): single assignee-type mapping + per-page tab state One core helper pair (assigneeTypesForActorKind / actorKindForViewVariant) replaces the four inlined tab->assignee_types literal mappings across query-plan, the table spec compiler, and the view-variant application. The assignee-type tab store becomes page-keyed (issues / project:<id>, persisted with a v0 migration carrying the old global tab to the Issues page): switching tabs inside a project no longer drags the Issues page or other projects along. The save-view dialog's default variant now comes from the header's own page tab, restoring the /issues default that the surface-side injection had dropped. Co-Authored-By: Claude Fable 5 <[email protected]> * refactor(issues): converge all issue surfaces onto the table query channel The legacy list/grouped query layer duplicated the table channel's scope semantics behind enable-conditions that were provably always false: list and status-board ride server status branches, every non-status grouping and swimlane ride server group branches, and the assignee-board / per-status load-more / flat-list / flat-export / client-side my:any 3-request merge fallbacks could never execute. Delete the whole layer: - queries: myIssueListOptions, myIssueAssigneeGroupsOptions, issueAssigneeGroupsOptions, issueFlatListOptions/ExportOptions, fetchAllFlatPages / fetchAllMyFlatIssues / fetchAllMyFirstPages / fetchAllMyAssigneeGroups, compareIssuesForSort (server owns ordering) - mutations: useLoadMoreByStatus, useLoadMoreByAssigneeGroup - board/swimlane: the never-taken client-paginated columns, property pool loader, hidden-column client counts, legacy footer rows - surface data hook / controller: dead queries and passthrough fields - gantt keeps its scheduled-only endpoint and now compiles the assignee-type tab into it, restoring the change alongside the cleanup query-plan shrinks to the scope's non-Table residue: scopeKey, the gantt filter, and create defaults. issueKeys and cache invalidation prefixes are untouched; mobile's own data layer is unaffected. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issues): review fixes — active view's variant wins in save dialog, stable dialog scope, dead mocks Post-review pass on the convergence refactor: - The save dialog's default variant now prefers the OPEN view's scope_variant over the page tab: saving a copy while an agents-scoped view is open must describe the rows on screen, not the tab underneath. - The dialog scope prop is memoized on primitive projections — a fresh object per header render re-armed the dialog's draft-reset effect, so any background refetch wiped a half-typed name. - Tests: persist migration coverage for the page-keyed tab store; gantt assignee_types reaches the request and forks the cache key. - Dead legacy load-more mocks removed from three view test files; stale hidden-columns doc comment; unknown hidden-column totals render no count instead of a blank; my-relation plan branch is return-exhaustive; leftover blank lines. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issues): keep the grabbing cursor for the whole row drag in manage views The handle's cursor classes only apply while the pointer hovers the handle — the vertical-locked drag loses it immediately, and buttons the pointer crosses flip it to pointer. Mirror the sidebar-resize cursor contract: an html[data-dnd-dragging] rule forces cursor: grabbing (and no text selection) document-wide for the drag's duration, toggled by the DndContext lifecycle with an unmount safety net. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issues): my views apply their relation at the query layer, not by switching the tab Opening a my-view used to overwrite the user's relation tab with the view's variant (VARIANT_TO_TAB), so when an open view vanished (deleted / access revoked) the fallback landed on the view's tab instead of where the user actually was. Views are now applied the same way on all three surfaces: while a view is open, effectiveScope substitutes the view's variant — assignee-type for workspace/project, relation for my — and the user's own tab state is never touched. myRelationForViewVariant joins its sibling mapping in scope.ts with unit coverage. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issues): ease the save-view dialog's expand and validation transitions The display-defaults section snapped open/closed and the name-required hint popped in. The panel now runs the Base UI height transition (--collapsible-panel-height + data-starting/ending-style — the tw-animate collapsible keyframes only know Radix's variable, so the accordion's animate-* classes cannot be reused here), and the hint eases in with the house animate-in vocabulary. Scoped to this dialog: the shared Collapsible primitive stays untouched because chat/transcript panels grow while open and a pinned panel height would clip them. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issues): filter chips ease in Chips popped into the bar and the save-view dialog's embedded list with no transition. Each chip now enters with the house fade+zoom vocabulary, keyed per dimension so value edits inside an existing chip don't re-animate. No exit animation by design. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): drag-reorder the view bar, tab context menu, pin views to sidebar The bar's tabs (built-ins and saved views alike) now reorder by drag, writing the same per-user preference document the manage dialog edits — one list, two editors. Saved-view tabs gain a context menu: - Edit: visible to everyone, disabled without manage permission (the greyed row is the signal); Delete: rendered only for the owner or a workspace admin on shared views — mirroring the server's canManageIssueView — and routed through the shared confirm dialog now extracted from the manage dialog. The manage dialog's own affordances follow the same widened rule (admins included). - Pin to sidebar: pinned_item grows a 'view' type (migration 267; create validates read access so foreign private views 404). Sidebar pin rows resolve the view's name via the new GET-by-id options, render the view-bar icon, activate the view in the store and navigate to its owning surface on click. Flag-off clients keep view pins dormant instead of auto-unpinning; deleted views follow the existing 404-auto-unpin pattern. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): review fixes — guard preference wipes, never auto-unpin views Two data-loss paths from review, plus hardening: - A drag landing before the views list loaded pruned the preference document against an empty list, silently erasing every saved-view entry from order and hidden. savePrefs now refuses to write until the list has actually loaded (viewsReady threads down from useActiveIssueView). - View pins are exempt from 404-auto-unpin: the detail endpoint also 404s when the feature flag is switched off server-side, and a client that booted with the flag on cannot tell the difference — a flag flip would have deleted every view pin permanently. Deleted views' rows hide instead. - POST /api/pins with item_type=view now sits behind the same feature gate as every other view endpoint. - SortableBarTab: drop dnd-kit's aria attributes (they nested a second focusable role=button around the tab), move the wasDragged ref into an effect, and clear it after a cancelled drag so the next real click isn't swallowed. - Sidebar view pins carry ?view= in their href so a web reload stays on the view; scope resolution collapsed to one branch driving both the path and the container key. - Malformed GET /api/issue-views/{id} responses pinned by test as null (hide the row), never an error (which alone may unpin). Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): single-row view bar with overflow popover The bar no longer wraps: tabs that don't fit collapse into a trailing popover, Linear-style, with two differences — built-ins are first-class orderable entries, and the whole list (hidden rows dimmed) lives in the pulled-down panel with drag reorder, per-row actions (edit/delete under the same permission rule, pin, hide/show) and a new-view entry. - useSingleRowFit (views/common): hidden mirror row measures natural tab widths; ResizeObserver + change-guarded per-commit remeasure computes the fitting prefix with trigger reservation. First reusable collapse-into-menu primitive in the repo. - The open view is always visible: when it overflows it takes the trigger slot, showing its name + chevron (wider reservation; the two-pass promotion settle is monotonic, no oscillation). - Every tab now shares one max width (max-w-40) and truncates with the full name in tooltips/rows. - ManageViewsDialog retired: the popover carries all of its duties (reorder, hide/show incl. built-ins, edit/delete); hiding stays impossible for the anchor built-in and still exits a hidden open view. DeleteViewConfirm and ViewBarItem move to view-bar-popover.tsx; orphaned i18n keys dropped, row action keys added x4 locales. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): restore [layers] menu + manage dialog; 'more' appears only on overflow; fix zero-width bar The single-row rework overreached: it replaced the existing new/manage entries and dialog with an all-in-one popover, and the bar's container collapsed to zero width (content-sized parent + measurement feedback), rendering an empty header. Back to the intended shape: - The [layers] menu (new view / manage views) and the manage dialog are restored exactly as before; DeleteViewConfirm and ViewBarItem stay shared from view-bar-popover. - The 'more' trigger exists ONLY while tabs overflow, showing just the overflowed entries (click to open; per-row edit/delete/pin/hide with the same permission rules — those tabs have no rendered surface to right-click). The open view still takes the trigger slot when it overflows. - Header view-bar slots gain flex-1 so the fit measures a real width; reserve tiers (menu / +more / +promoted) settle monotonically. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): list panel shows ALL tabs with drag reorder; trigger always visible The pulled-down panel now lists every bar entry (built-ins included) in bar order with vertical drag reorder — since the bar renders the longest fitting prefix, reordering in the panel directly decides what sits on the bar versus tucks away, with a separator marking the current fold. The trigger no longer appears/disappears with overflow: it is a fixed part of the bar's right side (still replaced by the open view's own tab when that view overflows). Row actions (edit/delete/pin/hide) and the [layers] new/manage entries are unchanged. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): kill the post-drop bounce with a synchronous order mirror The preference mutation is optimistic but its onMutate is async: on drop, dnd-kit clears the drag transform in the same frame while the reordered list lands a beat later, so the dropped tab flashed back to its old slot and then jumped — the manage dialog never bounced because it mirrors the new order synchronously in the drag handler. Both bar drag and panel drag now route through applyMove: set a local order mirror in the same tick, persist, and clear the mirror once the preference data catches up. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): panel row menu is always visible, not hover-revealed Hover-reveal made the three-dot row menu unreachable on touch devices; it is a primary affordance in the list panel, so it renders always. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): long view names truncate/wrap everywhere The manage dialog's row strip is a grid item — min-width:auto let an 80-char name blow past the dialog frame, carrying the row controls with it. Pin the wrapper (min-w-0 + overflow-hidden) and the rows (min-w-0) so labels truncate as designed. The delete confirmation and the save dialog's scope hint interpolate names/titles into prose — break-words so an unbroken name wraps instead of overflowing. Audited every other name surface (bar tabs, promoted trigger, panel rows, sidebar pins, context menus): all already bounded. Co-Authored-By: Claude Fable 5 <[email protected]> * refactor(issue-views): remove the saved_issue_views_v1 feature flag The feature ships unconditionally. The flag never reached any released client, so — unlike the retired flags that live on as always-on compat keys — the key is removed outright on both sides: - server: the issueViewsEnabled 404 gate drops from every view endpoint, the preference endpoints and the view pin type; the key leaves the registry and the frontend-public list; gate tests and the per-test enable helper go with it - frontend: the === true checks, the pre-view-bar fallback tab rows in both headers, and useActiveIssueView's enabled parameter are deleted; the view bar is now the only rendering - compat holds by construction: a NEW client against an OLD backend gets 404s on the list/preference queries → empty views, viewsReady stays false (no preference writes), sidebar view pins hide without auto-unpinning (comment updated to name this as the reason); an OLD client against the NEW backend reads an absent flag as false and keeps the feature hidden Full-suite run also surfaced that the workspace deletion manifest never classified issue_view / issue_view_preference: both now fall with the other issue roots in DeleteWorkspaceIssueRoots (no-FK policy: explicit teardown), and the manifest records the decision. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): fit reserve applies even when every tab fits The all-fit fast path skipped the reserve — a leftover from when the overflow trigger only rendered on overflow. With the list trigger and the [layers] menu now permanent, a row whose tabs exactly filled the container declared everything fitting and overflow-hidden clipped the trailing chrome. The reserve is now subtracted unconditionally. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): a freshly created view opens itself Creating a view now activates it on its surface immediately — the view you just saved is the one you meant to be looking at. Skipped only when the create response fails to parse (the list refetch still shows it). Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): creating a view no longer bounces the surface to the default tab Activation raced the list invalidation: the fresh view's id was set active before the refetched list contained it, the stale list read as 'view deleted', and the missing-view fallback kicked the surface back to the anchor tab (with an 'unavailable' toast). Two-sided fix: the create mutation seeds the created view into its scope's list cache before invalidating, and the missing verdict now requires the list to not be mid-refetch. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): release-gate fixes — rollback safety, limits, old-client pin compat, revoke sweep Addresses the pre-ship review blockers: - CI styles: text-muted-foreground/70 -> text-faint-foreground (solid tone rule) in the header hint and the dialog my-note; the chip emoji stack's text-[9px] -> text-micro (type-scale rule). apps/web text-contrast + type-scale suites pass. - migration 269 down normalizes workspace/project members/agents variants to NULL before restoring the old constraints — verified on a scratch DB with real variant data (up -> write -> down). - view pins are withheld from the legacy /api/pins contract unless the client opts in (?include=view): old Desktop builds classified any non-issue pin as a project pin and permanently auto-unpinned it on 404. New clients opt in; regression test pins one and asserts the legacy list never leaks it. - write hardening: MaxBytesReader (128KB) on view create/update and preference PUT; isJSONObject rejects JSON null (was a DB-CHECK 500); per-owner view quota (100/workspace); list query LIMIT 200. Boundary tests for null blob, oversized body, and quota. - member revoke now sweeps the departed member's PRIVATE views and their view-bar preferences in the same transaction (shared views stay, same rule as private quick actions) — the migration comments promised this; test covers private-gone/shared-stays/prefs-gone. - sidebar comment no longer claims project ?view= reload support. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): deleting a view sweeps its sidebar pins on every path View pins never auto-unpin client-side (old-backend safety), so a pin whose view is gone was invisible in the UI and unremovable forever — and could leave an empty-looking Pinned group. All three deletion paths now sweep matching pinned_item rows atomically in the same statement via CTEs: direct view delete, project deletion (its scoped views), and member revoke (their private views). Regression tests cover each path. Co-Authored-By: Claude Fable 5 <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]>
1473 lines
52 KiB
Go
1473 lines
52 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type issueTableEnrichmentFailTxStarter struct {
|
|
inner txStarter
|
|
labelCalls *int
|
|
tableQueryCalls *int
|
|
facetQueryCalls *int
|
|
rowQuerySQL *string
|
|
groupQuerySQL *string
|
|
}
|
|
|
|
func (s issueTableEnrichmentFailTxStarter) Begin(ctx context.Context) (pgx.Tx, error) {
|
|
tx, err := s.inner.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &issueTableEnrichmentFailTx{
|
|
Tx: tx,
|
|
labelCalls: s.labelCalls,
|
|
tableQueryCalls: s.tableQueryCalls,
|
|
facetQueryCalls: s.facetQueryCalls,
|
|
rowQuerySQL: s.rowQuerySQL,
|
|
groupQuerySQL: s.groupQuerySQL,
|
|
}, nil
|
|
}
|
|
|
|
type issueTableEnrichmentFailTx struct {
|
|
pgx.Tx
|
|
labelCalls *int
|
|
tableQueryCalls *int
|
|
facetQueryCalls *int
|
|
rowQuerySQL *string
|
|
groupQuerySQL *string
|
|
}
|
|
|
|
func (tx *issueTableEnrichmentFailTx) recordTableQuery(sql string) {
|
|
if tx.tableQueryCalls != nil {
|
|
if strings.Contains(sql, "page AS MATERIALIZED (") ||
|
|
strings.Contains(sql, "SELECT COUNT(*)::bigint FROM issue i WHERE") {
|
|
*tx.tableQueryCalls = *tx.tableQueryCalls + 1
|
|
}
|
|
}
|
|
if tx.facetQueryCalls != nil && strings.Contains(sql, "GROUP BY GROUPING SETS") {
|
|
*tx.facetQueryCalls = *tx.facetQueryCalls + 1
|
|
}
|
|
if tx.rowQuerySQL != nil && strings.Contains(sql, "page AS MATERIALIZED (") {
|
|
*tx.rowQuerySQL = sql
|
|
}
|
|
if tx.groupQuerySQL != nil && strings.Contains(sql, "WITH grouped AS (") {
|
|
*tx.groupQuerySQL = sql
|
|
}
|
|
}
|
|
|
|
func (tx *issueTableEnrichmentFailTx) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
|
|
tx.recordTableQuery(sql)
|
|
if strings.Contains(sql, "ListLabelsForIssues") {
|
|
*tx.labelCalls = *tx.labelCalls + 1
|
|
// A real PostgreSQL statement error poisons the transaction until
|
|
// rollback. Before enrichment moved after Commit, this turned the
|
|
// otherwise successful row window into a 500.
|
|
_, err := tx.Tx.Exec(ctx, "SELECT * FROM issue_table_missing_enrichment_relation")
|
|
return nil, err
|
|
}
|
|
return tx.Tx.Query(ctx, sql, args...)
|
|
}
|
|
|
|
func (tx *issueTableEnrichmentFailTx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
|
tx.recordTableQuery(sql)
|
|
return tx.Tx.QueryRow(ctx, sql, args...)
|
|
}
|
|
|
|
func TestCanonicalIssueTableFingerprintNormalizesSetLikeArrays(t *testing.T) {
|
|
left := issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "workspace", AssigneeTypes: []string{"agent", "member", "agent"}},
|
|
Filters: issueTableFiltersRequest{
|
|
Statuses: []string{"todo", "backlog", "todo"},
|
|
ProjectIDs: []string{"b", "a"},
|
|
},
|
|
Sort: issueTableSortRequest{Field: "title", Direction: "asc"},
|
|
}
|
|
right := issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "workspace", AssigneeTypes: []string{"member", "agent"}},
|
|
Filters: issueTableFiltersRequest{
|
|
Statuses: []string{"backlog", "todo"},
|
|
ProjectIDs: []string{"a", "b"},
|
|
},
|
|
Sort: issueTableSortRequest{Field: "title", Direction: "asc"},
|
|
}
|
|
leftFingerprint, err := canonicalIssueTableFingerprint("workspace-1", left)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rightFingerprint, err := canonicalIssueTableFingerprint("workspace-1", right)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if leftFingerprint != rightFingerprint {
|
|
t.Fatalf("equivalent table queries produced different fingerprints: %s != %s", leftFingerprint, rightFingerprint)
|
|
}
|
|
}
|
|
|
|
func TestCanonicalIssueTableFingerprintBindsWorkspace(t *testing.T) {
|
|
spec := issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "workspace"},
|
|
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
|
|
}
|
|
left, err := canonicalIssueTableFingerprint("workspace-1", spec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
right, err := canonicalIssueTableFingerprint("workspace-2", spec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if left == right {
|
|
t.Fatal("equivalent queries in different workspaces produced the same fingerprint")
|
|
}
|
|
}
|
|
|
|
func TestIssueTableExplicitEmptyAssigneesMatchesNone(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
base := issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "workspace"},
|
|
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
|
|
}
|
|
withEmpty := base
|
|
withEmpty.Filters.Assignees = []issueTableActorRef{}
|
|
|
|
unfilteredFingerprint, err := canonicalIssueTableFingerprint(testWorkspaceID, base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
emptyFingerprint, err := canonicalIssueTableFingerprint(testWorkspaceID, withEmpty)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if unfilteredFingerprint == emptyFingerprint {
|
|
t.Fatal("explicit empty assignees must not share the unfiltered cursor fingerprint")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
compiled, ok := testHandler.compileIssueTableQuery(
|
|
w,
|
|
newRequest(http.MethodPost, "/api/issues/table/rows", nil),
|
|
withEmpty,
|
|
)
|
|
if !ok {
|
|
t.Fatalf("compile failed: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(compiled.where, "FALSE") {
|
|
t.Fatalf("explicit empty assignees predicate = %q, want FALSE", compiled.where)
|
|
}
|
|
}
|
|
|
|
func TestIssueTableProjectScopeAssigneeTypes(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
spec := issueTableQuerySpec{
|
|
Scope: issueTableScope{
|
|
Kind: "project",
|
|
ProjectID: "00000000-0000-0000-0000-000000000001",
|
|
AssigneeTypes: []string{"agent", "squad"},
|
|
},
|
|
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
compiled, ok := testHandler.compileIssueTableQuery(
|
|
w,
|
|
newRequest(http.MethodPost, "/api/issues/table/rows", nil),
|
|
spec,
|
|
)
|
|
if !ok {
|
|
t.Fatalf("compile failed: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(compiled.where, "i.project_id") {
|
|
t.Fatalf("project predicate missing: %q", compiled.where)
|
|
}
|
|
if !strings.Contains(compiled.where, "i.assignee_type = ANY") {
|
|
t.Fatalf("assignee-type narrowing missing on project scope: %q", compiled.where)
|
|
}
|
|
|
|
bad := spec
|
|
bad.Scope.AssigneeTypes = []string{"martian"}
|
|
w = httptest.NewRecorder()
|
|
if _, ok := testHandler.compileIssueTableQuery(
|
|
w,
|
|
newRequest(http.MethodPost, "/api/issues/table/rows", nil),
|
|
bad,
|
|
); ok {
|
|
t.Fatal("invalid assignee_types must be rejected on project scope")
|
|
}
|
|
}
|
|
|
|
func TestIssueTableWorkingIssueIDsAreExplicitAndAssigneeIndependent(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
base := issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "workspace"},
|
|
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
|
|
}
|
|
withEmpty := base
|
|
withEmpty.Filters.WorkingIssueIDs = []string{}
|
|
|
|
unfilteredFingerprint, err := canonicalIssueTableFingerprint(testWorkspaceID, base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
emptyFingerprint, err := canonicalIssueTableFingerprint(testWorkspaceID, withEmpty)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if unfilteredFingerprint == emptyFingerprint {
|
|
t.Fatal("explicit empty working_issue_ids must not share the unfiltered cursor fingerprint")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
compiled, ok := testHandler.compileIssueTableQuery(
|
|
w,
|
|
newRequest(http.MethodPost, "/api/issues/table/rows", nil),
|
|
withEmpty,
|
|
)
|
|
if !ok {
|
|
t.Fatalf("compile empty filter: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(compiled.where, "FALSE") {
|
|
t.Fatalf("explicit empty working_issue_ids predicate = %q, want FALSE", compiled.where)
|
|
}
|
|
|
|
withIssue := base
|
|
withIssue.Filters.WorkingIssueIDs = []string{
|
|
"00000000-0000-4000-8000-000000000001",
|
|
}
|
|
w = httptest.NewRecorder()
|
|
compiled, ok = testHandler.compileIssueTableQuery(
|
|
w,
|
|
newRequest(http.MethodPost, "/api/issues/table/rows", nil),
|
|
withIssue,
|
|
)
|
|
if !ok {
|
|
t.Fatalf("compile issue filter: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(compiled.where, "i.id = ANY(") {
|
|
t.Errorf("working-issue predicate = %q, want issue-id membership", compiled.where)
|
|
}
|
|
if strings.Contains(compiled.where, "i.assignee_id") {
|
|
t.Fatalf("working-issue predicate must not filter issue assignees: %q", compiled.where)
|
|
}
|
|
}
|
|
|
|
func TestIssueTableWorkingIssueProjectionMatchesTaskIssuesNotAssignees(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
workingAgentID := createHandlerTestAgent(t, "table-working-task-agent", []byte(`{}`))
|
|
otherAgentID := createHandlerTestAgent(t, "table-other-working-agent", []byte(`{}`))
|
|
|
|
var finalNumber int
|
|
if err := testPool.QueryRow(ctx, `
|
|
UPDATE workspace
|
|
SET issue_counter = GREATEST(
|
|
issue_counter,
|
|
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
|
|
) + 3
|
|
WHERE id = $1
|
|
RETURNING issue_counter
|
|
`, testWorkspaceID).Scan(&finalNumber); err != nil {
|
|
t.Fatalf("reserve issue numbers: %v", err)
|
|
}
|
|
|
|
insertIssue := func(title string, number int, assigneeType string, assigneeID any) string {
|
|
t.Helper()
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, status, priority, creator_type, creator_id,
|
|
assignee_type, assignee_id, position, number
|
|
)
|
|
VALUES ($1, $2, 'todo', 'none', 'member', $3, $4, $5, $6, $7)
|
|
RETURNING id
|
|
`,
|
|
testWorkspaceID,
|
|
title,
|
|
testUserID,
|
|
assigneeType,
|
|
assigneeID,
|
|
number,
|
|
number,
|
|
).Scan(&issueID); err != nil {
|
|
t.Fatalf("insert issue %q: %v", title, err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
return issueID
|
|
}
|
|
|
|
assignedOnlyIssueID := insertIssue(
|
|
"assigned to working agent but not being edited",
|
|
finalNumber-2,
|
|
"agent",
|
|
workingAgentID,
|
|
)
|
|
editedIssueID := insertIssue(
|
|
"being edited by working agent but assigned to member",
|
|
finalNumber-1,
|
|
"member",
|
|
testUserID,
|
|
)
|
|
otherAgentIssueID := insertIssue(
|
|
"being edited by another agent",
|
|
finalNumber,
|
|
"agent",
|
|
workingAgentID,
|
|
)
|
|
createHandlerTestTaskForAgentOnIssue(t, workingAgentID, editedIssueID)
|
|
createHandlerTestTaskForAgentOnIssue(t, otherAgentID, otherAgentIssueID)
|
|
|
|
workingRecorder := httptest.NewRecorder()
|
|
testHandler.ListWorkspaceWorkingAgents(
|
|
workingRecorder,
|
|
newRequest(http.MethodGet, "/api/working-agents?type=issue", nil),
|
|
)
|
|
if workingRecorder.Code != http.StatusOK {
|
|
t.Fatalf(
|
|
"list working agents status = %d: %s",
|
|
workingRecorder.Code,
|
|
workingRecorder.Body.String(),
|
|
)
|
|
}
|
|
var workingAgents []WorkspaceWorkingAgent
|
|
if err := json.NewDecoder(workingRecorder.Body).Decode(&workingAgents); err != nil {
|
|
t.Fatalf("decode working agents: %v", err)
|
|
}
|
|
workingIssueIDs := make([]string, 0, 2)
|
|
for _, agent := range workingAgents {
|
|
if agent.ID == workingAgentID || agent.ID == otherAgentID {
|
|
workingIssueIDs = append(workingIssueIDs, agent.IssueIDs...)
|
|
}
|
|
}
|
|
|
|
listRows := func(issueIDs []string) issueTableRowsResponse {
|
|
t.Helper()
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.ListIssueTableRows(
|
|
recorder,
|
|
newRequest(http.MethodPost, "/api/issues/table/rows", map[string]any{
|
|
"query": map[string]any{
|
|
"scope": map[string]any{"kind": "workspace"},
|
|
"filters": map[string]any{
|
|
// A map intentionally preserves [] in JSON. Marshaling
|
|
// issueTableFiltersRequest directly would apply its
|
|
// omitempty tag and turn the match-none case into no filter.
|
|
"working_issue_ids": issueIDs,
|
|
},
|
|
"sort": map[string]any{
|
|
"field": "position",
|
|
"direction": "asc",
|
|
},
|
|
},
|
|
"group": map[string]any{"kind": "none"},
|
|
"hierarchy": map[string]any{"enabled": false},
|
|
"page": map[string]any{"limit": 10},
|
|
}),
|
|
)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("list rows status = %d: %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response issueTableRowsResponse
|
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode rows: %v", err)
|
|
}
|
|
return response
|
|
}
|
|
|
|
response := listRows(workingIssueIDs)
|
|
if response.Total != 2 || len(response.Rows) != 2 {
|
|
t.Fatalf("working rows total=%d rows=%d, want two", response.Total, len(response.Rows))
|
|
}
|
|
gotIssueIDs := make(map[string]struct{}, len(response.Rows))
|
|
for _, row := range response.Rows {
|
|
gotIssueIDs[row.Issue.ID] = struct{}{}
|
|
}
|
|
if _, ok := gotIssueIDs[editedIssueID]; !ok {
|
|
t.Errorf("missing issue edited by selected working agent: %s", editedIssueID)
|
|
}
|
|
if _, ok := gotIssueIDs[otherAgentIssueID]; !ok {
|
|
t.Errorf("missing issue edited by other working agent: %s", otherAgentIssueID)
|
|
}
|
|
if _, ok := gotIssueIDs[assignedOnlyIssueID]; ok {
|
|
t.Errorf("included assigned-only issue without a running task: %s", assignedOnlyIssueID)
|
|
}
|
|
|
|
empty := listRows([]string{})
|
|
if empty.Total != 0 || len(empty.Rows) != 0 {
|
|
t.Fatalf("explicit empty working-issue filter returned total=%d rows=%d", empty.Total, len(empty.Rows))
|
|
}
|
|
}
|
|
|
|
func TestIssueTableCursorRejectsAnotherQuery(t *testing.T) {
|
|
groupKey := "status:todo"
|
|
cursor := issueTableCursor{
|
|
Version: 1,
|
|
QueryFingerprint: "sha256:old",
|
|
GroupKey: &groupKey,
|
|
}
|
|
w := httptest.NewRecorder()
|
|
if issueTableCursorMatches(w, &cursor, "sha256:new", &groupKey, nil) {
|
|
t.Fatal("cursor from another query unexpectedly matched")
|
|
}
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("status = %d, want %d", w.Code, http.StatusConflict)
|
|
}
|
|
}
|
|
|
|
func TestIssueTablePositionCursorIncludesIndexableLowerBound(t *testing.T) {
|
|
cursorValue := "90000"
|
|
cursor := issueTableCursor{
|
|
SortValue: &cursorValue,
|
|
RowCreatedAt: "2026-01-01T00:00:00Z",
|
|
RowID: "00000000-0000-4000-8000-000000000001",
|
|
}
|
|
args := make([]any, 0, 3)
|
|
predicate, ok := (resolvedIssueTableSort{
|
|
expression: "i.position",
|
|
direction: "asc",
|
|
castType: "double precision",
|
|
}).cursorPredicate(httptest.NewRecorder(), &cursor, func(value any) string {
|
|
args = append(args, value)
|
|
return fmt.Sprintf("$%d", len(args))
|
|
})
|
|
if !ok {
|
|
t.Fatal("valid position cursor was rejected")
|
|
}
|
|
if !strings.Contains(predicate, "i.position >= $3::double precision") {
|
|
t.Fatalf("position cursor is missing its indexable lower bound: %s", predicate)
|
|
}
|
|
}
|
|
|
|
func TestIssueTableGroupIdentityBindsIncludeEmpty(t *testing.T) {
|
|
withoutEmpty := issueTableGroupIdentity(issueTableGroupSpec{
|
|
Kind: "property",
|
|
PropertyID: "00000000-0000-4000-8000-000000000001",
|
|
})
|
|
withEmpty := issueTableGroupIdentity(issueTableGroupSpec{
|
|
Kind: "property",
|
|
PropertyID: "00000000-0000-4000-8000-000000000001",
|
|
IncludeEmpty: true,
|
|
})
|
|
if withoutEmpty == withEmpty {
|
|
t.Fatalf("include-empty property cursors share an identity: %q", withEmpty)
|
|
}
|
|
}
|
|
|
|
func TestIssueTableCompoundCellKeyResolvesPrimaryAndStatus(t *testing.T) {
|
|
primary := resolvedIssueTableGroup{kind: "parent"}
|
|
compound := resolvedIssueTableGroup{kind: "compound", primary: &primary}
|
|
key := compoundCellGroupKey(
|
|
"parent:00000000-0000-4000-8000-000000000001",
|
|
"todo",
|
|
)
|
|
args := make([]any, 0, 2)
|
|
predicate, ok := compound.predicate(
|
|
httptest.NewRecorder(),
|
|
key,
|
|
func(value any) string {
|
|
args = append(args, value)
|
|
return fmt.Sprintf("$%d", len(args))
|
|
},
|
|
)
|
|
if !ok {
|
|
t.Fatal("valid compound cell key was rejected")
|
|
}
|
|
if !strings.Contains(predicate, "i.parent_issue_id = $1::uuid") ||
|
|
!strings.Contains(predicate, "i.status = $2::text") ||
|
|
len(args) != 2 || args[1] != "todo" {
|
|
t.Fatalf("compound cell predicate lost a dimension: %s args=%#v", predicate, args)
|
|
}
|
|
}
|
|
|
|
func TestIssueTableRowsCommitsBeforeBestEffortEnrichment(t *testing.T) {
|
|
ctx := context.Background()
|
|
suffix := time.Now().UnixNano()
|
|
var projectID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title)
|
|
VALUES ($1, $2)
|
|
RETURNING id
|
|
`, testWorkspaceID, fmt.Sprintf("Table enrichment %d", suffix)).Scan(&projectID); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
|
|
})
|
|
|
|
var issueNumber int
|
|
if err := testPool.QueryRow(ctx, `
|
|
UPDATE workspace
|
|
SET issue_counter = GREATEST(
|
|
issue_counter,
|
|
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
|
|
) + 1
|
|
WHERE id = $1
|
|
RETURNING issue_counter
|
|
`, testWorkspaceID).Scan(&issueNumber); err != nil {
|
|
t.Fatalf("reserve issue number: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, status, priority, creator_type, creator_id,
|
|
position, number, project_id
|
|
)
|
|
VALUES ($1, 'table-enrichment', 'todo', 'none', 'member', $2, 1, $3, $4)
|
|
`, testWorkspaceID, testUserID, issueNumber, projectID); err != nil {
|
|
t.Fatalf("seed issue: %v", err)
|
|
}
|
|
|
|
labelCalls := 0
|
|
tableQueryCalls := 0
|
|
rowQuerySQL := ""
|
|
handler := *testHandler
|
|
handler.TxStarter = issueTableEnrichmentFailTxStarter{
|
|
inner: testHandler.TxStarter,
|
|
labelCalls: &labelCalls,
|
|
tableQueryCalls: &tableQueryCalls,
|
|
rowQuerySQL: &rowQuerySQL,
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
handler.ListIssueTableRows(recorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
|
|
Query: issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
|
|
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
|
|
},
|
|
Group: issueTableGroupSpec{Kind: "none"},
|
|
Hierarchy: issueTableHierarchyRequest{Enabled: false},
|
|
Page: issueTablePageRequest{Limit: 10},
|
|
}))
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("rows status = %d: %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if labelCalls != 0 {
|
|
t.Fatalf("best-effort labels ran inside snapshot transaction %d times", labelCalls)
|
|
}
|
|
var response issueTableRowsResponse
|
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode rows: %v", err)
|
|
}
|
|
if len(response.Rows) != 1 {
|
|
t.Fatalf("rows = %d, want 1", len(response.Rows))
|
|
}
|
|
if response.Total != 1 || response.BranchTotal != 1 {
|
|
t.Fatalf("unexpected root counts: total=%d branch_total=%d", response.Total, response.BranchTotal)
|
|
}
|
|
if tableQueryCalls != 2 {
|
|
t.Fatalf("ungrouped root head executed %d table queries, want 2", tableQueryCalls)
|
|
}
|
|
if !strings.Contains(rowQuerySQL, "WITH page AS MATERIALIZED") ||
|
|
strings.Contains(rowQuerySQL, "membership AS") ||
|
|
strings.Contains(rowQuerySQL, "FROM membership child") {
|
|
t.Fatalf("flat rows query must page directly without hierarchy work:\n%s", rowQuerySQL)
|
|
}
|
|
}
|
|
|
|
func TestIssueTableStatusGroupingOverOneThousandRows(t *testing.T) {
|
|
ctx := context.Background()
|
|
suffix := time.Now().UnixNano()
|
|
var projectID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title)
|
|
VALUES ($1, $2)
|
|
RETURNING id
|
|
`, testWorkspaceID, fmt.Sprintf("Server table grouping %d", suffix)).Scan(&projectID); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
|
|
})
|
|
|
|
var finalNumber int
|
|
if err := testPool.QueryRow(ctx, `
|
|
UPDATE workspace
|
|
SET issue_counter = GREATEST(
|
|
issue_counter,
|
|
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
|
|
) + 1001
|
|
WHERE id = $1
|
|
RETURNING issue_counter
|
|
`, testWorkspaceID).Scan(&finalNumber); err != nil {
|
|
t.Fatalf("reserve issue numbers: %v", err)
|
|
}
|
|
firstNumber := finalNumber - 1000
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, status, priority, creator_type, creator_id,
|
|
position, number, project_id
|
|
)
|
|
SELECT $1, 'server-table-' || n::text,
|
|
CASE WHEN n <= 501 THEN 'todo' ELSE 'done' END,
|
|
'none', 'member', $2, n::double precision,
|
|
$3 + n - 1, $4
|
|
FROM generate_series(1, 1001) AS n
|
|
`, testWorkspaceID, testUserID, firstNumber, projectID); err != nil {
|
|
t.Fatalf("seed issues: %v", err)
|
|
}
|
|
|
|
query := issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
|
|
Filters: issueTableFiltersRequest{},
|
|
Sort: issueTableSortRequest{Field: "title", Direction: "asc"},
|
|
}
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListIssueTableGroups(w, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
|
|
Query: query,
|
|
Group: issueTableGroupSpec{Kind: "status"},
|
|
Page: issueTablePageRequest{Limit: 100},
|
|
}))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("groups status = %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var groups issueTableGroupsResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&groups); err != nil {
|
|
t.Fatalf("decode groups: %v", err)
|
|
}
|
|
if groups.Total != 1001 {
|
|
t.Fatalf("total = %d, want 1001", groups.Total)
|
|
}
|
|
counts := map[string]int64{}
|
|
for _, group := range groups.Groups {
|
|
counts[group.Key] = group.Count
|
|
}
|
|
if counts["status:todo"] != 501 || counts["status:done"] != 500 {
|
|
t.Fatalf("unexpected group counts: %#v", counts)
|
|
}
|
|
firstGroupPageRecorder := httptest.NewRecorder()
|
|
testHandler.ListIssueTableGroups(firstGroupPageRecorder, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
|
|
Query: query,
|
|
Group: issueTableGroupSpec{Kind: "status"},
|
|
Page: issueTablePageRequest{Limit: 1},
|
|
}))
|
|
var firstGroupPage issueTableGroupsResponse
|
|
if firstGroupPageRecorder.Code != http.StatusOK {
|
|
t.Fatalf("first group page status = %d: %s", firstGroupPageRecorder.Code, firstGroupPageRecorder.Body.String())
|
|
}
|
|
if err := json.NewDecoder(firstGroupPageRecorder.Body).Decode(&firstGroupPage); err != nil {
|
|
t.Fatalf("decode first group page: %v", err)
|
|
}
|
|
if len(firstGroupPage.Groups) != 1 || firstGroupPage.NextCursor == nil {
|
|
t.Fatalf("unexpected first group page: %#v", firstGroupPage)
|
|
}
|
|
secondGroupPageRecorder := httptest.NewRecorder()
|
|
testHandler.ListIssueTableGroups(secondGroupPageRecorder, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
|
|
Query: query,
|
|
Group: issueTableGroupSpec{Kind: "status"},
|
|
Page: issueTablePageRequest{Limit: 1, Cursor: firstGroupPage.NextCursor},
|
|
}))
|
|
var secondGroupPage issueTableGroupsResponse
|
|
if secondGroupPageRecorder.Code != http.StatusOK {
|
|
t.Fatalf("second group page status = %d: %s", secondGroupPageRecorder.Code, secondGroupPageRecorder.Body.String())
|
|
}
|
|
if err := json.NewDecoder(secondGroupPageRecorder.Body).Decode(&secondGroupPage); err != nil {
|
|
t.Fatalf("decode second group page: %v", err)
|
|
}
|
|
if len(secondGroupPage.Groups) != 1 || secondGroupPage.Groups[0].Key == firstGroupPage.Groups[0].Key || secondGroupPage.Total != 1001 {
|
|
t.Fatalf("group keyset pagination mismatch: first=%#v second=%#v", firstGroupPage, secondGroupPage)
|
|
}
|
|
|
|
groupKey := "status:todo"
|
|
labelCalls := 0
|
|
tableQueryCalls := 0
|
|
rowsHandler := *testHandler
|
|
rowsHandler.TxStarter = issueTableEnrichmentFailTxStarter{
|
|
inner: testHandler.TxStarter,
|
|
labelCalls: &labelCalls,
|
|
tableQueryCalls: &tableQueryCalls,
|
|
}
|
|
rowsRecorder := httptest.NewRecorder()
|
|
rowsHandler.ListIssueTableRows(rowsRecorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
|
|
Query: query,
|
|
Group: issueTableGroupSpec{Kind: "status"},
|
|
GroupKey: &groupKey,
|
|
Hierarchy: issueTableHierarchyRequest{Enabled: false},
|
|
Page: issueTablePageRequest{Limit: 50},
|
|
}))
|
|
if rowsRecorder.Code != http.StatusOK {
|
|
t.Fatalf("rows status = %d: %s", rowsRecorder.Code, rowsRecorder.Body.String())
|
|
}
|
|
var rows issueTableRowsResponse
|
|
if err := json.NewDecoder(rowsRecorder.Body).Decode(&rows); err != nil {
|
|
t.Fatalf("decode rows: %v", err)
|
|
}
|
|
if rows.Total != 0 || rows.BranchTotal != 50 || len(rows.Rows) != 50 || rows.NextCursor == nil {
|
|
t.Fatalf("unexpected rows page: total=%d branch_total=%d rows=%d cursor=%v", rows.Total, rows.BranchTotal, len(rows.Rows), rows.NextCursor)
|
|
}
|
|
if tableQueryCalls != 1 {
|
|
t.Fatalf("grouped root head executed %d table queries, want 1", tableQueryCalls)
|
|
}
|
|
firstPageIDs := make(map[string]struct{}, len(rows.Rows))
|
|
for _, row := range rows.Rows {
|
|
firstPageIDs[row.Issue.ID] = struct{}{}
|
|
}
|
|
secondRowsRecorder := httptest.NewRecorder()
|
|
rowsHandler.ListIssueTableRows(secondRowsRecorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
|
|
Query: query,
|
|
Group: issueTableGroupSpec{Kind: "status"},
|
|
GroupKey: &groupKey,
|
|
Hierarchy: issueTableHierarchyRequest{Enabled: false},
|
|
Page: issueTablePageRequest{Limit: 50, Cursor: rows.NextCursor},
|
|
}))
|
|
if secondRowsRecorder.Code != http.StatusOK {
|
|
t.Fatalf("second rows status = %d: %s", secondRowsRecorder.Code, secondRowsRecorder.Body.String())
|
|
}
|
|
var secondRows issueTableRowsResponse
|
|
if err := json.NewDecoder(secondRowsRecorder.Body).Decode(&secondRows); err != nil {
|
|
t.Fatalf("decode second rows: %v", err)
|
|
}
|
|
if secondRows.Total != 0 || secondRows.BranchTotal != 50 || len(secondRows.Rows) != 50 {
|
|
t.Fatalf("unexpected grouped continuation: total=%d branch_total=%d rows=%d", secondRows.Total, secondRows.BranchTotal, len(secondRows.Rows))
|
|
}
|
|
if tableQueryCalls != 2 {
|
|
t.Fatalf("grouped continuation executed %d cumulative table queries, want 2", tableQueryCalls)
|
|
}
|
|
for _, row := range secondRows.Rows {
|
|
if _, duplicate := firstPageIDs[row.Issue.ID]; duplicate {
|
|
t.Fatalf("keyset cursor repeated issue %s across pages", row.Issue.ID)
|
|
}
|
|
}
|
|
|
|
ungroupedRecorder := httptest.NewRecorder()
|
|
rowsHandler.ListIssueTableRows(ungroupedRecorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
|
|
Query: query,
|
|
Group: issueTableGroupSpec{Kind: "none"},
|
|
Hierarchy: issueTableHierarchyRequest{Enabled: false},
|
|
Page: issueTablePageRequest{Limit: 50},
|
|
}))
|
|
if ungroupedRecorder.Code != http.StatusOK {
|
|
t.Fatalf("ungrouped rows status = %d: %s", ungroupedRecorder.Code, ungroupedRecorder.Body.String())
|
|
}
|
|
var ungroupedRows issueTableRowsResponse
|
|
if err := json.NewDecoder(ungroupedRecorder.Body).Decode(&ungroupedRows); err != nil {
|
|
t.Fatalf("decode ungrouped rows: %v", err)
|
|
}
|
|
if ungroupedRows.Total != 1001 || ungroupedRows.BranchTotal != 50 || len(ungroupedRows.Rows) != 50 || ungroupedRows.NextCursor == nil {
|
|
t.Fatalf("unexpected ungrouped root head: total=%d branch_total=%d rows=%d cursor=%v", ungroupedRows.Total, ungroupedRows.BranchTotal, len(ungroupedRows.Rows), ungroupedRows.NextCursor)
|
|
}
|
|
if tableQueryCalls != 4 {
|
|
t.Fatalf("ungrouped root head executed %d cumulative table queries, want 4", tableQueryCalls)
|
|
}
|
|
|
|
ungroupedNextRecorder := httptest.NewRecorder()
|
|
rowsHandler.ListIssueTableRows(ungroupedNextRecorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
|
|
Query: query,
|
|
Group: issueTableGroupSpec{Kind: "none"},
|
|
Hierarchy: issueTableHierarchyRequest{Enabled: false},
|
|
Page: issueTablePageRequest{Limit: 50, Cursor: ungroupedRows.NextCursor},
|
|
}))
|
|
if ungroupedNextRecorder.Code != http.StatusOK {
|
|
t.Fatalf("ungrouped continuation status = %d: %s", ungroupedNextRecorder.Code, ungroupedNextRecorder.Body.String())
|
|
}
|
|
var ungroupedNext issueTableRowsResponse
|
|
if err := json.NewDecoder(ungroupedNextRecorder.Body).Decode(&ungroupedNext); err != nil {
|
|
t.Fatalf("decode ungrouped continuation: %v", err)
|
|
}
|
|
if ungroupedNext.Total != 0 || ungroupedNext.BranchTotal != 50 || len(ungroupedNext.Rows) != 50 {
|
|
t.Fatalf("unexpected ungrouped continuation: total=%d branch_total=%d rows=%d", ungroupedNext.Total, ungroupedNext.BranchTotal, len(ungroupedNext.Rows))
|
|
}
|
|
if tableQueryCalls != 5 {
|
|
t.Fatalf("ungrouped continuation executed %d cumulative table queries, want 5", tableQueryCalls)
|
|
}
|
|
|
|
for _, sortCase := range []issueTableSortRequest{
|
|
{Field: "status", Direction: "desc"},
|
|
{Field: "created_at", Direction: "desc"},
|
|
{Field: "due_date", Direction: "asc"},
|
|
} {
|
|
sortQuery := query
|
|
sortQuery.Sort = sortCase
|
|
fetchPage := func(cursor *string) issueTableRowsResponse {
|
|
t.Helper()
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.ListIssueTableRows(recorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
|
|
Query: sortQuery,
|
|
Group: issueTableGroupSpec{Kind: "status"},
|
|
GroupKey: &groupKey,
|
|
Hierarchy: issueTableHierarchyRequest{Enabled: false},
|
|
Page: issueTablePageRequest{Limit: 10, Cursor: cursor},
|
|
}))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("%s cursor page status = %d: %s", sortCase.Field, recorder.Code, recorder.Body.String())
|
|
}
|
|
var response issueTableRowsResponse
|
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode %s cursor page: %v", sortCase.Field, err)
|
|
}
|
|
return response
|
|
}
|
|
first := fetchPage(nil)
|
|
if len(first.Rows) != 10 || first.NextCursor == nil {
|
|
t.Fatalf("%s first cursor page is incomplete", sortCase.Field)
|
|
}
|
|
seen := make(map[string]struct{}, len(first.Rows))
|
|
for _, row := range first.Rows {
|
|
seen[row.Issue.ID] = struct{}{}
|
|
}
|
|
second := fetchPage(first.NextCursor)
|
|
if len(second.Rows) != 10 {
|
|
t.Fatalf("%s second cursor page length = %d", sortCase.Field, len(second.Rows))
|
|
}
|
|
for _, row := range second.Rows {
|
|
if _, duplicate := seen[row.Issue.ID]; duplicate {
|
|
t.Fatalf("%s keyset cursor repeated issue %s", sortCase.Field, row.Issue.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
filteredQuery := query
|
|
filteredQuery.Filters.Statuses = []string{"todo"}
|
|
facetsRecorder := httptest.NewRecorder()
|
|
testHandler.ListIssueTableFacets(facetsRecorder, newRequest("POST", "/api/issues/table/facets", issueTableFacetsRequest{
|
|
Query: filteredQuery,
|
|
Facets: []issueTableFacetSpec{{Kind: "status"}},
|
|
}))
|
|
if facetsRecorder.Code != http.StatusOK {
|
|
t.Fatalf("facets status = %d: %s", facetsRecorder.Code, facetsRecorder.Body.String())
|
|
}
|
|
var facets issueTableFacetsResponse
|
|
if err := json.NewDecoder(facetsRecorder.Body).Decode(&facets); err != nil {
|
|
t.Fatalf("decode facets: %v", err)
|
|
}
|
|
if facets.Total != 501 || len(facets.Facets) != 1 {
|
|
t.Fatalf("unexpected filtered facet response: total=%d facets=%d", facets.Total, len(facets.Facets))
|
|
}
|
|
facetCounts := map[string]int64{}
|
|
for _, value := range facets.Facets[0].Values {
|
|
facetCounts[value.Key] = value.Count
|
|
}
|
|
if facetCounts["todo"] != 501 || facetCounts["done"] != 500 {
|
|
t.Fatalf("status facet must ignore its own active filter: %#v", facetCounts)
|
|
}
|
|
|
|
includeTotal := false
|
|
facetCountQueries := 0
|
|
facetsHandler := *testHandler
|
|
facetsHandler.TxStarter = issueTableEnrichmentFailTxStarter{
|
|
inner: testHandler.TxStarter,
|
|
tableQueryCalls: &facetCountQueries,
|
|
}
|
|
noTotalRecorder := httptest.NewRecorder()
|
|
facetsHandler.ListIssueTableFacets(noTotalRecorder, newRequest("POST", "/api/issues/table/facets", issueTableFacetsRequest{
|
|
Query: filteredQuery,
|
|
Facets: []issueTableFacetSpec{{Kind: "status"}},
|
|
IncludeTotal: &includeTotal,
|
|
}))
|
|
if noTotalRecorder.Code != http.StatusOK {
|
|
t.Fatalf("facets without total status = %d: %s", noTotalRecorder.Code, noTotalRecorder.Body.String())
|
|
}
|
|
var noTotal issueTableFacetsResponse
|
|
if err := json.NewDecoder(noTotalRecorder.Body).Decode(&noTotal); err != nil {
|
|
t.Fatalf("decode facets without total: %v", err)
|
|
}
|
|
if noTotal.Total != 0 || len(noTotal.Facets) != 1 {
|
|
t.Fatalf("unexpected facets without total: %#v", noTotal)
|
|
}
|
|
if facetCountQueries != 0 {
|
|
t.Fatalf("include_total=false executed %d total count queries, want 0", facetCountQueries)
|
|
}
|
|
|
|
batchFacetQueries := 0
|
|
batchTotalQueries := 0
|
|
batchHandler := *testHandler
|
|
batchHandler.TxStarter = issueTableEnrichmentFailTxStarter{
|
|
inner: testHandler.TxStarter,
|
|
facetQueryCalls: &batchFacetQueries,
|
|
tableQueryCalls: &batchTotalQueries,
|
|
}
|
|
batchRecorder := httptest.NewRecorder()
|
|
batchHandler.ListIssueTableFacets(batchRecorder, newRequest("POST", "/api/issues/table/facets", issueTableFacetsRequest{
|
|
Query: query,
|
|
Facets: []issueTableFacetSpec{
|
|
{Kind: "status"},
|
|
{Kind: "priority"},
|
|
{Kind: "assignee"},
|
|
{Kind: "creator"},
|
|
{Kind: "project"},
|
|
},
|
|
}))
|
|
if batchRecorder.Code != http.StatusOK {
|
|
t.Fatalf("batch facets status = %d: %s", batchRecorder.Code, batchRecorder.Body.String())
|
|
}
|
|
var batchResponse issueTableFacetsResponse
|
|
if err := json.NewDecoder(batchRecorder.Body).Decode(&batchResponse); err != nil {
|
|
t.Fatalf("decode batch facets: %v", err)
|
|
}
|
|
if batchResponse.Total != 1001 || len(batchResponse.Facets) != 5 {
|
|
t.Fatalf("unexpected batch facets response: total=%d facets=%d", batchResponse.Total, len(batchResponse.Facets))
|
|
}
|
|
if batchFacetQueries != 1 || batchTotalQueries != 0 {
|
|
t.Fatalf("batch facets executed grouping queries=%d total queries=%d, want 1 and 0", batchFacetQueries, batchTotalQueries)
|
|
}
|
|
batchCounts := make(map[string]map[string]int64, len(batchResponse.Facets))
|
|
for _, facet := range batchResponse.Facets {
|
|
counts := make(map[string]int64, len(facet.Values))
|
|
for _, value := range facet.Values {
|
|
counts[value.Key] = value.Count
|
|
}
|
|
batchCounts[facet.Kind] = counts
|
|
}
|
|
if batchCounts["status"]["todo"] != 501 || batchCounts["status"]["done"] != 500 ||
|
|
batchCounts["priority"]["none"] != 1001 ||
|
|
batchCounts["assignee"]["__none__"] != 1001 ||
|
|
batchCounts["creator"]["member:"+testUserID] != 1001 ||
|
|
batchCounts["project"][projectID] != 1001 {
|
|
t.Fatalf("unexpected batched facet counts: %#v", batchCounts)
|
|
}
|
|
|
|
mixedRecorder := httptest.NewRecorder()
|
|
testHandler.ListIssueTableFacets(mixedRecorder, newRequest("POST", "/api/issues/table/facets", issueTableFacetsRequest{
|
|
Query: filteredQuery,
|
|
Facets: []issueTableFacetSpec{{Kind: "status"}, {Kind: "priority"}},
|
|
}))
|
|
if mixedRecorder.Code != http.StatusOK {
|
|
t.Fatalf("mixed facets status = %d: %s", mixedRecorder.Code, mixedRecorder.Body.String())
|
|
}
|
|
var mixedResponse issueTableFacetsResponse
|
|
if err := json.NewDecoder(mixedRecorder.Body).Decode(&mixedResponse); err != nil {
|
|
t.Fatalf("decode mixed facets: %v", err)
|
|
}
|
|
if mixedResponse.Total != 501 || len(mixedResponse.Facets) != 2 ||
|
|
mixedResponse.Facets[0].Kind != "status" || mixedResponse.Facets[1].Kind != "priority" {
|
|
t.Fatalf("unexpected mixed facets response: %#v", mixedResponse)
|
|
}
|
|
mixedCounts := make(map[string]map[string]int64, len(mixedResponse.Facets))
|
|
for _, facet := range mixedResponse.Facets {
|
|
counts := make(map[string]int64, len(facet.Values))
|
|
for _, value := range facet.Values {
|
|
counts[value.Key] = value.Count
|
|
}
|
|
mixedCounts[facet.Kind] = counts
|
|
}
|
|
if mixedCounts["status"]["todo"] != 501 || mixedCounts["status"]["done"] != 500 ||
|
|
mixedCounts["priority"]["none"] != 501 {
|
|
t.Fatalf("mixed facets lost disjunctive semantics: %#v", mixedCounts)
|
|
}
|
|
}
|
|
|
|
func TestIssueTableAssigneeNamesResolveAfterGrouping(t *testing.T) {
|
|
ctx := context.Background()
|
|
var projectID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title)
|
|
VALUES ($1, 'Server table assignee grouping')
|
|
RETURNING id
|
|
`, testWorkspaceID).Scan(&projectID); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
|
|
})
|
|
|
|
var finalNumber int
|
|
if err := testPool.QueryRow(ctx, `
|
|
UPDATE workspace
|
|
SET issue_counter = GREATEST(
|
|
issue_counter,
|
|
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
|
|
) + 2
|
|
WHERE id = $1
|
|
RETURNING issue_counter
|
|
`, testWorkspaceID).Scan(&finalNumber); err != nil {
|
|
t.Fatalf("reserve issue numbers: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, status, priority, assignee_type, assignee_id,
|
|
creator_type, creator_id, position, number, project_id
|
|
)
|
|
VALUES
|
|
($1, 'Assigned row', 'todo', 'none', 'member', $2, 'member', $2, 1, $3, $4),
|
|
($1, 'Unassigned row', 'todo', 'none', NULL, NULL, 'member', $2, 2, $3 + 1, $4)
|
|
`, testWorkspaceID, testUserID, finalNumber-1, projectID); err != nil {
|
|
t.Fatalf("seed issues: %v", err)
|
|
}
|
|
|
|
groupQuerySQL := ""
|
|
handler := *testHandler
|
|
handler.TxStarter = issueTableEnrichmentFailTxStarter{
|
|
inner: testHandler.TxStarter,
|
|
groupQuerySQL: &groupQuerySQL,
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
handler.ListIssueTableGroups(recorder, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
|
|
Query: issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
|
|
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
|
|
},
|
|
Group: issueTableGroupSpec{Kind: "assignee"},
|
|
Page: issueTablePageRequest{Limit: 10},
|
|
}))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("groups status = %d: %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response issueTableGroupsResponse
|
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode groups: %v", err)
|
|
}
|
|
counts := make(map[string]int64, len(response.Groups))
|
|
for _, group := range response.Groups {
|
|
counts[group.Key] = group.Count
|
|
}
|
|
if counts["assignee:member:"+testUserID] != 1 || counts["assignee:unassigned"] != 1 {
|
|
t.Fatalf("unexpected assignee groups: %#v", counts)
|
|
}
|
|
if response.Groups[0].Key != "assignee:member:"+testUserID ||
|
|
response.Groups[len(response.Groups)-1].Key != "assignee:unassigned" {
|
|
t.Fatalf("assignee groups are not actor-priority ordered: %#v", response.Groups)
|
|
}
|
|
sortedAt := strings.Index(groupQuerySQL, "), sorted AS (")
|
|
nameLookupAt := strings.Index(groupQuerySQL, `SELECT u.name FROM "user" u`)
|
|
if sortedAt < 0 || nameLookupAt < sortedAt {
|
|
t.Fatalf("assignee names must resolve after actor aggregation:\n%s", groupQuerySQL)
|
|
}
|
|
}
|
|
|
|
func TestIssueTableCompoundParentGroupsReturnExactStatusCells(t *testing.T) {
|
|
ctx := context.Background()
|
|
suffix := time.Now().UnixNano()
|
|
var projectID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title)
|
|
VALUES ($1, $2)
|
|
RETURNING id
|
|
`, testWorkspaceID, fmt.Sprintf("Compound parent grouping %d", suffix)).Scan(&projectID); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
|
|
})
|
|
|
|
var finalNumber int
|
|
if err := testPool.QueryRow(ctx, `
|
|
UPDATE workspace
|
|
SET issue_counter = GREATEST(
|
|
issue_counter,
|
|
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
|
|
) + 4
|
|
WHERE id = $1
|
|
RETURNING issue_counter
|
|
`, testWorkspaceID).Scan(&finalNumber); err != nil {
|
|
t.Fatalf("reserve issue numbers: %v", err)
|
|
}
|
|
var parentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, status, priority, creator_type, creator_id,
|
|
position, number, project_id
|
|
)
|
|
VALUES ($1, 'Parent context', 'done', 'none', 'member', $2, 1, $3, $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID, finalNumber-3, projectID).Scan(&parentID); err != nil {
|
|
t.Fatalf("create parent: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, status, priority, creator_type, creator_id,
|
|
parent_issue_id, position, number, project_id
|
|
)
|
|
VALUES
|
|
($1, 'Child todo', 'todo', 'none', 'member', $2, $3, 2, $4, $5),
|
|
($1, 'Child review', 'in_review', 'none', 'member', $2, $3, 3, $4 + 1, $5),
|
|
($1, 'No parent', 'todo', 'none', 'member', $2, NULL, 4, $4 + 2, $5)
|
|
`, testWorkspaceID, testUserID, parentID, finalNumber-2, projectID); err != nil {
|
|
t.Fatalf("seed grouped issues: %v", err)
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.ListIssueTableGroups(recorder, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
|
|
Query: issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
|
|
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
|
|
},
|
|
Group: issueTableGroupSpec{Kind: "compound", Primary: "parent", Secondary: "status"},
|
|
Page: issueTablePageRequest{Limit: 10},
|
|
}))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("compound groups status = %d: %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response issueTableGroupsResponse
|
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode compound groups: %v", err)
|
|
}
|
|
if response.Total != 4 || len(response.Groups) != 2 {
|
|
t.Fatalf("unexpected compound group totals: %#v", response)
|
|
}
|
|
byKey := make(map[string]issueTableGroupDescriptorResponse, len(response.Groups))
|
|
for _, group := range response.Groups {
|
|
byKey[group.Key] = group
|
|
}
|
|
parent := byKey["parent:"+parentID]
|
|
if parent.Count != 2 || parent.Value.Parent == nil ||
|
|
parent.Value.Parent.Title != "Parent context" ||
|
|
parent.Value.ValueState != "value" {
|
|
t.Fatalf("parent descriptor lost context: %#v", parent)
|
|
}
|
|
parentCells := make(map[string]int64, len(parent.SecondaryGroups))
|
|
for _, cell := range parent.SecondaryGroups {
|
|
parentCells[cell.Value.Status] = cell.Count
|
|
if !strings.HasPrefix(cell.Key, "compound:") {
|
|
t.Fatalf("cell key is not opaque compound key: %q", cell.Key)
|
|
}
|
|
}
|
|
if parentCells["todo"] != 1 || parentCells["in_review"] != 1 {
|
|
t.Fatalf("unexpected parent status cells: %#v", parentCells)
|
|
}
|
|
noParent := byKey["parent:none"]
|
|
if noParent.Count != 2 || noParent.Value.ValueState != "unset" {
|
|
t.Fatalf("unexpected no-parent lane: %#v", noParent)
|
|
}
|
|
|
|
// A second parent has only a hidden-status child. Under a visible-status
|
|
// compound query it must stay a No-parent card instead of creating an
|
|
// empty lane. The first parent does have a visible child, so it is promoted
|
|
// to a lane header and must disappear from the No-parent rows and counts.
|
|
var hiddenParentNumber int
|
|
if err := testPool.QueryRow(ctx, `
|
|
UPDATE workspace
|
|
SET issue_counter = issue_counter + 2
|
|
WHERE id = $1
|
|
RETURNING issue_counter
|
|
`, testWorkspaceID).Scan(&hiddenParentNumber); err != nil {
|
|
t.Fatalf("reserve hidden-parent issue numbers: %v", err)
|
|
}
|
|
var hiddenParentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, status, priority, creator_type, creator_id,
|
|
position, number, project_id
|
|
)
|
|
VALUES ($1, 'Visible parent card', 'todo', 'none', 'member', $2, 5, $3, $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID, hiddenParentNumber-1, projectID).Scan(&hiddenParentID); err != nil {
|
|
t.Fatalf("create hidden-only parent: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, status, priority, creator_type, creator_id,
|
|
parent_issue_id, position, number, project_id
|
|
)
|
|
VALUES ($1, 'Hidden child', 'done', 'none', 'member', $2, $3, 6, $4, $5)
|
|
`, testWorkspaceID, testUserID, hiddenParentID, hiddenParentNumber, projectID); err != nil {
|
|
t.Fatalf("create hidden child: %v", err)
|
|
}
|
|
|
|
filteredGroup := issueTableGroupSpec{
|
|
Kind: "compound",
|
|
Primary: "parent",
|
|
Secondary: "status",
|
|
SecondaryValues: []string{"todo"},
|
|
}
|
|
filteredRecorder := httptest.NewRecorder()
|
|
testHandler.ListIssueTableGroups(filteredRecorder, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
|
|
Query: issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
|
|
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
|
|
},
|
|
Group: filteredGroup,
|
|
Page: issueTablePageRequest{Limit: 10},
|
|
}))
|
|
if filteredRecorder.Code != http.StatusOK {
|
|
t.Fatalf("filtered compound groups status = %d: %s", filteredRecorder.Code, filteredRecorder.Body.String())
|
|
}
|
|
var filtered issueTableGroupsResponse
|
|
if err := json.NewDecoder(filteredRecorder.Body).Decode(&filtered); err != nil {
|
|
t.Fatalf("decode filtered compound groups: %v", err)
|
|
}
|
|
if filtered.Total != 3 || len(filtered.Groups) != 2 {
|
|
t.Fatalf("unexpected visible compound groups: %#v", filtered)
|
|
}
|
|
filteredByKey := make(map[string]issueTableGroupDescriptorResponse, len(filtered.Groups))
|
|
for _, descriptor := range filtered.Groups {
|
|
filteredByKey[descriptor.Key] = descriptor
|
|
}
|
|
if _, exists := filteredByKey["parent:"+hiddenParentID]; exists {
|
|
t.Fatalf("hidden-only parent lane was returned: %#v", filtered.Groups)
|
|
}
|
|
filteredParent := filteredByKey["parent:"+parentID]
|
|
filteredNoParent := filteredByKey["parent:none"]
|
|
if filteredParent.Count != 2 || filteredNoParent.Count != 2 {
|
|
t.Fatalf("parent header was not removed from aligned counts: parent=%#v no-parent=%#v", filteredParent, filteredNoParent)
|
|
}
|
|
|
|
cellKey := func(descriptor issueTableGroupDescriptorResponse, status string) string {
|
|
t.Helper()
|
|
for _, cell := range descriptor.SecondaryGroups {
|
|
if cell.Value.Status == status {
|
|
return cell.Key
|
|
}
|
|
}
|
|
t.Fatalf("missing %s cell in %#v", status, descriptor)
|
|
return ""
|
|
}
|
|
listCell := func(key string) issueTableRowsResponse {
|
|
t.Helper()
|
|
rowsRecorder := httptest.NewRecorder()
|
|
testHandler.ListIssueTableRows(rowsRecorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
|
|
Query: issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
|
|
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
|
|
},
|
|
Group: filteredGroup,
|
|
GroupKey: &key,
|
|
Hierarchy: issueTableHierarchyRequest{Enabled: false},
|
|
Page: issueTablePageRequest{Limit: 10},
|
|
}))
|
|
if rowsRecorder.Code != http.StatusOK {
|
|
t.Fatalf("filtered compound rows status = %d: %s", rowsRecorder.Code, rowsRecorder.Body.String())
|
|
}
|
|
var result issueTableRowsResponse
|
|
if err := json.NewDecoder(rowsRecorder.Body).Decode(&result); err != nil {
|
|
t.Fatalf("decode filtered compound rows: %v", err)
|
|
}
|
|
return result
|
|
}
|
|
noParentTodo := listCell(cellKey(filteredNoParent, "todo"))
|
|
if len(noParentTodo.Rows) != 2 {
|
|
t.Fatalf("unexpected visible no-parent rows: %#v", noParentTodo.Rows)
|
|
}
|
|
noParentIDs := map[string]bool{}
|
|
for _, row := range noParentTodo.Rows {
|
|
noParentIDs[row.Issue.ID] = true
|
|
}
|
|
if noParentIDs[parentID] || !noParentIDs[hiddenParentID] {
|
|
t.Fatalf("parent header/card semantics diverged: %#v", noParentIDs)
|
|
}
|
|
noParentDone := listCell(cellKey(filteredNoParent, "done"))
|
|
if len(noParentDone.Rows) != 0 {
|
|
t.Fatalf("promoted parent remained in No-parent rows: %#v", noParentDone.Rows)
|
|
}
|
|
}
|
|
|
|
func TestIssueTableHierarchyDoesNotCrossGroups(t *testing.T) {
|
|
ctx := context.Background()
|
|
var projectID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title)
|
|
VALUES ($1, 'Server table cross-group hierarchy')
|
|
RETURNING id
|
|
`, testWorkspaceID).Scan(&projectID); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
|
|
})
|
|
|
|
var finalNumber int
|
|
if err := testPool.QueryRow(ctx, `
|
|
UPDATE workspace
|
|
SET issue_counter = GREATEST(
|
|
issue_counter,
|
|
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
|
|
) + 2
|
|
WHERE id = $1
|
|
RETURNING issue_counter
|
|
`, testWorkspaceID).Scan(&finalNumber); err != nil {
|
|
t.Fatalf("reserve issue numbers: %v", err)
|
|
}
|
|
var parentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, status, priority, creator_type, creator_id,
|
|
position, number, project_id
|
|
)
|
|
VALUES ($1, 'Todo parent', 'todo', 'none', 'member', $2, 1, $3, $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID, finalNumber-1, projectID).Scan(&parentID); err != nil {
|
|
t.Fatalf("create parent: %v", err)
|
|
}
|
|
var childID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, status, priority, creator_type, creator_id,
|
|
parent_issue_id, position, number, project_id
|
|
)
|
|
VALUES ($1, 'Done child', 'done', 'none', 'member', $2, $3, 2, $4, $5)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID, parentID, finalNumber, projectID).Scan(&childID); err != nil {
|
|
t.Fatalf("create child: %v", err)
|
|
}
|
|
|
|
query := issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
|
|
Filters: issueTableFiltersRequest{},
|
|
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
|
|
}
|
|
rowQuerySQL := ""
|
|
rowsHandler := *testHandler
|
|
rowsHandler.TxStarter = issueTableEnrichmentFailTxStarter{
|
|
inner: testHandler.TxStarter,
|
|
rowQuerySQL: &rowQuerySQL,
|
|
}
|
|
listGroup := func(groupKey string) issueTableRowsResponse {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
rowsHandler.ListIssueTableRows(w, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
|
|
Query: query,
|
|
Group: issueTableGroupSpec{Kind: "status"},
|
|
GroupKey: &groupKey,
|
|
Hierarchy: issueTableHierarchyRequest{Enabled: true},
|
|
Page: issueTablePageRequest{Limit: 50},
|
|
}))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list %s: status=%d body=%s", groupKey, w.Code, w.Body.String())
|
|
}
|
|
var response issueTableRowsResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode %s: %v", groupKey, err)
|
|
}
|
|
return response
|
|
}
|
|
|
|
doneRows := listGroup("status:done")
|
|
if len(doneRows.Rows) != 1 || doneRows.Rows[0].Issue.ID != childID {
|
|
t.Fatalf("done child must become a root in its own group: %#v", doneRows.Rows)
|
|
}
|
|
todoRows := listGroup("status:todo")
|
|
if len(todoRows.Rows) != 1 || todoRows.Rows[0].Issue.ID != parentID {
|
|
t.Fatalf("todo group root mismatch: %#v", todoRows.Rows)
|
|
}
|
|
if todoRows.Rows[0].DirectChildCount != 0 {
|
|
t.Fatalf("cross-group child leaked into todo parent count: %d", todoRows.Rows[0].DirectChildCount)
|
|
}
|
|
if !strings.Contains(rowQuerySQL, "membership AS NOT MATERIALIZED") ||
|
|
!strings.Contains(rowQuerySQL, "page AS MATERIALIZED") ||
|
|
!strings.Contains(rowQuerySQL, "(SELECT parent.id FROM membership parent") ||
|
|
strings.Contains(rowQuerySQL, "NOT EXISTS (SELECT 1 FROM membership parent") ||
|
|
strings.Contains(rowQuerySQL, "child_counts AS") {
|
|
t.Fatalf("hierarchy rows query must preserve ordered paging and page-local counts:\n%s", rowQuerySQL)
|
|
}
|
|
}
|
|
|
|
func TestIssueTableHierarchyRootKeysetPagination(t *testing.T) {
|
|
ctx := context.Background()
|
|
var projectID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title)
|
|
VALUES ($1, 'Server table hierarchy pagination')
|
|
RETURNING id
|
|
`, testWorkspaceID).Scan(&projectID); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
|
|
})
|
|
|
|
var finalNumber int
|
|
if err := testPool.QueryRow(ctx, `
|
|
UPDATE workspace
|
|
SET issue_counter = GREATEST(
|
|
issue_counter,
|
|
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
|
|
) + 3
|
|
WHERE id = $1
|
|
RETURNING issue_counter
|
|
`, testWorkspaceID).Scan(&finalNumber); err != nil {
|
|
t.Fatalf("reserve issue numbers: %v", err)
|
|
}
|
|
rows, err := testPool.Query(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, status, priority, creator_type, creator_id,
|
|
position, number, project_id
|
|
)
|
|
SELECT $1, 'Hierarchy root ' || n::text, 'todo', 'none', 'member', $2,
|
|
n::double precision, $3 + n - 1, $4
|
|
FROM generate_series(1, 3) AS n
|
|
ORDER BY n
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID, finalNumber-2, projectID)
|
|
if err != nil {
|
|
t.Fatalf("seed hierarchy roots: %v", err)
|
|
}
|
|
expectedIDs := make(map[string]struct{}, 3)
|
|
for rows.Next() {
|
|
var issueID string
|
|
if err := rows.Scan(&issueID); err != nil {
|
|
rows.Close()
|
|
t.Fatalf("scan hierarchy root: %v", err)
|
|
}
|
|
expectedIDs[issueID] = struct{}{}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
t.Fatalf("seed hierarchy roots: %v", err)
|
|
}
|
|
rows.Close()
|
|
|
|
query := issueTableQuerySpec{
|
|
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
|
|
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
|
|
}
|
|
fetchPage := func(cursor *string) issueTableRowsResponse {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListIssueTableRows(w, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
|
|
Query: query,
|
|
Group: issueTableGroupSpec{Kind: "none"},
|
|
Hierarchy: issueTableHierarchyRequest{Enabled: true},
|
|
Page: issueTablePageRequest{Limit: 2, Cursor: cursor},
|
|
}))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list hierarchy roots: status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var response issueTableRowsResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode hierarchy roots: %v", err)
|
|
}
|
|
return response
|
|
}
|
|
|
|
first := fetchPage(nil)
|
|
if first.Total != 3 || len(first.Rows) != 2 || first.NextCursor == nil {
|
|
t.Fatalf("unexpected first hierarchy page: total=%d rows=%d cursor=%v", first.Total, len(first.Rows), first.NextCursor)
|
|
}
|
|
second := fetchPage(first.NextCursor)
|
|
if second.Total != 0 || len(second.Rows) != 1 || second.NextCursor != nil {
|
|
t.Fatalf("unexpected second hierarchy page: total=%d rows=%d cursor=%v", second.Total, len(second.Rows), second.NextCursor)
|
|
}
|
|
|
|
seenIDs := make(map[string]struct{}, 3)
|
|
for _, page := range []issueTableRowsResponse{first, second} {
|
|
for _, row := range page.Rows {
|
|
if _, duplicate := seenIDs[row.Issue.ID]; duplicate {
|
|
t.Fatalf("hierarchy root %s repeated across pages", row.Issue.ID)
|
|
}
|
|
seenIDs[row.Issue.ID] = struct{}{}
|
|
}
|
|
}
|
|
if len(seenIDs) != len(expectedIDs) {
|
|
t.Fatalf("reachable hierarchy roots = %d, want %d", len(seenIDs), len(expectedIDs))
|
|
}
|
|
for issueID := range expectedIDs {
|
|
if _, ok := seenIDs[issueID]; !ok {
|
|
t.Fatalf("hierarchy root %s was not reachable through pagination", issueID)
|
|
}
|
|
}
|
|
}
|