Files
multica/server/internal/handler/issue_table_query.go
T
e3a40b0b8d feat(MUL-6243): custom issue status UI — picker, board, filters, settings (#7103)
* refactor(views): widen IssueStatus to open key, resolve category at render sites

Board columns, swimlane columns and hidden-column panels are CATEGORIES,
not status keys, so adding a custom status never adds a column. Retyped
those to IssueStatusCategory and made every presentation lookup
(STATUS_CONFIG, i18n labels, status icons) resolve the key to its
category first. Status filters stay in concrete keys and map back to
columns via the catalog.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* feat(views): catalog-driven status picker and per-status labels

The picker now offers the workspace catalog's active statuses grouped by
category, instead of the 7 hardcoded built-ins, and falls back to the
built-ins until the catalog lands so a cold render is unchanged.

Built-in names always resolve through i18n, never through the catalog's
server-seeded English name — otherwise every non-English workspace would
see 'In Progress' where it used to see '进行中'. Custom names come from
the catalog. That rule lives in one place: useStatusLabel.

Applied it to every surface that shows an issue's OWN status (detail
activity feed, table group headers and CSV export, hover card, inbox),
which previously showed the category label and so could not tell two
statuses in the same category apart.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* feat(views): show custom statuses on cards and in filters

Board columns and list sections are categories, so two issues in the
same column can sit on different statuses with nothing to tell them
apart. CustomStatusChip is that signal, and it renders nothing for a
built-in — a workspace with no custom statuses looks unchanged.

The status filter now lists the workspace catalog instead of the 7
hardcoded built-ins. Without this an issue moved onto a custom status
could not be narrowed to at all. Picker and filter share one
useStatusOptions hook so the two lists cannot drift apart.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* feat(settings): issue status catalog management with drag reorder

Owner/admin CRUD for custom statuses, organised by CATEGORY rather than
as one flat list. Category is not decoration here — it is the behavior a
status inherits, and picking it is the one moment an admin can, since it
is immutable afterwards. Built-ins are shown but locked: each is its
category's canonical definition.

Drag reorder writes intra-category positions starting at 1, because the
built-in is seeded at 0 and PATCH on it is a 403, so it always heads its
category. The optimistic cache write re-SORTS rather than only writing
positions, or the drag would visually undo itself until the refetch.

custom_issue_statuses is now published to the frontend, so the tab can
hide creation when the deployment has the rollout gate off instead of
offering a button that 403s.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): close the category contract end to end

Elon's review found the board only half-converted: the UI targeted
categories while the server still paged by concrete status key, so a
card on a custom status was dropped twice — it never came back from the
server, and the row filter would have discarded it if it had.

1. New `status_category` group kind (and compound secondary axis).
   Grouping rewrites the column with a static CASE built from the
   catalog; paging expands the category to concrete keys and stays on
   `status = ANY(...)`, so (workspace_id, status) is still usable. A
   workspace with no custom statuses gets exactly `i.status` back.
   Row filter, facet totals and swimlane cells all fold by category now.

2. Column visibility split from the status filter. Hiding a column used
   to write the other six built-in keys into statusFilters, so hiding
   Backlog silently dropped every CUSTOM status in other categories.
   Display state is now its own field. The saved-view baseline also
   stopped deleting custom status filters on reopen.

3. The board/list right-click menu reads the shared catalog options, so
   every entry point that can set a status offers the same set.

4. Remaining presentation consumers resolve a category instead of
   guessing: gantt bars were keyed by IssueStatus and went colorless on
   any custom key; hover card, sidebar, search, tab strip, inbox row,
   create toast and the activity feed all resolve explicitly now.

5. Reorder is one atomic statement behind a new endpoint, and the client
   submits ACTIVE rows only. The per-row PATCH loop half-committed when
   an archived row rejected mid-sequence.

Also: one catalog observer per card instead of three, and the stale
tasks/todo.md draft is gone.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): atomic reorder, category behavior sweep, rollout-safe grouping

Second review round.

1. Reorder is now one transaction holding the catalog's SHARED lock. The
   old shape validated ids, THEN ran the UPDATE — an archive committing
   in that window left the UPDATE silently skipping the archived row
   while the earlier rows were rewritten, and still returned 200. The
   authoritative active set is read under the lock and the payload must
   match it EXACTLY: a partial order would assign positions from the
   array index and collide with the rows it left out. Affected rows are
   checked against the payload before commit.

2. Swept the Web behavior consumers that still compared raw keys, so a
   custom category actually inherits behavior: gantt hide-completed,
   batch backlog parking, done/cancelled strikethrough, the mark-done
   button, sub-issue progress, search cancelled-demotion and mention
   demotion/dimming. issueBehavesAs is the one predicate they share, and
   it fails toward showing more and asking more. Two glyph sites (gantt
   row, swimlane lane) were still painting custom in_review as Todo.

3. Rollout safety. status_category is a contract this PR introduces, and
   the default-off creation flag does not guard the request path, so a
   new Web build would have 400'd against any un-upgraded pod — for every
   workspace. The client now sends it only once the catalog confirms the
   workspace HAS a custom status, which can only be true if the fleet
   already serves this version. Everyone else keeps the exact request
   they made before.

   That also uncovered a real defect: plain status grouping 500'd on a
   workspace holding custom statuses, because the descriptor rejected any
   non-built-in value and failed the whole response.

4. One catalog read per resolve instead of three, and the common path
   reads none at all. Pinned with observable scan-count tests rather than
   an assertion that the SQL string matches.

5. A custom-key filter no longer routes on a guess. categoryOf answers
   todo for anything unloaded, so a saved qa filter paged the todo column
   while the query still restricted status=qa — empty on every cold load,
   permanent if the catalog fetch failed. Unresolved custom keys now
   contribute no column until the catalog is authoritative; facet counts
   drop unknown keys rather than inflating todo.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): swimlane category axis, catalog tri-state, tests with teeth

Third review round.

1. Swimlane was only half-converted. The compound spec sent
   secondary=status_category unconditionally — the same rolling-deploy
   hole the board/list branches had — and the client row matcher then
   compared the raw key, so a qa row the server correctly folded into the
   in_review cell was thrown away again. Both now follow the same
   hasCustomStatuses switch, and the matcher knows which axis it is on.

2. The cold-load fix did not actually make the surface wait. Returning an
   empty column set is indistinguishable from "narrow to nothing", so the
   surface fetched nothing and rendered an empty board with no spinner;
   on catalog failure that was permanent, with no retry. Resolution is
   now pending / resolved / error: pending and error suspend fetching AND
   hold loading, error surfaces isStatusCatalogError. Fetching is
   suspended rather than left to fall back to all columns, which would
   briefly show the UNFILTERED board to someone opening a saved qa view.
   The group contract also joins the branch cursor identity, so a status
   cursor cannot ride into a status_category request.

3. Two tests were false-green and are replaced:
   - The reorder race archived BEFORE calling reorder, so the active-set
     check caught it even with the lock deleted. It now reproduces the
     real interleaving (reorder parked on the lock, archive commits, then
     reorder proceeds) and fails without the shared lock — with a 200,
     which is the actual hole.
   - The catalog read count read pg_stat_user_tables, whose counters flush
     asynchronously, and asserted delta <= 1 — vacuous at zero. It now
     counts through an injected querier and asserts exactly 1 read and 0
     category-expansion reads.

Also: plain status grouping 500'd on a workspace holding custom statuses
(the descriptor rejected non-built-in values and failed the whole
response) — that is the path the Table view and every client fallback
use. And the chat mention items dropped status_category, so a custom done
status in Current/Recent rendered as an active Todo.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): give the status catalog failure a real user path

Fourth review round. One group, both halves of it.

A failed status catalog suspends row fetching, because a custom status
filter cannot be routed to a column without it. That left isLoading and
isEmpty both false, so IssueSurface fell straight through to the content
branch and rendered an empty view with no explanation and no way out —
the controller's isStatusCatalogError had no consumer.

The catalog now carries its own `retry` (React Query's refetch) rather
than just a failure flag, the controller re-exposes it, and IssueSurface
renders an explicit error state ahead of loading/empty/content. Component
test covers the full loop: catalog rejects, error appears with no row
requests issued, clicking retry succeeds, fetching resumes.

The blocking condition is also narrowed. A BACKGROUND refetch can fail
while the last successful catalog is still cached, and error-before-loaded
meant that perfectly serviceable snapshot got a retry screen in front of
it. Only a failure with no data behind it blocks now; otherwise the last
successful catalog keeps resolving.

That rule lives in status-behavior.test.ts, where it can be asserted
directly. The component test asserts the wiring — it was originally
written to claim the rule too, but reverting the narrowing did not fail
it, so the claim moved to the layer that actually holds it.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): accept custom status keys in table filters.statuses

Filtering a board, list or table by a custom status 400'd the whole
request: `filters.statuses` carries the exact keys the user picked, and
the table query still validated that list against the 7 built-ins. Every
surface errored out instead of narrowing — the one thing the filter work
in this PR exists to do.

The predicate is a parameterized `status = ANY(...)`, so an unknown key
simply matches nothing; the check is now non-empty and length-bounded.

Found while writing the post-deploy verification steps, which is where
the gap became obvious: "filter by QA" was step one and it 400s.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 18:52:11 +08:00

644 lines
22 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))
}
// Any non-empty status KEY, not just the 7 built-ins. A status filter names
// the exact statuses the user picked, and since MUL-6243 those can be custom
// — rejecting them here 400'd the entire request, so filtering a board by a
// custom status errored the surface instead of narrowing it. The predicate
// below is a parameterized `status = ANY(...)`, so an unknown key simply
// matches nothing; length-bounded so an arbitrary blob cannot ride in.
for _, status := range spec.Filters.Statuses {
if status == "" || len(status) > 64 {
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
}