Files
multica/server/internal/handler/issue_table_group.go
Multica Eve 40f9ecdd56 MUL-5202: unify Issue Query across List, Board, and Swimlane (#5820)
* MUL-5202: migrate status issue surfaces to table query

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: unify grouped issue surfaces

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: cover move safety boundaries

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: preserve server swimlane semantics

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: keep grouped surface facets exact

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 15:58:25 +08:00

817 lines
29 KiB
Go

package handler
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"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"
)
type issueTableGroupValueResponse struct {
Kind string `json:"kind"`
Status string `json:"status,omitempty"`
Actor *issueTableActorRef `json:"actor"`
ProjectID *string `json:"project_id,omitempty"`
ParentID *string `json:"parent_id,omitempty"`
Parent *issueTableParentRef `json:"parent,omitempty"`
PropertyID string `json:"property_id,omitempty"`
Value any `json:"value,omitempty"`
ValueState string `json:"value_state,omitempty"`
}
type issueTableParentRef struct {
ID string `json:"id"`
Number int32 `json:"number"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Status string `json:"status"`
}
type issueTableGroupContext struct {
Parent *issueTableParentRef `json:"parent,omitempty"`
}
type issueTableGroupDescriptorResponse struct {
Key string `json:"key"`
Value issueTableGroupValueResponse `json:"value"`
Count int64 `json:"count"`
SecondaryGroups []issueTableGroupDescriptorResponse `json:"secondary_groups,omitempty"`
}
type issueTableGroupsResponse struct {
QueryFingerprint string `json:"query_fingerprint"`
Total int64 `json:"total"`
Groups []issueTableGroupDescriptorResponse `json:"groups"`
NextCursor *string `json:"next_cursor"`
}
type resolvedIssueTableGroup struct {
kind string
propertyID string
propertyType string
groupExpr string
groupSortExpr string
activeOptionOrder []string
activeOptions map[string]struct{}
primary *resolvedIssueTableGroup
secondaryValues []string
secondaryFiltered bool
}
func issueTableGroupIdentity(group issueTableGroupSpec) string {
if group.Kind == "property" {
return "group:property:" + group.PropertyID + ":empty=" + strconv.FormatBool(group.IncludeEmpty)
}
if group.Kind == "compound" {
identity := "group:compound:" + group.Primary + ":" + group.Secondary
if group.SecondaryValues != nil {
identity += ":visible=" + strings.Join(group.SecondaryValues, ",")
}
return identity
}
return "group:" + group.Kind
}
func (h *Handler) resolveIssueTableGroup(w http.ResponseWriter, r *http.Request, workspaceID pgtype.UUID, group issueTableGroupSpec, allowNone bool) (resolvedIssueTableGroup, bool) {
if group.Kind != "compound" && group.SecondaryValues != nil {
writeError(w, http.StatusBadRequest, "group.secondary_values requires group.kind=compound")
return resolvedIssueTableGroup{}, false
}
switch group.Kind {
case "none":
if !allowNone {
writeError(w, http.StatusBadRequest, "group.kind=none is not valid for group headers")
return resolvedIssueTableGroup{}, false
}
return resolvedIssueTableGroup{kind: "none"}, true
case "status":
return resolvedIssueTableGroup{kind: "status", groupExpr: "i.status"}, true
case "assignee":
return resolvedIssueTableGroup{
kind: "assignee",
groupExpr: "CASE WHEN i.assignee_type IS NULL OR i.assignee_id IS NULL THEN '__unassigned__' ELSE i.assignee_type || ':' || i.assignee_id::text END",
// groupSortExpr runs after issues have been reduced to one row per
// actor. Resolving display names before GROUP BY executes one lookup
// per issue and turns large assignee groups into an N+1 query plan.
groupSortExpr: `LOWER(COALESCE(CASE split_part(group_value, ':', 1)
WHEN 'member' THEN (SELECT u.name FROM "user" u WHERE u.id = split_part(group_value, ':', 2)::uuid)
WHEN 'agent' THEN (SELECT a.name FROM agent a WHERE a.workspace_id = $1 AND a.id = split_part(group_value, ':', 2)::uuid)
WHEN 'squad' THEN (SELECT s.name FROM squad s WHERE s.workspace_id = $1 AND s.id = split_part(group_value, ':', 2)::uuid)
END, ''))`,
}, true
case "project":
return resolvedIssueTableGroup{
kind: "project",
groupExpr: "COALESCE(i.project_id::text, '__no_project__')",
groupSortExpr: `CASE WHEN group_value = '__no_project__' THEN '' ELSE LOWER(COALESCE(
(SELECT p.title FROM project p WHERE p.workspace_id = $1 AND p.id = group_value::uuid),
''
)) END`,
}, true
case "parent":
return resolvedIssueTableGroup{
kind: "parent",
groupExpr: "COALESCE(i.parent_issue_id::text, '__no_parent__')",
groupSortExpr: `CASE WHEN group_value = '__no_parent__' THEN '' ELSE LOWER(COALESCE(
(SELECT p.title FROM issue p WHERE p.workspace_id = $1 AND p.id = group_value::uuid),
''
)) END`,
}, true
case "compound":
if group.Secondary != "status" {
writeIssueTableUnsupportedGroup(w, "secondary_group_unsupported", "Only status is supported as a secondary group.")
return resolvedIssueTableGroup{}, false
}
if group.Primary != "assignee" && group.Primary != "project" && group.Primary != "parent" {
writeIssueTableUnsupportedGroup(w, "primary_group_unsupported", "This primary group is not supported.")
return resolvedIssueTableGroup{}, false
}
seenSecondaryValues := make(map[string]struct{}, len(group.SecondaryValues))
for _, value := range group.SecondaryValues {
if !issueTableContainsString(validIssueStatuses, value) {
writeError(w, http.StatusBadRequest, "invalid group.secondary_values")
return resolvedIssueTableGroup{}, false
}
if _, exists := seenSecondaryValues[value]; exists {
writeError(w, http.StatusBadRequest, "duplicate group.secondary_values")
return resolvedIssueTableGroup{}, false
}
seenSecondaryValues[value] = struct{}{}
}
primary, ok := h.resolveIssueTableGroup(w, r, workspaceID, issueTableGroupSpec{Kind: group.Primary}, false)
if !ok {
return resolvedIssueTableGroup{}, false
}
return resolvedIssueTableGroup{
kind: "compound",
primary: &primary,
secondaryValues: append([]string(nil), group.SecondaryValues...),
secondaryFiltered: group.SecondaryValues != nil,
}, true
case "property":
propertyUUID, err := util.ParseUUID(group.PropertyID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group.property_id")
return resolvedIssueTableGroup{}, false
}
property, err := h.Queries.GetIssueProperty(r.Context(), db.GetIssuePropertyParams{
ID: propertyUUID,
WorkspaceID: workspaceID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeIssueTableUnsupportedGroup(w, "property_not_found", "The grouped property no longer exists.")
return resolvedIssueTableGroup{}, false
}
slog.Warn("resolve table group property failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to resolve table group")
return resolvedIssueTableGroup{}, false
}
if property.ArchivedAt.Valid {
writeIssueTableUnsupportedGroup(w, "property_archived", "The grouped property is archived.")
return resolvedIssueTableGroup{}, false
}
propertyID := util.UUIDToString(property.ID)
quotedKey := "'" + propertyID + "'"
resolved := resolvedIssueTableGroup{
kind: "property",
propertyID: propertyID,
propertyType: property.Type,
activeOptions: map[string]struct{}{},
}
switch property.Type {
case "select":
config := parsePropertyConfig(property.Config)
resolved.activeOptionOrder = make([]string, 0, len(config.Options))
for _, option := range config.Options {
resolved.activeOptions[option.ID] = struct{}{}
resolved.activeOptionOrder = append(resolved.activeOptionOrder, "value:"+option.ID)
}
resolved.groupExpr = fmt.Sprintf(`CASE
WHEN NOT (i.properties ? %s) THEN 'unset:'
WHEN jsonb_typeof(i.properties -> %s) = 'string' AND i.properties ->> %s = ANY(%%s::text[]) THEN 'value:' || (i.properties ->> %s)
WHEN jsonb_typeof(i.properties -> %s) = 'string' THEN 'unavailable:' || (i.properties ->> %s)
ELSE 'unavailable:'
END`, quotedKey, quotedKey, quotedKey, quotedKey, quotedKey, quotedKey)
case "checkbox":
resolved.groupExpr = fmt.Sprintf(`CASE
WHEN NOT (i.properties ? %s) THEN 'unset:'
WHEN jsonb_typeof(i.properties -> %s) = 'boolean' THEN 'value:' || (i.properties ->> %s)
ELSE 'unavailable:'
END`, quotedKey, quotedKey, quotedKey)
default:
writeIssueTableUnsupportedGroup(w, "property_type_unsupported", "This property type cannot be used for grouping.")
return resolvedIssueTableGroup{}, false
}
return resolved, true
default:
writeIssueTableUnsupportedGroup(w, "group_kind_unsupported", "This group type is not supported.")
return resolvedIssueTableGroup{}, false
}
}
func writeIssueTableUnsupportedGroup(w http.ResponseWriter, code, message string) {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": "unsupported_group",
"code": code,
"message": message,
})
}
func (group resolvedIssueTableGroup) expression(addArg func(any) string) string {
if group.kind == "compound" && group.primary != nil {
return group.primary.expression(addArg)
}
if group.kind == "property" && group.propertyType == "select" {
active := make([]string, 0, len(group.activeOptions))
for value := range group.activeOptions {
active = append(active, value)
}
return fmt.Sprintf(group.groupExpr, addArg(active))
}
return group.groupExpr
}
func (group resolvedIssueTableGroup) sortExpression() string {
if group.kind == "compound" && group.primary != nil {
return group.primary.sortExpression()
}
if group.groupSortExpr != "" {
return group.groupSortExpr
}
return "group_value"
}
func (group resolvedIssueTableGroup) orderExpression(addArg func(any) string) string {
if group.kind == "compound" && group.primary != nil {
return group.primary.orderExpression(addArg)
}
switch group.kind {
case "status":
return "CASE group_value WHEN 'backlog' THEN 0 WHEN 'todo' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'in_review' THEN 3 WHEN 'done' THEN 4 WHEN 'blocked' THEN 5 WHEN 'cancelled' THEN 6 ELSE 7 END"
case "assignee":
return "CASE split_part(group_value, ':', 1) WHEN 'member' THEN 0 WHEN 'agent' THEN 1 WHEN 'squad' THEN 2 ELSE 3 END"
case "project":
return "CASE WHEN group_value = '__no_project__' THEN 0 ELSE 1 END"
case "parent":
return "CASE WHEN group_value = '__no_parent__' THEN 0 ELSE 1 END"
case "property":
if group.propertyType == "select" {
ref := addArg(group.activeOptionOrder)
return fmt.Sprintf("CASE WHEN group_value LIKE 'value:%%' THEN COALESCE(array_position(%s::text[], group_value), 100000) WHEN group_value LIKE 'unavailable:%%' THEN 100001 ELSE 100002 END", ref)
}
return "CASE group_value WHEN 'value:false' THEN 0 WHEN 'value:true' THEN 1 WHEN 'unavailable:' THEN 2 ELSE 3 END"
default:
return "0"
}
}
func (group resolvedIssueTableGroup) contextExpression(addArg func(any) string, issuePrefix string) string {
if group.kind == "compound" && group.primary != nil {
return group.primary.contextExpression(addArg, issuePrefix)
}
if group.kind != "parent" {
return "'{}'::jsonb"
}
prefixRef := addArg(issuePrefix)
return fmt.Sprintf(`CASE WHEN group_value = '__no_parent__' THEN '{}'::jsonb ELSE COALESCE((
SELECT jsonb_build_object('parent', jsonb_build_object(
'id', p.id::text,
'number', p.number,
'identifier', %s::text || '-' || p.number::text,
'title', p.title,
'status', p.status
))
FROM issue p
WHERE p.workspace_id = $1 AND p.id = group_value::uuid
), '{}'::jsonb) END`, prefixRef)
}
func compoundCellGroupKey(primaryKey, status string) string {
return "compound:" + base64.RawURLEncoding.EncodeToString([]byte(primaryKey)) + ":status:" + status
}
func (group resolvedIssueTableGroup) descriptor(raw string, count int64, context issueTableGroupContext, secondaryCounts map[string]int64) (issueTableGroupDescriptorResponse, error) {
if group.kind == "compound" && group.primary != nil {
descriptor, err := group.primary.descriptor(raw, count, context, nil)
if err != nil {
return descriptor, err
}
descriptor.SecondaryGroups = make([]issueTableGroupDescriptorResponse, 0, len(secondaryCounts))
for _, status := range validIssueStatuses {
statusCount := secondaryCounts[status]
descriptor.SecondaryGroups = append(descriptor.SecondaryGroups, issueTableGroupDescriptorResponse{
Key: compoundCellGroupKey(descriptor.Key, status),
Value: issueTableGroupValueResponse{
Kind: "status",
Status: status,
},
Count: statusCount,
})
}
return descriptor, nil
}
descriptor := issueTableGroupDescriptorResponse{Count: count}
switch group.kind {
case "status":
if !issueTableContainsString(validIssueStatuses, raw) {
return descriptor, fmt.Errorf("unexpected status group value %q", raw)
}
descriptor.Key = "status:" + raw
descriptor.Value = issueTableGroupValueResponse{Kind: "status", Status: raw}
case "assignee":
descriptor.Value.Kind = "assignee"
if raw == "__unassigned__" {
descriptor.Key = "assignee:unassigned"
return descriptor, nil
}
parts := strings.SplitN(raw, ":", 2)
if len(parts) != 2 || !isIssueActorType(parts[0]) {
return descriptor, fmt.Errorf("unexpected assignee group value %q", raw)
}
if _, err := util.ParseUUID(parts[1]); err != nil {
return descriptor, fmt.Errorf("unexpected assignee group value %q", raw)
}
descriptor.Key = "assignee:" + raw
descriptor.Value.Actor = &issueTableActorRef{Type: parts[0], ID: parts[1]}
case "project":
descriptor.Value.Kind = "project"
if raw == "__no_project__" {
descriptor.Key = "project:none"
return descriptor, nil
}
if _, err := util.ParseUUID(raw); err != nil {
return descriptor, fmt.Errorf("unexpected project group value %q", raw)
}
descriptor.Key = "project:" + raw
descriptor.Value.ProjectID = &raw
case "parent":
descriptor.Value.Kind = "parent"
if raw == "__no_parent__" {
descriptor.Key = "parent:none"
descriptor.Value.ValueState = "unset"
return descriptor, nil
}
if _, err := util.ParseUUID(raw); err != nil {
return descriptor, fmt.Errorf("unexpected parent group value %q", raw)
}
descriptor.Key = "parent:" + raw
descriptor.Value.ParentID = &raw
descriptor.Value.Parent = context.Parent
if context.Parent == nil {
descriptor.Value.ValueState = "unavailable"
} else {
descriptor.Value.ValueState = "value"
}
case "property":
state, rawValue, ok := strings.Cut(raw, ":")
if !ok {
return descriptor, fmt.Errorf("unexpected property group value %q", raw)
}
encoded := base64.RawURLEncoding.EncodeToString([]byte(rawValue))
descriptor.Key = "property:" + group.propertyID + ":" + state + ":" + encoded
descriptor.Value = issueTableGroupValueResponse{
Kind: "property",
PropertyID: group.propertyID,
}
switch state {
case "unset":
descriptor.Value.ValueState = "unset"
case "unavailable":
descriptor.Value.ValueState = "unavailable"
if rawValue != "" {
descriptor.Value.Value = rawValue
}
case "value":
descriptor.Value.ValueState = "value"
if group.propertyType == "checkbox" {
value, err := strconv.ParseBool(rawValue)
if err != nil {
return descriptor, fmt.Errorf("unexpected checkbox group value %q", rawValue)
}
descriptor.Value.Value = value
} else {
descriptor.Value.Value = rawValue
}
default:
return descriptor, fmt.Errorf("unexpected property group state %q", state)
}
default:
return descriptor, fmt.Errorf("unsupported group kind %q", group.kind)
}
return descriptor, nil
}
func (group resolvedIssueTableGroup) predicate(w http.ResponseWriter, key string, addArg func(any) string) (string, bool) {
if group.kind == "compound" && group.primary != nil {
const prefix = "compound:"
if !strings.HasPrefix(key, prefix) {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
encodedAndStatus := strings.TrimPrefix(key, prefix)
encoded, status, ok := strings.Cut(encodedAndStatus, ":status:")
if !ok || !issueTableContainsString(validIssueStatuses, status) {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
primaryPredicate, ok := group.primary.predicate(w, string(decoded), addArg)
if !ok {
return "", false
}
return fmt.Sprintf("(%s) AND i.status = %s::text", primaryPredicate, addArg(status)), true
}
switch group.kind {
case "none":
if key != "" {
writeError(w, http.StatusBadRequest, "group_key must be empty when group.kind=none")
return "", false
}
return "TRUE", true
case "status":
const prefix = "status:"
if !strings.HasPrefix(key, prefix) || !issueTableContainsString(validIssueStatuses, strings.TrimPrefix(key, prefix)) {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
return fmt.Sprintf("i.status = %s::text", addArg(strings.TrimPrefix(key, prefix))), true
case "assignee":
const prefix = "assignee:"
if !strings.HasPrefix(key, prefix) {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
raw := strings.TrimPrefix(key, prefix)
if raw == "unassigned" {
return "i.assignee_type IS NULL AND i.assignee_id IS NULL", true
}
parts := strings.SplitN(raw, ":", 2)
if len(parts) != 2 || !isIssueActorType(parts[0]) {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
id, err := util.ParseUUID(parts[1])
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
return fmt.Sprintf("i.assignee_type = %s::text AND i.assignee_id = %s::uuid", addArg(parts[0]), addArg(id)), true
case "project":
const prefix = "project:"
if !strings.HasPrefix(key, prefix) {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
raw := strings.TrimPrefix(key, prefix)
if raw == "none" {
return "i.project_id IS NULL", true
}
id, err := util.ParseUUID(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
return fmt.Sprintf("i.project_id = %s::uuid", addArg(id)), true
case "parent":
const prefix = "parent:"
if !strings.HasPrefix(key, prefix) {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
raw := strings.TrimPrefix(key, prefix)
if raw == "none" {
return "i.parent_issue_id IS NULL", true
}
id, err := util.ParseUUID(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
return fmt.Sprintf("i.parent_issue_id = %s::uuid", addArg(id)), true
case "property":
prefix := "property:" + group.propertyID + ":"
if !strings.HasPrefix(key, prefix) {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
rest := strings.TrimPrefix(key, prefix)
state, encoded, ok := strings.Cut(rest, ":")
if !ok {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
value := string(decoded)
keySQL := "'" + group.propertyID + "'"
switch state {
case "unset":
if value != "" {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
return fmt.Sprintf("NOT (i.properties ? %s)", keySQL), true
case "value":
if group.propertyType == "select" {
if _, exists := group.activeOptions[value]; !exists {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
return fmt.Sprintf("jsonb_typeof(i.properties -> %s) = 'string' AND i.properties ->> %s = %s::text", keySQL, keySQL, addArg(value)), true
}
if value != "true" && value != "false" {
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
return fmt.Sprintf("jsonb_typeof(i.properties -> %s) = 'boolean' AND i.properties ->> %s = %s::text", keySQL, keySQL, addArg(value)), true
case "unavailable":
if group.propertyType == "select" && value != "" {
return fmt.Sprintf("jsonb_typeof(i.properties -> %s) = 'string' AND i.properties ->> %s = %s::text", keySQL, keySQL, addArg(value)), true
}
return fmt.Sprintf("i.properties ? %s AND jsonb_typeof(i.properties -> %s) <> %s::text", keySQL, keySQL, addArg(map[string]string{"select": "string", "checkbox": "boolean"}[group.propertyType])), true
default:
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
default:
writeError(w, http.StatusBadRequest, "invalid group_key")
return "", false
}
}
func (h *Handler) ListIssueTableGroups(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusInternalServerError, "database is unavailable")
return
}
var request issueTableGroupsRequest
if !decodeIssueTableJSON(w, r, &request) {
return
}
r, cancel := withIssueTableQueryTimeout(r)
defer cancel()
snapshot, tx, err := h.beginIssueTableSnapshot(r.Context())
if err != nil {
slog.Warn("ListIssueTableGroups 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
limit, cursor, ok := normalizeIssueTablePage(w, request.Page)
if !ok {
return
}
compiled, ok := h.compileIssueTableQuery(w, r, request.Query)
if !ok {
return
}
group, ok := h.resolveIssueTableGroup(w, r, compiled.workspaceID, request.Group, false)
if !ok {
return
}
groupIdentity := issueTableGroupIdentity(request.Group)
if !issueTableCursorMatches(w, cursor, compiled.fingerprint, &groupIdentity, nil) {
return
}
args := append([]any(nil), compiled.args...)
addArg := func(value any) string {
args = append(args, value)
return "$" + strconv.Itoa(len(args))
}
groupExpr := group.expression(addArg)
groupSortExpr := group.sortExpression()
orderExpr := group.orderExpression(addArg)
contextExpr := group.contextExpression(addArg, h.getIssuePrefix(r.Context(), compiled.workspaceID))
cursorPredicate := "TRUE"
if cursor != nil {
if cursor.GroupOrder == nil || cursor.GroupSortKey == nil || cursor.GroupCursorKey == nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return
}
orderRef := addArg(*cursor.GroupOrder)
sortRef := addArg(*cursor.GroupSortKey)
keyRef := addArg(*cursor.GroupCursorKey)
cursorPredicate = fmt.Sprintf(`(group_order > %[1]s::int OR (
group_order = %[1]s::int AND (
group_sort > %[2]s::text OR (group_sort = %[2]s::text AND group_value > %[3]s::text)
)
))`, orderRef, sortRef, keyRef)
}
limitRef := addArg(limit + 1)
groupedCTE := fmt.Sprintf(`grouped AS (
SELECT %s AS group_value, COUNT(*)::bigint AS issue_count,
COUNT(*)::bigint AS visible_count, '{}'::jsonb AS secondary_counts
FROM issue i
WHERE %s
GROUP BY 1
)`, groupExpr, compiled.where)
if request.Group.IncludeEmpty && group.kind == "property" {
expectedValues := []string{"unset:"}
if group.propertyType == "select" {
expectedValues = append(append([]string(nil), group.activeOptionOrder...), "unset:")
} else {
expectedValues = []string{"value:false", "value:true", "unset:"}
}
expectedRef := addArg(expectedValues)
groupedCTE = fmt.Sprintf(`actual AS (
SELECT %s AS group_value, COUNT(*)::bigint AS issue_count
FROM issue i
WHERE %s
GROUP BY 1
), expected AS (
SELECT unnest(%s::text[]) AS group_value
), grouped AS (
SELECT e.group_value, COALESCE(a.issue_count, 0)::bigint AS issue_count,
COALESCE(a.issue_count, 0)::bigint AS visible_count,
'{}'::jsonb AS secondary_counts
FROM expected e
LEFT JOIN actual a USING (group_value)
UNION ALL
SELECT a.group_value, a.issue_count, a.issue_count AS visible_count,
'{}'::jsonb AS secondary_counts
FROM actual a
WHERE NOT (a.group_value = ANY(%s::text[]))
)`, groupExpr, compiled.where, expectedRef, expectedRef)
}
if group.kind == "compound" {
groupedCTE = fmt.Sprintf(`cells AS (
SELECT %s AS group_value, i.status AS secondary_value, COUNT(*)::bigint AS cell_count
FROM issue i
WHERE %s
GROUP BY 1, 2
), grouped AS (
SELECT group_value,
SUM(cell_count)::bigint AS issue_count,
SUM(cell_count)::bigint AS visible_count,
jsonb_object_agg(secondary_value, cell_count)::jsonb AS secondary_counts
FROM cells
GROUP BY group_value
)`, groupExpr, compiled.where)
if group.secondaryFiltered {
visibleRef := addArg(group.secondaryValues)
headerPredicate := "TRUE"
promotedParentsCTE := ""
if group.primary != nil && group.primary.kind == "parent" {
headerPredicate = `NOT (
i.parent_issue_id IS NULL AND
EXISTS (SELECT 1 FROM promoted_parents p WHERE p.id = i.id)
)`
promotedParentsCTE = fmt.Sprintf(`, promoted_parents AS (
SELECT DISTINCT child.parent_issue_id AS id
FROM membership child
WHERE child.parent_issue_id IS NOT NULL
AND child.status = ANY(%s::text[])
)`, visibleRef)
}
groupedCTE = fmt.Sprintf(`membership AS NOT MATERIALIZED (
SELECT i.*
FROM issue i
WHERE %s
)%s, cells AS (
SELECT %s AS group_value, i.status AS secondary_value,
COUNT(*) FILTER (WHERE %s)::bigint AS cell_count
FROM membership i
GROUP BY 1, 2
), grouped AS (
SELECT group_value,
SUM(cell_count)::bigint AS issue_count,
COALESCE(
SUM(cell_count) FILTER (WHERE secondary_value = ANY(%s::text[])),
0
)::bigint AS visible_count,
jsonb_object_agg(secondary_value, cell_count)::jsonb AS secondary_counts
FROM cells
GROUP BY group_value
HAVING COALESCE(
SUM(cell_count) FILTER (WHERE secondary_value = ANY(%s::text[])),
0
) > 0
)`, compiled.where, promotedParentsCTE, groupExpr, headerPredicate, visibleRef, visibleRef)
}
}
query := fmt.Sprintf(`WITH %s, sorted AS (
SELECT group_value, issue_count, visible_count, secondary_counts, (%s)::text AS group_sort,
(%s)::jsonb AS group_context
FROM grouped
), ranked AS (
SELECT group_value, issue_count, visible_count, secondary_counts, group_sort, group_context, (%s)::int AS group_order,
SUM(visible_count) OVER ()::bigint AS total
FROM sorted
)
SELECT group_value, issue_count, secondary_counts, group_sort, group_context, group_order, total
FROM ranked
WHERE %s
ORDER BY group_order ASC, group_sort ASC, group_value ASC
LIMIT %s`, groupedCTE, groupSortExpr, contextExpr, orderExpr, cursorPredicate, limitRef)
rows, err := h.DB.Query(r.Context(), query, args...)
if err != nil {
slog.Warn("ListIssueTableGroups query failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to list table groups")
return
}
defer rows.Close()
groups := make([]issueTableGroupDescriptorResponse, 0, limit+1)
orders := make([]int, 0, limit+1)
sortValues := make([]string, 0, limit+1)
values := make([]string, 0, limit+1)
var total int64
for rows.Next() {
var raw string
var count int64
var secondaryJSON []byte
var sortValue string
var contextJSON []byte
var order int
if err := rows.Scan(&raw, &count, &secondaryJSON, &sortValue, &contextJSON, &order, &total); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table groups")
return
}
var context issueTableGroupContext
if len(contextJSON) > 0 {
if err := json.Unmarshal(contextJSON, &context); err != nil {
writeIssueTableQueryFailure(w, r, "failed to resolve table group")
return
}
}
secondaryCounts := map[string]int64{}
if len(secondaryJSON) > 0 {
if err := json.Unmarshal(secondaryJSON, &secondaryCounts); err != nil {
writeIssueTableQueryFailure(w, r, "failed to resolve table group")
return
}
}
descriptor, err := group.descriptor(raw, count, context, secondaryCounts)
if err != nil {
slog.Warn("ListIssueTableGroups descriptor failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to resolve table group")
return
}
groups = append(groups, descriptor)
orders = append(orders, order)
sortValues = append(sortValues, sortValue)
values = append(values, raw)
}
if err := rows.Err(); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table groups")
return
}
rows.Close()
var nextCursor *string
if len(groups) > limit {
groups = groups[:limit]
lastOrder := orders[limit-1]
lastSort := sortValues[limit-1]
lastKey := values[limit-1]
nextCursor = encodeIssueTableCursor(issueTableCursor{
Version: 1,
QueryFingerprint: compiled.fingerprint,
GroupKey: &groupIdentity,
GroupOrder: &lastOrder,
GroupSortKey: &lastSort,
GroupCursorKey: &lastKey,
})
}
response := issueTableGroupsResponse{
QueryFingerprint: compiled.fingerprint,
Total: total,
Groups: groups,
NextCursor: nextCursor,
}
if err := tx.Commit(r.Context()); err != nil {
slog.Warn("ListIssueTableGroups 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)
}