mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* feat(core): add app space model for rail navigation Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): replace left sidebar with app rail and space panels Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * test(e2e): drive navigation through the app rail Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): finish the rail and panel shell against the mock - The sidebar toggle always works: in Inbox, Chat and Settings it collapses the page's own list column instead of being disabled. - Move create workspace from the rail into the account menu, and complete the menu: profile, theme and language submenus, shortcuts, docs, change log, Discord, desktop download, log out. - Add an explicit New issue button to the Issues and My issues headers. - Autopilot panel gets a create button and template shortcuts; Team panel gets an invite-member button for owners and admins. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(desktop): draw tabs as pills on the title bar The active tab no longer merges into the content card: with the space panel inside the card, the merged tab landed over whatever column happened to sit below it. Every tab is now a pill on the title bar; the active one uses the card's own surface, inactive ones a lighter tint. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): give list columns the space panel's width Inbox and Chat defaulted to 260px with their own saved widths, Settings was a fixed 240px, and the space panel used the sidebar width. They now share one ListColumn sized by --sidebar-width with the panel's resize edge, so the divider stays in place across spaces and one drag resizes them all. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): return each rail space to where you left it Rail items always linked to the space's home, so switching spaces dropped the project, squad, chat session or settings tab you had open. The shell now remembers the last page (path and query) per space and workspace, and the rail links there; the current space's item still leads home, so a second click resets it. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): animate the list column when the sidebar toggles Inbox, Chat and Settings unmounted their list column on toggle, so it snapped away while the space panel slid. ListColumn now stays mounted and eases its width like the panel (200ms, off under reduced motion and while dragging), which also keeps the list's scroll position across a collapse. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(layout): one title band for every left column The space panels used PanelHeader (title-sm, no rule), Inbox and Chat used the page header (body size, bottom border) and Settings its own 64px title. PanelHeader now takes a heading level, a leading slot and a meta slot, and Inbox, Chat and Settings render their list title with it. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(layout): collapse the whole sidebar with one toggle across spaces Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): switch workspaces from one rail tile Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): preview Inbox, Chat and Settings lists on rail hover Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(inbox): keep the list column's own loading skeleton Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(desktop): lay out the shell for the page on screen during rail switches (MUL-8317) The tab session URL moves before the router commits the next page (it renders in a transition), so for a few frames the shell already showed the incoming space's layout around the outgoing page: switching into Inbox or Chat dropped the Work panel and the old page's header icon jumped to the top-left. The router now publishes its committed pathname from a layout effect and the shell's panel and list columns follow it; space memory keeps following the navigation URL. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): flatten the settings nav into one level of section headings (MUL-8317) Scope header rows, sub-group headings and dividers stacked three levels into the 240px list column. Every section is now a peer with the space panels' section label and spacing; each page already names its scope in a badge. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): expand projects inline in the work panel (MUL-8317) Project rows in the Issues panel get a chevron that lists the project's open issues in place, so the sidebar can move between issues without opening the project page. The issue on screen reveals its project and stays lit; expansion persists per workspace. A dedicated open-issues cache follows realtime title patches and refetches only when status or project membership moves. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): sidebar tree settings, load more, expandable views and project order (MUL-8317) - Expanded projects and views list issues from the server (updated date, finished statuses hidden) with Load more / Show less. - One shared issue display popover on hovered expanded projects; a project display popover on the Projects header (ordering, statuses, only mine, counts); drag to reorder; collapsible section; no project cap. - Issue rows shimmer and show agent avatars while an agent works or waits. - Settings, order and expansion persist per workspace. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(views): show sidebar status filters as checkboxes (MUL-8317) Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(ui): auto-collapse the nav only below lg (MUL-8317) The rail now stays on screen beside the space panel, so laptop-sized windows (1024-1279px, just under the 1280 desktop default) have room for the panel; they no longer open Issues with it collapsed. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): one Projects display popover, higher-contrast filter chips (MUL-8317) Issue settings are shared by every project, so they move from each expanded row into the Projects header popover beside the project settings. Status filters go back to chips: a solid outline and tint when on, dashed and faded when off. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(server): list views across scopes and store sidebar view order Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): starred views sidebar, unified view header and All views page - Sidebar Views lists only starred views (built-in and saved, user-ordered) with an All views row; order lives in a per-user sidebar preference. - Issues, My issues and project pages share one header: view name menu, folding filter chips (+N), assignee-type / relation chip, Reset and Save. - Saved-view filters are fully editable; new views are created directly and renamed inline instead of through the save dialog. - New All views directory with tabs, search and star toggles. - Project page drops the breadcrumb and hides the redundant project chip. - Remove the view bar, save/manage dialogs and the old headers. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(inbox): switch between inbox and archive with header tabs Replace the Archived row that trailed the notification list (and the back row inside the archive) with Inbox | Archived tabs pinned under the list header. The unread count moves onto the Inbox tab, and an empty archive no longer says the inbox is empty. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * Revert "feat(inbox): switch between inbox and archive with header tabs" This reverts commit e8a8e1bcea1bf5b45c4e5830d654523a0b77daff. Co-authored-by: multica-agent <[email protected]> * fix(inbox): drop the count beside the no-agents-working filter The count widened the whole filter menu for a toggle whose result is visible in the list as soon as it is applied. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(inbox): switch to the archive from the page title The Archived entry trailed the notification list and scrolled away with it. The list title is now a menu (Inbox / Archived), so the switch stays in reach without adding a row, and the archive's back row is gone. An empty archive no longer says the inbox is empty. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): keep a pinned issue lit after its URL turns canonical The issue route replaces /issues/<uuid> with /issues/MUL-123, but a pin only matched its own UUID href, so clicking a pinned issue lit the same issue under its project instead. Issue pins now match the open issue by id, a lit pin no longer auto-expands its project, and pinned issues show the running agent the same way tree rows do. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): line pinned agent avatars up with the tree rows The avatar sat inside the gap reserved for the unpin button, so pinned avatars landed left of the project tree's and the label lost room. A pinned issue with agents now puts the avatar in the row's right edge and hands that slot to the unpin button on hover. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): raise the contrast of the working-agent shimmer ShimmerText now reads --shimmer-text-base, --shimmer-text-highlight and --shimmer-text-spread, defaulting to the previous values. Sidebar issue rows (tree and Pinned) rest on faint-foreground and use a 22% highlight band so the sweep is visible at sidebar size; chat, the issue page and other shimmer hosts are unchanged. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): keep the view header from overlapping on narrow widths The view-name button kept the Button base shrink-0, so once the h1 hit its min width the name painted over the add-filter trigger (visible on a 390px web viewport). The button now shrinks and truncates, the add-filter label becomes screen-reader-only in a narrow header, and FoldingFilterChips re-measures its reserved width when its leading or trailing controls resize instead of only when the chips change. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): one Filter dropdown and a header that adapts to its width The view header no longer lays filters out as chips with a +N overflow. A single Filter button carries the active count and opens the filter menu, which now lists the dimensions already filtering first, each showing its values, with the scope axis (assignee type, relation to me) at the top. The split is taken when the menu opens so a row never moves mid-edit. The header measures itself: when the title side runs out of room it first folds the display controls to icons, then the agents chip and New issue, before the title truncates, and only releases a step once the row is wider than the width that forced it. This fixes the overlap in saved views with table controls and after toggling agents working. FoldingFilterChips and single-row-fit had no other users and are removed. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): keep the highlight on the row that was clicked An issue that is both pinned and listed in the tree always lit its pin, so clicking it under a project moved the highlight away from the row just clicked. The work panel now remembers which section a click on an issue link came from: a click in the tree keeps the tree row lit and the pin unlit, a click on the pin keeps the pin. Arriving any other way (search, a link, a shortcut) still lights the pin. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(sidebar): let Views and Pinned collapse like Projects Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): keep a dragged row at its own height while reordering (MUL-8354) (#9662) Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(sidebar): create an issue in a project from its row Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): put the workflow beside the title and set the panel toggle apart The project workflow now sits on the left with the title and Filter, in the same borderless style. New issue matches Display's height, and a divider separates the right-sidebar toggle from the header actions. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): fold the layout switcher into the Display panel One Display button whose icon follows the layout. The panel opens with the layouts as a segmented row, then grouping, ordering and sub-issues, then the current layout's own options. The table's grouping and column pickers move in from the header. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): keep hovered Display chips from looking selected Light mode's muted and accent are the same grey, so a hovered chip matched a pressed one. Hover now only darkens the text and border, and a pressed chip fills with the stronger input tone. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(sidebar): give the Projects settings the Display panel's controls Both panels now share one chip and one sort-direction button. The sidebar popover drops its dividers and dashed off-chips, takes the same section headings, and its selects match the Display panel's width. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): quiet the issue view header down to its title The header carried six pieces of text, half of which said nothing: the project's workflow name, "0 agents working" and "Display". - Workflow moves into the project title menu as a submenu; the properties panel keeps its row. - Filter becomes an icon beside the title, followed by Reset / Save, so the count sits next to the actions it causes. Its tooltip names the active filter fields. - Display and New issue become ghost icon buttons named by tooltips, with New issue showing its C shortcut. - The agents chip shows only while someone is working or its filter is on, as avatars and a number. With every control already an icon, the crowding steps are gone. MUL-8361 Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): is / is not per filter, Relation to me, and a hover summary (MUL-8353) (#9663) * feat(issues): is / is not per filter, a Relation to me filter, and a hover summary (MUL-8353) Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): match the Relation filter's Chinese labels to My issues (MUL-8353) Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): advanced filter mode, menu search, and due date filter Advanced mode turns the simple filters into a rule tree (match all/any, is/is not per condition, one level of groups) edited in a panel behind the same Filter trigger. Switching back is offered when the tree is something simple mode can say. The filter menu gets a search box that matches fields and values; a value hit reads as the condition it adds. Due date joins as a dimension with relative buckets (overdue, today, this week, next 7 days, none). The issue table query accepts filters.advanced, filters.due_dates and filters.today, compiled to SQL with the same IS NOT TRUE negation. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * test(e2e): menu search, due date and advanced filter Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> Co-authored-by: Cursor <[email protected]> * test(e2e): let menus settle before filter screenshots Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(views): advanced filter keeps status columns, property filters and negated totals - Columns follow a status the rule tree requires, and a status it names un-hides its column, so switching to advanced no longer drops rows. - Status branch totals drop the excluded keys for "is not" instead of falling back to the loaded count. - The advanced panel shows property filters as editable rows with an "Add property filter" entry, and Clear all clears them too. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): make custom properties ordinary advanced filter conditions Properties are fields in the advanced tree: they sit in the field picker after the built-in fields, can live in groups and follow all/any, and edit their values with the property's own options. The server compiles a property:<id> condition with the existing property predicate. Entering advanced mode moves property filters into the tree, so the separate property section and its Add property filter button are gone. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): typed filter operators and direct condition groups - Add condition group inserts an empty group and opens its own add menu, instead of asking for a field first. The group's header carries its match ("Match any of these"); rows only show the joining word. - A row's menu can turn it into a group, and a tree with groups reads back as one sentence under the rows. - Operators follow the field's type: is / is not, includes any / none of for labels and multi-value properties, contains for text, comparisons for numbers, before / after for dates, and is empty / is not empty for anything that can be unset. Text, number and date values are typed in the row. Every operator is a reading of the stored negated + values, so saved views and the server query are unchanged. - Actor value headings sit in a menu group, which Base UI requires. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): New issue and search at the far end of the desktop title bar New issue lived only as a pen in the Issues panel header, so Inbox, Team, Chat and Auto had no way to start an issue but the C shortcut. Search sat in the rail between the workspace tile and the spaces. - Desktop: New issue and search are icon buttons at the right end of the title bar, styled like Back/Forward, with search last. The tabs give way to them. The rail drops search. - Web has no title bar, so its rail keeps both under the workspace tile, New issue first. - The button does what C does: a project page files the issue into that project. Both now share one helper. - The Issues panel header loses its pen; the page header keeps New issue. MUL-8359 Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): remove horizontal scrollbar from the filter menu Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(sidebar): every view expands in the sidebar Saved views preview through the table query, which expresses every view filter (is not, relation, due date, rule trees, my-any). Built-in views expand too, scoped like their page and listed with the sidebar issue settings. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(sidebar): group expanded rows by spacing, folder icon for projects Issues under an expanded row drop to 26px rows with no gap and a 12px status icon, an open group ends with 8px of space, and the expanded row's name goes full-strength. Sections sit 20px apart. Sidebar projects show a folder icon instead of their emoji. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): expandable rows give their label the full width The hidden chevron, an empty spacer and their gaps reserved about 90px at the end of every expandable row, so names truncated with space to spare. The chevron now takes the icon's place on hover. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): tree row chevron swaps back to the icon immediately Drop the opacity fades, and show the chevron for keyboard focus only, so a clicked row no longer keeps it until focus moves. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): drop the hint beside Switch to advanced filter The grey text sat where the menu shows current values, so it read as a setting rather than an explanation. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(layout): tighten the gap under panel titles, keep selected rows at regular weight The first section kept the 20px that separates sections, so it sat far below the panel title. Selected and expanded rows, desktop tabs, the Settings nav, agent tabs and the skills file tree no longer switch to medium weight: the wider glyphs made the label grow when it was clicked. Selection reads from the background and text colour. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): keep stars while loading, leave view state to in-place clicks, page past 100 - Star, unstar and reorder write the stored order, not the resolved items, and wait until that order is known, so views still loading are kept. - Sidebar rows switch the active view only when the click navigates this page; a new tab takes its target from the URL. - Sidebar issue lists walk 100-row pages so Load more keeps growing. - Drop a trailing blank line in advanced-filter-panel. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(views): open the clicked view in desktop tabs, prepare view state only on in-place navigation - Desktop tabs carry their own ?view=: list pages adopt it on mount and on back/forward, write view changes back with replace, and the active-view map is swapped with the active tab. A saved view is part of tab identity, so cmd+clicking view B from a tab on view A opens B in a new tab. - AppLink and useRowLink call onNavigateInPlace right before they push, so sidebar and All views rows prepare view state exactly when the page navigates in place (desktop shift-click included, web shift-click not). - Rail items and the workspace tile use named radius tokens. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): one page system for lists and objects Every page now follows the Issues page: one 48px header, nothing stacked under it. - Lists entered from the sidebar (agents, squads, skills, runtimes, projects, views, autopilots, issue wakeups) title themselves with an icon and a scope menu, and keep only icon tools: filter with its count and Reset, display, and "+". Counts left the header; list search is removed. - Objects (agent, squad, skill, runtime, autopilot, member) open under a breadcrumb with at most one solid action and a ⋯ menu. Their tabs became body sections with actions on the section titles, in a 760px column, with a 300px properties panel the header toggles. - Analytics keeps its tabs, now in the header; settings, chat and the create-agent flow share the same header. - Shared CollectionPage* and ObjectPage* primitives in views/layout. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): one CollectionList for every list page Replace the per-page tables and ListGrid with a shared CollectionList in packages/views/layout: single-line 36px rows with inline grey descriptions, a shared column/grid template, header sort, row selection with shift-range, keyboard navigation (arrows, J/K, Enter, X, Cmd+A, Esc), right-click and kebab row menus, load-more, and a shared batch bar. - createCollectionViewStore factory in core; display menus are generated from the column definitions. - Agents, Squads, Skills, Projects, Autopilots, Runtimes (page and RuntimeList), Views and Wakeups migrate to it; Wakeups pages with load-more instead of prev/next. - The Issues batch toolbar uses the shared CollectionBatchBar. - Agents hides owner and access by default so the default columns fit at 1280px; persisted v1 defaults migrate, custom choices are kept. - Remove ListGrid and the now-unused per-page locale keys. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(layout): smooth the nav panel toggle - Ease the panel and list columns with a soft-start curve (220ms ease) instead of ease-out, whose first frame jumped a quarter of the width and, when that frame dropped, nearly half of it. - End-align the panel content and clip it while it grows, so it slides in with the edge instead of spilling under the page; the resize handle moves to a zero-width sibling so the clip does not cut it. - The floating chat stays mounted while closed and re-rendered on every frame of the toggle as its container resized; it now re-renders only when the clamped window size changes. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): give My agents' work its own URL My agents' work shared /my-issues with My issues and was told apart only by a persisted global scope, so it could not open in its own tab, and switching one tab flipped every other. It now lives at /my-issues?scope=agents: the page, header and sidebar read the scope from the URL, desktop tab dedupe keys on it, and the tab gets its own title and icon. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(views): preserve My agents' work in copied links Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]>
847 lines
29 KiB
Go
847 lines
29 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"`
|
|
// ProjectStatuses filters on the parent project's lifecycle status
|
|
// (`validProjectStatuses`), independently of ProjectIDs.
|
|
ProjectStatuses []string `json:"project_statuses,omitempty"`
|
|
LabelIDs []string `json:"label_ids,omitempty"`
|
|
// Relations narrows by how the requesting user relates to the issue
|
|
// (`issueTableRelations`), OR within the list.
|
|
Relations []string `json:"relations,omitempty"`
|
|
// DueDates narrows by relative due-date bucket (`issueTableDueDates`),
|
|
// OR within the list, resolved against Today.
|
|
DueDates []string `json:"due_dates,omitempty"`
|
|
// Today is the viewer's calendar day (YYYY-MM-DD). Required whenever a
|
|
// due-date bucket is filtered on, here or in Advanced.
|
|
Today string `json:"today,omitempty"`
|
|
// Negate names the dimensions above (by JSON field) whose predicate is
|
|
// inverted. A listed dimension with no values of its own changes nothing.
|
|
Negate []string `json:"negate,omitempty"`
|
|
// Advanced is a condition tree ANDed with every flat filter: the one
|
|
// place an "or" can span dimensions.
|
|
Advanced *issueTableFilterNode `json:"advanced,omitempty"`
|
|
// Members are raw JSON so operator objects ({op, value}) and plain
|
|
// strings both survive the round-trip into parsePropertiesFilterParam.
|
|
Properties map[string][]json.RawMessage `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 {
|
|
// Empty preserves the seven-value protocol used by installed clients.
|
|
CategoryFormat string `json:"category_format,omitempty"`
|
|
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.ProjectStatuses = sortedUniqueStrings(normalized.Filters.ProjectStatuses)
|
|
normalized.Filters.LabelIDs = sortedUniqueStrings(normalized.Filters.LabelIDs)
|
|
normalized.Filters.Relations = sortedUniqueStrings(normalized.Filters.Relations)
|
|
normalized.Filters.Negate = sortedUniqueStrings(normalized.Filters.Negate)
|
|
normalized.Filters.DueDates = sortedUniqueStrings(normalized.Filters.DueDates)
|
|
normalized.Filters.Advanced = canonicalIssueTableFilterNode(normalized.Filters.Advanced)
|
|
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] = sortedUniqueRawJSON(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
|
|
}
|
|
|
|
// sortedUniqueRawJSON canonicalizes property filter members for the query
|
|
// fingerprint: byte-exact dedup + ordering. Members are raw JSON (strings and
|
|
// operator objects), and marshaling the map back into the fingerprint input
|
|
// must reproduce the same bytes for the same query regardless of member order.
|
|
func sortedUniqueRawJSON(values []json.RawMessage) []json.RawMessage {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(values))
|
|
unique := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
key := string(value)
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
unique = append(unique, key)
|
|
}
|
|
sort.Strings(unique)
|
|
result := make([]json.RawMessage, len(unique))
|
|
for i, value := range unique {
|
|
result[i] = json.RawMessage(value)
|
|
}
|
|
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
|
|
}
|
|
|
|
// issueTableRelations are the viewer relations `filters.relations` accepts.
|
|
var issueTableRelations = []string{"assigned", "created", "my_agents"}
|
|
|
|
// issueTableNegatableFilters are the `filters` fields `filters.negate` may name.
|
|
var issueTableNegatableFilters = []string{
|
|
"statuses", "priorities", "assignees", "creators",
|
|
"project_ids", "project_statuses", "label_ids", "relations", "due_dates",
|
|
}
|
|
|
|
// issueTableRelationPredicate matches issues related to the user one way.
|
|
func issueTableRelationPredicate(relation string, userRef string) string {
|
|
switch relation {
|
|
case "assigned":
|
|
return fmt.Sprintf("(i.assignee_type = 'member' AND i.assignee_id = %s::uuid)", userRef)
|
|
case "created":
|
|
return fmt.Sprintf("(i.creator_type = 'member' AND i.creator_id = %s::uuid)", userRef)
|
|
default: // my_agents
|
|
return 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 = %s::uuid
|
|
))`, userRef)
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
negated := make(map[string]bool, len(spec.Filters.Negate))
|
|
for _, field := range spec.Filters.Negate {
|
|
if !issueTableContainsString(issueTableNegatableFilters, field) {
|
|
writeError(w, http.StatusBadRequest, "invalid filters.negate")
|
|
return issueTableSQL{}, false
|
|
}
|
|
negated[field] = true
|
|
}
|
|
// "Is not" keeps every row the positive predicate does not accept,
|
|
// including rows where it is NULL (no assignee, no project): `IS NOT TRUE`
|
|
// rather than `NOT`, which would drop those too.
|
|
appendFilter := func(field, predicate string) {
|
|
if negated[field] {
|
|
where = append(where, "("+predicate+") IS NOT TRUE")
|
|
return
|
|
}
|
|
where = append(where, predicate)
|
|
}
|
|
|
|
// 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 {
|
|
appendFilter("statuses", 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 {
|
|
appendFilter("priorities", 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.
|
|
appendFilter("assignees", "FALSE")
|
|
} else {
|
|
appendFilter("assignees", "("+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)))
|
|
}
|
|
appendFilter("creators", "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
for _, relation := range spec.Filters.Relations {
|
|
if !issueTableContainsString(issueTableRelations, relation) {
|
|
writeError(w, http.StatusBadRequest, "invalid filters.relations")
|
|
return issueTableSQL{}, false
|
|
}
|
|
}
|
|
if relations := sortedUniqueStrings(spec.Filters.Relations); len(relations) > 0 {
|
|
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
|
|
}
|
|
userRef := addArg(userUUID)
|
|
ors := make([]string, 0, len(relations))
|
|
for _, relation := range relations {
|
|
ors = append(ors, issueTableRelationPredicate(relation, userRef))
|
|
}
|
|
appendFilter("relations", "("+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")
|
|
}
|
|
appendFilter("project_ids", "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
if len(spec.Filters.ProjectStatuses) > 0 {
|
|
for _, status := range spec.Filters.ProjectStatuses {
|
|
if !validateProjectEnum(w, "filters.project_statuses", status, validProjectStatuses) {
|
|
return issueTableSQL{}, false
|
|
}
|
|
}
|
|
// A projectless issue has no row to match, so EXISTS is false and the
|
|
// issue drops out — "no project" is deliberately not a project status.
|
|
// `p.workspace_id = i.workspace_id` is not redundant: the schema has no
|
|
// foreign keys by design, so a stale or corrupt `issue.project_id` can
|
|
// name a project in another workspace. Without the bound, that
|
|
// tenant's project status would decide this row's membership.
|
|
appendFilter("project_statuses", fmt.Sprintf(
|
|
"EXISTS (SELECT 1 FROM project p WHERE p.id = i.project_id AND p.workspace_id = i.workspace_id AND p.status = ANY(%s::text[]))",
|
|
addArg(spec.Filters.ProjectStatuses),
|
|
))
|
|
}
|
|
|
|
labelIDs, ok := parseIssueTableUUIDList(w, spec.Filters.LabelIDs, "filters.label_ids")
|
|
if !ok {
|
|
return issueTableSQL{}, false
|
|
}
|
|
if len(labelIDs) > 0 {
|
|
appendFilter("label_ids", 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)))
|
|
}
|
|
|
|
var (
|
|
today time.Time
|
|
todayResolved bool
|
|
)
|
|
resolveToday := func() (time.Time, error) {
|
|
if !todayResolved {
|
|
parsed, ok := parseIssueTableToday(spec.Filters.Today)
|
|
if !ok {
|
|
return time.Time{}, invalidIssueTableFilter("filters.today is required for due-date filters")
|
|
}
|
|
today, todayResolved = parsed, true
|
|
}
|
|
return today, nil
|
|
}
|
|
writeFilterError := func(err error) {
|
|
var filterErr issueTableFilterError
|
|
if errors.As(err, &filterErr) {
|
|
writeError(w, filterErr.status, filterErr.message)
|
|
return
|
|
}
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
}
|
|
|
|
for _, bucket := range spec.Filters.DueDates {
|
|
if !issueTableContainsString(issueTableDueDates, bucket) {
|
|
writeError(w, http.StatusBadRequest, "invalid filters.due_dates")
|
|
return issueTableSQL{}, false
|
|
}
|
|
}
|
|
if buckets := sortedUniqueStrings(spec.Filters.DueDates); len(buckets) > 0 {
|
|
day, err := resolveToday()
|
|
if err != nil {
|
|
writeFilterError(err)
|
|
return issueTableSQL{}, false
|
|
}
|
|
ors := make([]string, 0, len(buckets))
|
|
for _, bucket := range buckets {
|
|
ors = append(ors, issueTableDueDatePredicate(bucket, day, addArg))
|
|
}
|
|
appendFilter("due_dates", "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
if spec.Filters.Advanced != nil {
|
|
compiler := issueTableFilterCompiler{
|
|
addArg: addArg,
|
|
today: resolveToday,
|
|
userRef: func() (string, error) {
|
|
userUUID, err := util.ParseUUID(requestUserID(r))
|
|
if err != nil {
|
|
return "", issueTableFilterError{status: http.StatusUnauthorized, message: "user not authenticated"}
|
|
}
|
|
return addArg(userUUID), nil
|
|
},
|
|
}
|
|
predicate, err := compiler.compile(*spec.Filters.Advanced)
|
|
if err != nil {
|
|
writeFilterError(err)
|
|
return issueTableSQL{}, false
|
|
}
|
|
if predicate != "" {
|
|
where = append(where, predicate)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|