mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* 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]>
644 lines
22 KiB
Go
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
|
|
}
|