Files
multica/server/internal/handler/issue_table_facets.go
T
bb14e8763c feat(issues): add multi_text and multi_url custom property types (MUL-7496) (#8559)
* feat(issues): add multi_text and multi_url custom property types

Workspaces could only hold list-shaped values as preset option lists
(multi_select) or member references (multi_actor). There was no way to
keep a list of free-form strings or links on an issue, so teams with
"related docs / spec links / aliases" needs had to either cram them into
one text field (no structure, nothing clickable) or burn one select
option per link.

This adds two property types:

- multi_text: an ordered list of free-form text entries
- multi_url: an ordered list of http(s) URLs

Each entry is validated exactly like a single text / url value
(length caps, http(s)-only for URLs), duplicates are dropped, insertion
order is preserved, and values are capped at 20 entries to respect the
16KB properties-bag budget. Values ride the existing jsonb bag, so the
jsonb_path_ops GIN index, the @> containment filter, and the client
value schema all keep working unchanged.

Filtering matches any single list element exactly (equality) plus "No
value" — the containment form the filter compiler already emits for
array elements, so no new server predicate is needed. List types
deliberately get no sort order and cannot group, like the other
multi-value types.

On the web, list values render as chips; multi_url chips are clickable
and open in a new tab, and the editor popover offers a per-entry open
button, so several related documents or links stay directly reachable
from the issue. The filter menu reuses the scalar input (equality-only
plus "No value"). CLI: comma-separated --value form for both types,
element-equality --property filtering, and display_values rows.

Server-side allowlist widened by migration 500 (check constraint
re-added NOT VALID then validated, same pattern as migration 341).

* fix(properties): address list property review nits

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

* fix(properties): scope list drafts to the edited issue

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

* test(agent): avoid fork races when writing zeroclaw fixtures

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-07 13:33:02 +08:00

457 lines
18 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// The workspace property catalog is capped at 20 active definitions. Six
// built-in dimensions plus that catalog fit under this guard with headroom,
// while preventing a single request from scheduling hundreds of sequential
// aggregation scans inside one snapshot transaction.
const issueTableMaxFacets = 32
type issueTableFacetValueResponse struct {
Key string `json:"key"`
Count int64 `json:"count"`
}
type issueTableFacetResponse struct {
Kind string `json:"kind"`
PropertyID string `json:"property_id,omitempty"`
Values []issueTableFacetValueResponse `json:"values"`
}
type issueTableFacetsResponse struct {
QueryFingerprint string `json:"query_fingerprint"`
Total int64 `json:"total"`
Facets []issueTableFacetResponse `json:"facets"`
}
func issueTableQueryWithoutFacet(input issueTableQuerySpec, facet issueTableFacetSpec) issueTableQuerySpec {
output := input
output.Filters = input.Filters
if input.Filters.Properties != nil {
output.Filters.Properties = make(map[string][]json.RawMessage, len(input.Filters.Properties))
for propertyID, values := range input.Filters.Properties {
output.Filters.Properties[propertyID] = append([]json.RawMessage(nil), values...)
}
}
switch facet.Kind {
case "status":
output.Filters.Statuses = nil
case "priority":
output.Filters.Priorities = nil
case "assignee":
output.Filters.Assignees = nil
output.Filters.IncludeNoAssignee = false
case "creator":
output.Filters.Creators = nil
case "project":
output.Filters.ProjectIDs = nil
output.Filters.IncludeNoProject = false
case "label":
output.Filters.LabelIDs = nil
case "property":
delete(output.Filters.Properties, facet.PropertyID)
case "working_agents":
// Disjunctive like every other facet: drop this facet's own dimension so
// the answer is "who would you see if you turned the agents-working
// filter ON", which is also the answer while it is already on. That
// makes the header chip's count stable across the toggle.
output.Filters.WorkingIssueIDs = nil
output.Filters.WorkingOnly = false
}
return output
}
func issueTableFacetIdentity(facet issueTableFacetSpec) string {
if facet.Kind == "property" {
return "property:" + facet.PropertyID
}
return facet.Kind
}
func issueTableBaseFacetExpression(query issueTableQuerySpec, facet issueTableFacetSpec) (string, bool) {
switch facet.Kind {
case "status":
return "i.status", len(query.Filters.Statuses) == 0
case "priority":
return "i.priority", len(query.Filters.Priorities) == 0
case "assignee":
return "CASE WHEN i.assignee_type IS NULL OR i.assignee_id IS NULL THEN '__none__' ELSE i.assignee_type || ':' || i.assignee_id::text END", len(query.Filters.Assignees) == 0 && !query.Filters.IncludeNoAssignee
case "creator":
return "i.creator_type || ':' || i.creator_id::text", len(query.Filters.Creators) == 0
case "project":
return "COALESCE(i.project_id::text, '__none__')", len(query.Filters.ProjectIDs) == 0 && !query.Filters.IncludeNoProject
default:
return "", false
}
}
func (h *Handler) issueTableBaseFacetQuery(
w http.ResponseWriter,
r *http.Request,
base issueTableSQL,
requestQuery issueTableQuerySpec,
facets []issueTableFacetSpec,
includeTotal bool,
) (map[string]issueTableFacetResponse, int64, bool) {
markerCases := make([]string, 0, len(facets))
valueCases := make([]string, 0, len(facets))
groupingSets := make([]string, 0, len(facets)+1)
responses := make(map[string]issueTableFacetResponse, len(facets))
for _, facet := range facets {
expression, ok := issueTableBaseFacetExpression(requestQuery, facet)
if !ok {
writeIssueTableQueryFailure(w, r, "failed to batch table facets")
return nil, 0, false
}
identity := issueTableFacetIdentity(facet)
markerCases = append(markerCases, fmt.Sprintf("WHEN GROUPING(%s) = 0 THEN '%s'", expression, identity))
valueCases = append(valueCases, fmt.Sprintf("WHEN GROUPING(%s) = 0 THEN (%s)::text", expression, expression))
groupingSets = append(groupingSets, "("+expression+")")
responses[identity] = issueTableFacetResponse{
Kind: facet.Kind,
PropertyID: facet.PropertyID,
Values: []issueTableFacetValueResponse{},
}
}
if includeTotal {
groupingSets = append(groupingSets, "()")
}
query := fmt.Sprintf(`SELECT CASE %s ELSE '__total__' END,
CASE %s ELSE '' END,
COUNT(*)::bigint
FROM issue i
WHERE %s
GROUP BY GROUPING SETS (%s)`, strings.Join(markerCases, " "), strings.Join(valueCases, " "), base.where, strings.Join(groupingSets, ", "))
rows, err := h.DB.Query(r.Context(), query, base.args...)
if err != nil {
slog.Warn("ListIssueTableFacets batch query failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return nil, 0, false
}
defer rows.Close()
var total int64
for rows.Next() {
var identity string
var value string
var count int64
if err := rows.Scan(&identity, &value, &count); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return nil, 0, false
}
if identity == "__total__" {
total = count
continue
}
response, ok := responses[identity]
if !ok {
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return nil, 0, false
}
response.Values = append(response.Values, issueTableFacetValueResponse{Key: value, Count: count})
responses[identity] = response
}
if err := rows.Err(); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return nil, 0, false
}
for identity, response := range responses {
sort.Slice(response.Values, func(i, j int) bool {
return strings.Compare(response.Values[i].Key, response.Values[j].Key) < 0
})
responses[identity] = response
}
return responses, total, true
}
func (h *Handler) issueTableFacetQuery(w http.ResponseWriter, r *http.Request, requestQuery issueTableQuerySpec, facet issueTableFacetSpec) (issueTableFacetResponse, bool) {
response := issueTableFacetResponse{Kind: facet.Kind, PropertyID: facet.PropertyID, Values: []issueTableFacetValueResponse{}}
compiled, ok := h.compileIssueTableQuery(w, r, issueTableQueryWithoutFacet(requestQuery, facet))
if !ok {
return response, false
}
query := ""
switch facet.Kind {
case "status":
query = fmt.Sprintf(`SELECT i.status, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY i.status`, compiled.where)
case "priority":
query = fmt.Sprintf(`SELECT i.priority, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY i.priority`, compiled.where)
case "assignee":
query = fmt.Sprintf(`SELECT CASE WHEN i.assignee_type IS NULL OR i.assignee_id IS NULL THEN '__none__' ELSE i.assignee_type || ':' || i.assignee_id::text END, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY 1`, compiled.where)
case "creator":
query = fmt.Sprintf(`SELECT i.creator_type || ':' || i.creator_id::text, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY 1`, compiled.where)
case "project":
query = fmt.Sprintf(`SELECT COALESCE(i.project_id::text, '__none__'), COUNT(*)::bigint FROM issue i WHERE %s GROUP BY 1`, compiled.where)
case "label":
query = fmt.Sprintf(`SELECT itl.label_id::text, COUNT(DISTINCT i.id)::bigint FROM issue i JOIN issue_to_label itl ON itl.issue_id = i.id WHERE %s GROUP BY itl.label_id`, compiled.where)
case "working_agents":
agentIDs, ok := h.issueTableWorkingAgentIDs(w, r, compiled.workspaceID)
if !ok {
return response, false
}
if len(agentIDs) == 0 {
return response, true
}
// One row per agent currently running issue work inside THIS surface,
// with its running-task count. Same predicate as
// ListWorkspaceWorkingAgents with type=issue (chat > autopilot > issue
// precedence, user-authored non-archived agents only), but the issue set
// comes from the surface's own compiled scope + filters instead of a
// second, independent workspace-wide definition. That is what keeps the
// header chip's count equal to the rows clicking it leaves (MUL-5525).
// Join the unique visible-agent ids as a relation. With a large roster,
// ANY(array) can become a per-task linear filter on a global running
// scan; a relation also permits a hash join. Materialization bounds the
// intermediate set, not the optimizer's task access or issue join plan.
query = fmt.Sprintf(`WITH running AS MATERIALIZED (
SELECT atq.agent_id, atq.issue_id
FROM unnest($%d::uuid[]) AS cand(agent_id)
JOIN agent_task_queue atq
ON atq.agent_id = cand.agent_id AND atq.status = 'running'
WHERE atq.chat_session_id IS NULL
AND atq.autopilot_run_id IS NULL
AND atq.issue_id IS NOT NULL
)
SELECT r.agent_id::text, COUNT(*)::bigint
FROM running r
WHERE EXISTS (SELECT 1 FROM issue i WHERE i.id = r.issue_id AND %s)
GROUP BY r.agent_id`, len(compiled.args)+1, compiled.where)
compiled.args = append(compiled.args, agentIDs)
case "property":
propertyID, err := util.ParseUUID(facet.PropertyID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid facets.property_id")
return response, false
}
property, err := h.Queries.GetIssueProperty(r.Context(), db.GetIssuePropertyParams{
ID: propertyID,
WorkspaceID: compiled.workspaceID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeIssueTableUnsupportedGroup(w, "property_not_found", "The faceted property no longer exists.")
return response, false
}
slog.Warn("resolve table facet property failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to resolve table facet")
return response, false
}
if property.ArchivedAt.Valid {
writeIssueTableUnsupportedGroup(w, "property_archived", "The faceted property is archived.")
return response, false
}
propertyKey := "'" + util.UUIDToString(property.ID) + "'"
switch property.Type {
case "select", "actor":
// Unset issues count under the "__none__" bucket so the filter menu's
// "No value" option carries a real count (issues without the key).
query = fmt.Sprintf(`SELECT COALESCE(i.properties ->> %s, '__none__'), COUNT(*)::bigint FROM issue i WHERE %s AND (jsonb_typeof(i.properties -> %s) = 'string' OR NOT (i.properties ? %s)) GROUP BY 1`, propertyKey, compiled.where, propertyKey, propertyKey)
case "text", "url", "date", "number", "multi_text", "multi_url":
// Scalar and free-form-list values are unbounded distinct-value
// spaces (multi_text/multi_url enumerate arbitrary user strings),
// so grouping by distinct value would return one row per observed
// value with no bound. The filter menu only reads the "__none__"
// bucket for these types, so compute just the two bounded buckets
// instead of pulling the whole value space. Key existence is exact
// for "no value": stored values can never be null or empty, so a
// present key is always a set value.
query = fmt.Sprintf(`SELECT CASE WHEN i.properties ? %s THEN '__set__' ELSE '__none__' END, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY 1`, propertyKey, compiled.where)
case "multi_select", "multi_actor":
query = fmt.Sprintf(`SELECT COALESCE(property_value.value, '__none__'), COUNT(DISTINCT i.id)::bigint FROM issue i JOIN LATERAL (SELECT jsonb_array_elements_text(CASE WHEN jsonb_typeof(i.properties -> %s) = 'array' THEN i.properties -> %s ELSE '[]'::jsonb END) AS value UNION ALL SELECT NULL WHERE NOT (i.properties ? %s)) property_value(value) ON TRUE WHERE %s GROUP BY 1`, propertyKey, propertyKey, propertyKey, compiled.where)
case "checkbox":
query = fmt.Sprintf(`SELECT COALESCE(i.properties ->> %s, '__none__'), COUNT(*)::bigint FROM issue i WHERE %s AND (jsonb_typeof(i.properties -> %s) = 'boolean' OR NOT (i.properties ? %s)) GROUP BY 1`, propertyKey, compiled.where, propertyKey, propertyKey)
default:
writeIssueTableUnsupportedGroup(w, "property_type_unsupported", "This property type cannot be used as a filter facet.")
return response, false
}
default:
writeError(w, http.StatusBadRequest, "invalid facets.kind")
return response, false
}
rows, err := h.DB.Query(r.Context(), query, compiled.args...)
if err != nil {
slog.Warn("ListIssueTableFacets query failed", append(logger.RequestAttrs(r), "facet", issueTableFacetIdentity(facet), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return response, false
}
defer rows.Close()
for rows.Next() {
var value string
var count int64
if err := rows.Scan(&value, &count); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return response, false
}
response.Values = append(response.Values, issueTableFacetValueResponse{Key: value, Count: count})
}
if err := rows.Err(); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return response, false
}
sort.Slice(response.Values, func(i, j int) bool {
return strings.Compare(response.Values[i].Key, response.Values[j].Key) < 0
})
return response, true
}
// The working-agents facet discloses agent identity. Resolve its visible,
// active user agents inside the table snapshot before scanning their tasks.
// accessibleAgentIDs intentionally includes archived agents for other
// aggregations, so it cannot supply this facet's candidate set.
func (h *Handler) issueTableWorkingAgentIDs(
w http.ResponseWriter,
r *http.Request,
workspaceUUID pgtype.UUID,
) ([]pgtype.UUID, bool) {
workspaceID := util.UUIDToString(workspaceUUID)
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return nil, false
}
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
agents, err := h.Queries.ListAgents(r.Context(), workspaceUUID)
if err != nil {
writeIssueTableQueryFailure(w, r, "failed to resolve agent access")
return nil, false
}
targetsByAgent, ok := h.loadInvocationTargetsByAgent(r.Context(), agents)
if !ok {
writeIssueTableQueryFailure(w, r, "failed to resolve agent access")
return nil, false
}
ids := make([]pgtype.UUID, 0, len(agents))
for _, agent := range agents {
if actorType == "member" && !memberAllowedToViewAgent(agent, targetsByAgent[uuidToString(agent.ID)], actorID, member.Role) {
continue
}
ids = append(ids, agent.ID)
}
return ids, true
}
func (h *Handler) ListIssueTableFacets(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusInternalServerError, "database is unavailable")
return
}
var request issueTableFacetsRequest
if !decodeIssueTableJSON(w, r, &request) {
return
}
if len(request.Facets) == 0 || len(request.Facets) > issueTableMaxFacets {
writeError(w, http.StatusBadRequest, fmt.Sprintf("facets must contain between 1 and %d entries", issueTableMaxFacets))
return
}
r, cancel := withIssueTableQueryTimeout(r)
defer cancel()
snapshot, tx, err := h.beginIssueTableSnapshot(r.Context())
if err != nil {
slog.Warn("ListIssueTableFacets snapshot failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to start table query")
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(context.Background())
}
}()
h = snapshot
base, ok := h.compileIssueTableQuery(w, r, request.Query)
if !ok {
return
}
seen := make(map[string]struct{}, len(request.Facets))
normalizedFacets := make([]issueTableFacetSpec, len(request.Facets))
for index, facet := range request.Facets {
facet.Kind = strings.TrimSpace(facet.Kind)
facet.PropertyID = strings.TrimSpace(facet.PropertyID)
identity := issueTableFacetIdentity(facet)
if _, exists := seen[identity]; exists {
writeError(w, http.StatusBadRequest, "duplicate table facet")
return
}
seen[identity] = struct{}{}
normalizedFacets[index] = facet
}
includeTotal := request.IncludeTotal == nil || *request.IncludeTotal
batchFacets := make([]issueTableFacetSpec, 0, len(normalizedFacets))
individualIndexes := make([]int, 0, len(normalizedFacets))
for index, facet := range normalizedFacets {
if _, batchable := issueTableBaseFacetExpression(request.Query, facet); batchable {
batchFacets = append(batchFacets, facet)
} else {
individualIndexes = append(individualIndexes, index)
}
}
responses := make([]issueTableFacetResponse, len(normalizedFacets))
var total int64
totalResolved := false
if len(batchFacets) > 0 {
batched, batchTotal, ok := h.issueTableBaseFacetQuery(w, r, base, request.Query, batchFacets, includeTotal)
if !ok {
return
}
for index, facet := range normalizedFacets {
if response, exists := batched[issueTableFacetIdentity(facet)]; exists {
responses[index] = response
}
}
if includeTotal {
total = batchTotal
totalResolved = true
}
}
if includeTotal && !totalResolved {
if err := h.DB.QueryRow(r.Context(), fmt.Sprintf("SELECT COUNT(*)::bigint FROM issue i WHERE %s", base.where), base.args...).Scan(&total); err != nil {
slog.Warn("ListIssueTableFacets total failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to count table facets")
return
}
}
for _, index := range individualIndexes {
resolved, ok := h.issueTableFacetQuery(w, r, request.Query, normalizedFacets[index])
if !ok {
return
}
responses[index] = resolved
}
response := issueTableFacetsResponse{
QueryFingerprint: base.fingerprint,
Total: total,
Facets: responses,
}
if err := tx.Commit(r.Context()); err != nil {
slog.Warn("ListIssueTableFacets snapshot commit failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to finish table query")
return
}
committed = true
writeJSON(w, http.StatusOK, response)
}