Files
multica/server/internal/handler/issue_table_query.go
Naiyuan Qing 984a2c2bff MUL-5954: feat: saved issue views V1 (MUL-4796) (#6516)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:55:05 +08:00

638 lines
21 KiB
Go

package handler
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
const (
issueTableDefaultPageSize = 50
issueTableMaxPageSize = 100
issueTableQueryTimeout = 8 * time.Second
)
func withIssueTableQueryTimeout(r *http.Request) (*http.Request, context.CancelFunc) {
ctx, cancel := context.WithTimeout(r.Context(), issueTableQueryTimeout)
return r.WithContext(ctx), cancel
}
func (h *Handler) beginIssueTableSnapshot(ctx context.Context) (*Handler, pgx.Tx, error) {
if h.TxStarter == nil {
return nil, nil, errors.New("transaction starter is unavailable")
}
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
return nil, nil, err
}
if _, err := tx.Exec(ctx, "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"); err != nil {
_ = tx.Rollback(context.Background())
return nil, nil, err
}
snapshot := *h
snapshot.DB = tx
snapshot.Queries = db.New(tx)
return &snapshot, tx, nil
}
func writeIssueTableQueryFailure(w http.ResponseWriter, r *http.Request, message string) {
if errors.Is(r.Context().Err(), context.DeadlineExceeded) {
writeJSON(w, http.StatusGatewayTimeout, map[string]any{
"error": "query_timeout",
"message": "The table query took too long. Narrow the filters and retry.",
})
return
}
writeError(w, http.StatusInternalServerError, message)
}
type issueTableActorRef struct {
Type string `json:"type"`
ID string `json:"id"`
}
type issueTableScope struct {
Kind string `json:"kind"`
AssigneeTypes []string `json:"assignee_types,omitempty"`
ProjectID string `json:"project_id,omitempty"`
Actor *issueTableActorRef `json:"actor,omitempty"`
Relation string `json:"relation,omitempty"`
}
type issueTableDateFilterRequest struct {
Field string `json:"field"`
Start string `json:"start"`
End string `json:"end"`
}
type issueTableFiltersRequest struct {
Statuses []string `json:"statuses,omitempty"`
Priorities []string `json:"priorities,omitempty"`
Assignees []issueTableActorRef `json:"assignees,omitempty"`
IncludeNoAssignee bool `json:"include_no_assignee,omitempty"`
Creators []issueTableActorRef `json:"creators,omitempty"`
ProjectIDs []string `json:"project_ids,omitempty"`
IncludeNoProject bool `json:"include_no_project,omitempty"`
LabelIDs []string `json:"label_ids,omitempty"`
Properties map[string][]string `json:"properties,omitempty"`
Date *issueTableDateFilterRequest `json:"date,omitempty"`
WorkingOnly bool `json:"working_only,omitempty"`
WorkingIssueIDs []string `json:"working_issue_ids,omitempty"`
IncludeSubIssues *bool `json:"include_sub_issues,omitempty"`
}
type issueTableSortRequest struct {
Field string `json:"field"`
Direction string `json:"direction"`
}
type issueTableQuerySpec struct {
Scope issueTableScope `json:"scope"`
Filters issueTableFiltersRequest `json:"filters"`
Search string `json:"search,omitempty"`
Sort issueTableSortRequest `json:"sort"`
}
type issueTableGroupSpec struct {
Kind string `json:"kind"`
PropertyID string `json:"property_id,omitempty"`
IncludeEmpty bool `json:"include_empty,omitempty"`
Primary string `json:"primary,omitempty"`
Secondary string `json:"secondary,omitempty"`
SecondaryValues []string `json:"secondary_values,omitempty"`
}
type issueTablePageRequest struct {
Limit int `json:"limit,omitempty"`
Cursor *string `json:"cursor,omitempty"`
}
type issueTableHierarchyRequest struct {
Enabled bool `json:"enabled"`
}
type issueTableGroupsRequest struct {
Query issueTableQuerySpec `json:"query"`
Group issueTableGroupSpec `json:"group"`
Page issueTablePageRequest `json:"page"`
}
type issueTableRowsRequest struct {
Query issueTableQuerySpec `json:"query"`
Group issueTableGroupSpec `json:"group"`
GroupKey *string `json:"group_key"`
Hierarchy issueTableHierarchyRequest `json:"hierarchy"`
ParentID *string `json:"parent_id"`
Page issueTablePageRequest `json:"page"`
}
type issueTableFacetSpec struct {
Kind string `json:"kind"`
PropertyID string `json:"property_id,omitempty"`
}
type issueTableFacetsRequest struct {
Query issueTableQuerySpec `json:"query"`
Facets []issueTableFacetSpec `json:"facets"`
IncludeTotal *bool `json:"include_total,omitempty"`
}
type issueTableSQL struct {
where string
args []any
fingerprint string
workspaceID pgtype.UUID
}
type issueTableCursor struct {
Version int `json:"v"`
QueryFingerprint string `json:"query"`
GroupKey *string `json:"group_key,omitempty"`
ParentID *string `json:"parent_id,omitempty"`
GroupOrder *int `json:"group_order,omitempty"`
GroupSortKey *string `json:"group_sort_key,omitempty"`
GroupCursorKey *string `json:"group_cursor_key,omitempty"`
BranchIdentity string `json:"branch_identity,omitempty"`
SortValue *string `json:"sort_value,omitempty"`
SortIsNull bool `json:"sort_is_null,omitempty"`
RowCreatedAt string `json:"row_created_at,omitempty"`
RowID string `json:"row_id,omitempty"`
}
func decodeIssueTableJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid issue table query: "+err.Error())
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "invalid issue table query: request must contain one JSON object")
return false
}
return true
}
func normalizeIssueTablePage(w http.ResponseWriter, page issueTablePageRequest) (int, *issueTableCursor, bool) {
limit := page.Limit
if limit == 0 {
limit = issueTableDefaultPageSize
}
if limit < 1 || limit > issueTableMaxPageSize {
writeError(w, http.StatusBadRequest, fmt.Sprintf("page.limit must be between 1 and %d", issueTableMaxPageSize))
return 0, nil, false
}
if page.Cursor == nil || strings.TrimSpace(*page.Cursor) == "" {
return limit, nil, true
}
decoded, err := base64.RawURLEncoding.DecodeString(*page.Cursor)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return 0, nil, false
}
var cursor issueTableCursor
if err := json.Unmarshal(decoded, &cursor); err != nil || cursor.Version != 1 {
writeError(w, http.StatusBadRequest, "invalid cursor")
return 0, nil, false
}
return limit, &cursor, true
}
func encodeIssueTableCursor(cursor issueTableCursor) *string {
encoded, err := json.Marshal(cursor)
if err != nil {
return nil
}
value := base64.RawURLEncoding.EncodeToString(encoded)
return &value
}
func issueTableCursorMatches(w http.ResponseWriter, cursor *issueTableCursor, fingerprint string, groupKey, parentID *string) bool {
if cursor == nil {
return true
}
if cursor.QueryFingerprint != fingerprint || !equalOptionalString(cursor.GroupKey, groupKey) || !equalOptionalString(cursor.ParentID, parentID) {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "cursor_query_mismatch",
"message": "cursor does not belong to this table query",
})
return false
}
return true
}
func equalOptionalString(a, b *string) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func canonicalIssueTableFingerprint(workspaceID string, spec issueTableQuerySpec) (string, error) {
explicitEmptyAssignees :=
spec.Filters.Assignees != nil && len(spec.Filters.Assignees) == 0
explicitEmptyWorkingIssues :=
spec.Filters.WorkingIssueIDs != nil && len(spec.Filters.WorkingIssueIDs) == 0
normalized := spec
normalized.Search = strings.TrimSpace(normalized.Search)
normalized.Scope.AssigneeTypes = sortedUniqueStrings(normalized.Scope.AssigneeTypes)
normalized.Filters.Statuses = sortedUniqueStrings(normalized.Filters.Statuses)
normalized.Filters.Priorities = sortedUniqueStrings(normalized.Filters.Priorities)
normalized.Filters.ProjectIDs = sortedUniqueStrings(normalized.Filters.ProjectIDs)
normalized.Filters.LabelIDs = sortedUniqueStrings(normalized.Filters.LabelIDs)
normalized.Filters.Assignees = sortedUniqueActors(normalized.Filters.Assignees)
normalized.Filters.WorkingIssueIDs = sortedUniqueStrings(normalized.Filters.WorkingIssueIDs)
normalized.Filters.Creators = sortedUniqueActors(normalized.Filters.Creators)
for key, values := range normalized.Filters.Properties {
normalized.Filters.Properties[key] = sortedUniqueStrings(values)
}
encoded, err := json.Marshal(struct {
WorkspaceID string `json:"workspace_id"`
Query issueTableQuerySpec `json:"query"`
ExplicitEmptyAssignees bool `json:"explicit_empty_assignees,omitempty"`
ExplicitEmptyWorkingIssues bool `json:"explicit_empty_working_issues,omitempty"`
}{
WorkspaceID: workspaceID,
Query: normalized,
ExplicitEmptyAssignees: explicitEmptyAssignees,
ExplicitEmptyWorkingIssues: explicitEmptyWorkingIssues,
})
if err != nil {
return "", err
}
digest := sha256.Sum256(encoded)
return "sha256:" + hex.EncodeToString(digest[:]), nil
}
func sortedUniqueStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
sort.Strings(result)
if len(result) == 0 {
return nil
}
return result
}
func sortedUniqueActors(values []issueTableActorRef) []issueTableActorRef {
seen := make(map[string]issueTableActorRef, len(values))
for _, value := range values {
key := value.Type + ":" + value.ID
seen[key] = value
}
keys := make([]string, 0, len(seen))
for key := range seen {
keys = append(keys, key)
}
sort.Strings(keys)
result := make([]issueTableActorRef, 0, len(keys))
for _, key := range keys {
result = append(result, seen[key])
}
if len(result) == 0 {
return nil
}
return result
}
func issueTableContainsString(values []string, candidate string) bool {
for _, value := range values {
if value == candidate {
return true
}
}
return false
}
func parseIssueTableUUIDList(w http.ResponseWriter, values []string, field string) ([]pgtype.UUID, bool) {
result := make([]pgtype.UUID, 0, len(values))
for _, raw := range values {
parsed, err := util.ParseUUID(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid "+field)
return nil, false
}
result = append(result, parsed)
}
return result, true
}
func parseIssueTableActor(w http.ResponseWriter, actor issueTableActorRef, field string) (issueActorFilter, bool) {
if !isIssueActorType(actor.Type) {
writeError(w, http.StatusBadRequest, "invalid "+field)
return issueActorFilter{}, false
}
id, err := util.ParseUUID(actor.ID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid "+field)
return issueActorFilter{}, false
}
return issueActorFilter{actorType: actor.Type, actorID: id}, true
}
func appendIssueTableInvolvedPredicate(where []string, addArg func(any) string, userID pgtype.UUID) []string {
ref := addArg(userID)
return append(where, fmt.Sprintf(`(
(i.assignee_type = 'agent' AND i.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = $1
AND a.owner_id = %[1]s::uuid
))
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
WHERE s.workspace_id = $1
AND sm.member_type = 'member'
AND sm.member_id = %[1]s::uuid
UNION
SELECT s.id
FROM squad s
JOIN agent a ON a.id = s.leader_id
WHERE s.workspace_id = $1
AND a.workspace_id = $1
AND a.owner_id = %[1]s::uuid
UNION
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
JOIN agent a ON a.id = sm.member_id
WHERE s.workspace_id = $1
AND sm.member_type = 'agent'
AND a.workspace_id = $1
AND a.owner_id = %[1]s::uuid
))
)`, ref))
}
func (h *Handler) compileIssueTableQuery(w http.ResponseWriter, r *http.Request, spec issueTableQuerySpec) (issueTableSQL, bool) {
workspaceID := h.resolveWorkspaceID(r)
workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return issueTableSQL{}, false
}
fingerprint, err := canonicalIssueTableFingerprint(util.UUIDToString(workspaceUUID), spec)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to canonicalize table query")
return issueTableSQL{}, false
}
where := []string{"i.workspace_id = $1"}
args := []any{workspaceUUID}
addArg := func(value any) string {
args = append(args, value)
return "$" + strconv.Itoa(len(args))
}
for _, status := range spec.Filters.Statuses {
if !issueTableContainsString(validIssueStatuses, status) {
writeError(w, http.StatusBadRequest, "invalid filters.statuses")
return issueTableSQL{}, false
}
}
if len(spec.Filters.Statuses) > 0 {
where = append(where, fmt.Sprintf("i.status = ANY(%s::text[])", addArg(sortedUniqueStrings(spec.Filters.Statuses))))
}
for _, priority := range spec.Filters.Priorities {
if !issueTableContainsString(validIssuePriorities, priority) {
writeError(w, http.StatusBadRequest, "invalid filters.priorities")
return issueTableSQL{}, false
}
}
if len(spec.Filters.Priorities) > 0 {
where = append(where, fmt.Sprintf("i.priority = ANY(%s::text[])", addArg(sortedUniqueStrings(spec.Filters.Priorities))))
}
// Workspace and project scopes share the optional assignee-type
// narrowing (the Members/Agents tabs above both surfaces).
appendAssigneeTypes := func() bool {
for _, actorType := range spec.Scope.AssigneeTypes {
if !isIssueActorType(actorType) {
writeError(w, http.StatusBadRequest, "invalid scope.assignee_types")
return false
}
}
if len(spec.Scope.AssigneeTypes) > 0 {
where = append(where, fmt.Sprintf("i.assignee_type = ANY(%s::text[])", addArg(sortedUniqueStrings(spec.Scope.AssigneeTypes))))
}
return true
}
switch spec.Scope.Kind {
case "workspace":
if !appendAssigneeTypes() {
return issueTableSQL{}, false
}
case "project":
projectID, err := util.ParseUUID(spec.Scope.ProjectID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid scope.project_id")
return issueTableSQL{}, false
}
where = append(where, fmt.Sprintf("i.project_id = %s::uuid", addArg(projectID)))
if !appendAssigneeTypes() {
return issueTableSQL{}, false
}
case "assignee":
if spec.Scope.Actor == nil {
writeError(w, http.StatusBadRequest, "scope.actor is required")
return issueTableSQL{}, false
}
actor, ok := parseIssueTableActor(w, *spec.Scope.Actor, "scope.actor")
if !ok {
return issueTableSQL{}, false
}
where = append(where, fmt.Sprintf("i.assignee_type = %s::text AND i.assignee_id = %s::uuid", addArg(actor.actorType), addArg(actor.actorID)))
case "creator":
if spec.Scope.Actor == nil {
writeError(w, http.StatusBadRequest, "scope.actor is required")
return issueTableSQL{}, false
}
actor, ok := parseIssueTableActor(w, *spec.Scope.Actor, "scope.actor")
if !ok {
return issueTableSQL{}, false
}
where = append(where, fmt.Sprintf("i.creator_type = %s::text AND i.creator_id = %s::uuid", addArg(actor.actorType), addArg(actor.actorID)))
case "my":
userID, ok := requireUserID(w, r)
if !ok {
return issueTableSQL{}, false
}
userUUID, err := util.ParseUUID(userID)
if err != nil {
writeError(w, http.StatusUnauthorized, "user not authenticated")
return issueTableSQL{}, false
}
relation := spec.Scope.Relation
if relation == "" {
relation = "any"
}
switch relation {
case "assigned":
where = append(where, fmt.Sprintf("i.assignee_type = 'member' AND i.assignee_id = %s::uuid", addArg(userUUID)))
case "created":
where = append(where, fmt.Sprintf("i.creator_type = 'member' AND i.creator_id = %s::uuid", addArg(userUUID)))
case "involved":
where = appendIssueTableInvolvedPredicate(where, addArg, userUUID)
case "any":
assignedRef := addArg(userUUID)
createdRef := addArg(userUUID)
involved := appendIssueTableInvolvedPredicate(nil, addArg, userUUID)[0]
where = append(where, fmt.Sprintf("((i.assignee_type = 'member' AND i.assignee_id = %s::uuid) OR (i.creator_type = 'member' AND i.creator_id = %s::uuid) OR %s)", assignedRef, createdRef, involved))
default:
writeError(w, http.StatusBadRequest, "invalid scope.relation")
return issueTableSQL{}, false
}
default:
writeError(w, http.StatusBadRequest, "invalid scope.kind")
return issueTableSQL{}, false
}
if spec.Filters.Assignees != nil || spec.Filters.IncludeNoAssignee {
ors := make([]string, 0, len(spec.Filters.Assignees)+1)
for _, value := range spec.Filters.Assignees {
actor, ok := parseIssueTableActor(w, value, "filters.assignees")
if !ok {
return issueTableSQL{}, false
}
ors = append(ors, fmt.Sprintf("(i.assignee_type = %s::text AND i.assignee_id = %s::uuid)", addArg(actor.actorType), addArg(actor.actorID)))
}
if spec.Filters.IncludeNoAssignee {
ors = append(ors, "(i.assignee_type IS NULL AND i.assignee_id IS NULL)")
}
if len(ors) == 0 {
// Omitted assignees means "no assignee filter"; an explicitly
// empty list means "match none", so API callers can preserve an
// intentionally active empty predicate.
where = append(where, "FALSE")
} else {
where = append(where, "("+strings.Join(ors, " OR ")+")")
}
}
if len(spec.Filters.Creators) > 0 {
ors := make([]string, 0, len(spec.Filters.Creators))
for _, value := range spec.Filters.Creators {
actor, ok := parseIssueTableActor(w, value, "filters.creators")
if !ok {
return issueTableSQL{}, false
}
ors = append(ors, fmt.Sprintf("(i.creator_type = %s::text AND i.creator_id = %s::uuid)", addArg(actor.actorType), addArg(actor.actorID)))
}
where = append(where, "("+strings.Join(ors, " OR ")+")")
}
projectIDs, ok := parseIssueTableUUIDList(w, spec.Filters.ProjectIDs, "filters.project_ids")
if !ok {
return issueTableSQL{}, false
}
if len(projectIDs) > 0 || spec.Filters.IncludeNoProject {
ors := make([]string, 0, 2)
if len(projectIDs) > 0 {
ors = append(ors, fmt.Sprintf("i.project_id = ANY(%s::uuid[])", addArg(projectIDs)))
}
if spec.Filters.IncludeNoProject {
ors = append(ors, "i.project_id IS NULL")
}
where = append(where, "("+strings.Join(ors, " OR ")+")")
}
labelIDs, ok := parseIssueTableUUIDList(w, spec.Filters.LabelIDs, "filters.label_ids")
if !ok {
return issueTableSQL{}, false
}
if len(labelIDs) > 0 {
where = append(where, fmt.Sprintf("EXISTS (SELECT 1 FROM issue_to_label itl WHERE itl.issue_id = i.id AND itl.label_id = ANY(%s::uuid[]))", addArg(labelIDs)))
}
if len(spec.Filters.Properties) > 0 {
raw, err := json.Marshal(spec.Filters.Properties)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid filters.properties")
return issueTableSQL{}, false
}
compiled, ok := parsePropertiesFilterParam(w, string(raw))
if !ok {
return issueTableSQL{}, false
}
if len(compiled) > 0 {
where = append(where, propertiesFilterPredicate(compiled, addArg))
}
}
if spec.Filters.Date != nil {
column := ""
switch spec.Filters.Date.Field {
case "created_at", "updated_at":
column = spec.Filters.Date.Field
default:
writeError(w, http.StatusBadRequest, "invalid filters.date.field")
return issueTableSQL{}, false
}
start, startErr := time.Parse(time.RFC3339Nano, spec.Filters.Date.Start)
end, endErr := time.Parse(time.RFC3339Nano, spec.Filters.Date.End)
if startErr != nil || endErr != nil || !start.Before(end) {
writeError(w, http.StatusBadRequest, "invalid filters.date range")
return issueTableSQL{}, false
}
where = append(where, fmt.Sprintf("i.%s >= %s AND i.%s < %s", column, addArg(start), column, addArg(end)))
}
if spec.Filters.WorkingOnly {
where = append(where, "EXISTS (SELECT 1 FROM agent_task_queue atq WHERE atq.issue_id = i.id AND atq.status = 'running')")
}
workingIssueIDs, ok := parseIssueTableUUIDList(w, spec.Filters.WorkingIssueIDs, "filters.working_issue_ids")
if !ok {
return issueTableSQL{}, false
}
if spec.Filters.WorkingIssueIDs != nil {
if len(workingIssueIDs) == 0 {
where = append(where, "FALSE")
} else {
where = append(where, fmt.Sprintf(
"i.id = ANY(%s::uuid[])",
addArg(workingIssueIDs),
))
}
}
if spec.Filters.IncludeSubIssues != nil && !*spec.Filters.IncludeSubIssues {
where = append(where, "i.parent_issue_id IS NULL")
}
where = appendIssueTableSearchFilter(where, addArg, spec.Search)
return issueTableSQL{
where: strings.Join(where, " AND "),
args: args,
fingerprint: fingerprint,
workspaceID: workspaceUUID,
}, true
}