mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
* 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>
817 lines
29 KiB
Go
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)
|
|
}
|