mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
Make `triage` a platform-reserved issue status: it has no catalog row, is not a category a custom status can declare, and no ordinary write can set it or move an issue out of it. - issuestatus: Triage constant; IsBuiltIn covers it, so ValidateKey, DeriveKey and firstFreeKey refuse or skip the key; Resolve refuses it with ErrReservedStatus; Effective and Category resolve it without a catalog read - migrations: a barrier file adds CHECK (key <> 'triage') NOT VALID on its own, so it commits before the repair file renames a pre-existing custom `triage` key per workspace (firstFreeKey rule) across the catalog, issues and saved views and VALIDATEs; issue_effective_status() gains 'triage' on its fast path - writes: status_reserved_for_triage on create/update/batch; issue_in_triage when status, project_id or parent_issue_id is written on an issue in Triage; merged PRs no longer auto-advance a Triage issue - status sort ranks triage first; duplicate guard ignores triage issues - notification label, CLI help, TriageV1 feature flag key - cmd/migrate forwards notices marked 'migration report: ' to its log Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
1129 lines
41 KiB
Go
1129 lines
41 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/issuestatus"
|
|
"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
|
|
// secondaryCategory marks a compound whose secondary axis is the CATEGORY
|
|
// of the status rather than its key, retained for installed clients.
|
|
secondaryCategory bool
|
|
// categoryKeys maps each of the 4 categories to the concrete status keys
|
|
// that belong to it, resolved ONCE per request. Category predicates expand
|
|
// through this into `i.status = ANY(...)` so the (workspace_id, status)
|
|
// index stays usable — see issuestatus.ExpandCategories. (MUL-6243)
|
|
categoryKeys map[string][]string
|
|
// statusCustomKeys is the CUSTOM key -> category map behind
|
|
// statusCategoryExpr. Empty for a workspace with no custom statuses.
|
|
statusCustomKeys map[string]string
|
|
statusOrder []string
|
|
}
|
|
|
|
// statusCategoryExpr builds the scalar `status key -> category` rewrite used as
|
|
// a GROUP BY expression. Built-ins and custom keys are both translated to one
|
|
// of the four lifecycle categories. (MUL-6243)
|
|
func statusCategoryExpr(customKeys map[string]string, addArg func(any) string) string {
|
|
return statusValueCategoryExpr("i.status", customKeys, addArg)
|
|
}
|
|
|
|
func statusValueCategoryExpr(valueExpr string, customKeys map[string]string, addArg func(any) string) string {
|
|
allKeys := make(map[string]string, len(customKeys)+len(validIssueStatuses))
|
|
for _, key := range validIssueStatuses {
|
|
if category, ok := issuestatus.CategoryForBehavior(key); ok {
|
|
allKeys[key] = category
|
|
}
|
|
}
|
|
for key, category := range customKeys {
|
|
allKeys[key] = category
|
|
}
|
|
keys := make([]string, 0, len(allKeys))
|
|
for key := range allKeys {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
var b strings.Builder
|
|
b.WriteString("CASE " + valueExpr)
|
|
for _, key := range keys {
|
|
fmt.Fprintf(&b, " WHEN %s::text THEN %s::text", addArg(key), addArg(allKeys[key]))
|
|
}
|
|
b.WriteString(" ELSE " + valueExpr + " END")
|
|
return b.String()
|
|
}
|
|
|
|
// statusOrderExpression ranks a category in board order. Triage, which has no
|
|
// board column, sorts ahead of Unstarted: it is work nobody has taken on yet.
|
|
func statusOrderExpression(categoryExpr string) string {
|
|
return "CASE " + categoryExpr + " WHEN 'triage' THEN -1 WHEN 'unstarted' THEN 0 WHEN 'started' THEN 1 WHEN 'done' THEN 2 WHEN 'closed' THEN 3 ELSE 4 END"
|
|
}
|
|
|
|
// resolveStatusCategoryMaps derives BOTH shapes a category grouping needs from
|
|
// ONE catalog read: the custom key -> category map behind the GROUP BY rewrite,
|
|
// and the category -> concrete keys expansion that predicate() looks up (it has
|
|
// no context or Querier of its own).
|
|
//
|
|
// One read, not three. A board can load four column branches as separate
|
|
// HTTP requests, so a per-call `ExpandCategories` + `CustomKeyCategories` pair
|
|
// would multiply catalog SELECTs behind one surface load.
|
|
func (h *Handler) resolveStatusCategoryMaps(
|
|
ctx context.Context,
|
|
workspaceID pgtype.UUID,
|
|
) (customKeys map[string]string, categoryKeys map[string][]string, err error) {
|
|
customKeys, err = issuestatus.CustomKeyCategories(ctx, h.issueStatusCatalog(), workspaceID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
categoryKeys = make(map[string][]string, len(validIssueStatusCategories))
|
|
for _, category := range validIssueStatusCategories {
|
|
// Every category contains its built-in behaviors even for an unseeded
|
|
// workspace.
|
|
categoryKeys[category] = issuestatus.BehaviorsForCategory(category)
|
|
}
|
|
// Sorted so the expansion — and therefore the query's argument list — is
|
|
// deterministic for the same catalog.
|
|
keys := make([]string, 0, len(customKeys))
|
|
for key := range customKeys {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
category := customKeys[key]
|
|
categoryKeys[category] = append(categoryKeys[category], key)
|
|
}
|
|
return customKeys, categoryKeys, nil
|
|
}
|
|
|
|
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":
|
|
entries, err := h.issueStatusCatalog().ListIssueStatusEntries(r.Context(), db.ListIssueStatusEntriesParams{
|
|
WorkspaceID: workspaceID, IncludeArchived: true,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("resolve status group order failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to resolve table group")
|
|
return resolvedIssueTableGroup{}, false
|
|
}
|
|
customKeys := make(map[string]string)
|
|
for _, entry := range entries {
|
|
if !entry.IsSystem {
|
|
customKeys[entry.Key] = entry.Category
|
|
}
|
|
}
|
|
return resolvedIssueTableGroup{
|
|
kind: "status",
|
|
groupExpr: "i.status",
|
|
statusCustomKeys: customKeys,
|
|
statusOrder: issueTableStatusOrder(entries),
|
|
}, true
|
|
case "status_category":
|
|
// Retained category-grouping API for installed clients. New task views
|
|
// use exact status groups, including custom keys.
|
|
customKeys, categoryKeys, err := h.resolveStatusCategoryMaps(r.Context(), workspaceID)
|
|
if err != nil {
|
|
slog.Warn("resolve status category group failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to resolve table group")
|
|
return resolvedIssueTableGroup{}, false
|
|
}
|
|
return resolvedIssueTableGroup{
|
|
kind: "status_category",
|
|
categoryKeys: categoryKeys,
|
|
statusCustomKeys: customKeys,
|
|
}, 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" && group.Secondary != "status_category" {
|
|
writeIssueTableUnsupportedGroup(w, "secondary_group_unsupported", "Only status is supported as a secondary group.")
|
|
return resolvedIssueTableGroup{}, false
|
|
}
|
|
secondaryCategory := group.Secondary == "status_category"
|
|
if group.Primary != "assignee" && group.Primary != "project" && group.Primary != "parent" {
|
|
writeIssueTableUnsupportedGroup(w, "primary_group_unsupported", "This primary group is not supported.")
|
|
return resolvedIssueTableGroup{}, false
|
|
}
|
|
var customKeys map[string]string
|
|
if !secondaryCategory {
|
|
var err error
|
|
customKeys, err = issuestatus.CustomKeyCategories(r.Context(), h.issueStatusCatalog(), workspaceID)
|
|
if err != nil {
|
|
writeIssueTableQueryFailure(w, r, "failed to resolve table group")
|
|
return resolvedIssueTableGroup{}, false
|
|
}
|
|
}
|
|
seenSecondaryValues := make(map[string]struct{}, len(group.SecondaryValues))
|
|
seenInputs := make(map[string]bool, len(group.SecondaryValues))
|
|
normalizedSecondaryValues := make([]string, 0, len(group.SecondaryValues))
|
|
validSecondary := validIssueStatuses
|
|
if secondaryCategory {
|
|
validSecondary = validIssueStatusCategories
|
|
}
|
|
for _, value := range group.SecondaryValues {
|
|
if seenInputs[value] {
|
|
writeError(w, http.StatusBadRequest, "duplicate group.secondary_values")
|
|
return resolvedIssueTableGroup{}, false
|
|
}
|
|
seenInputs[value] = true
|
|
if secondaryCategory {
|
|
if normalized, ok := issuestatus.ParseCategory(value); ok {
|
|
value = normalized
|
|
}
|
|
}
|
|
_, custom := customKeys[value]
|
|
if !issueTableContainsString(validSecondary, value) && (secondaryCategory || !custom) {
|
|
writeError(w, http.StatusBadRequest, "invalid group.secondary_values")
|
|
return resolvedIssueTableGroup{}, false
|
|
}
|
|
if _, exists := seenSecondaryValues[value]; exists {
|
|
continue // Distinct legacy categories can now share one lifecycle.
|
|
}
|
|
seenSecondaryValues[value] = struct{}{}
|
|
normalizedSecondaryValues = append(normalizedSecondaryValues, value)
|
|
}
|
|
primary, ok := h.resolveIssueTableGroup(w, r, workspaceID, issueTableGroupSpec{Kind: group.Primary}, false)
|
|
if !ok {
|
|
return resolvedIssueTableGroup{}, false
|
|
}
|
|
resolved := resolvedIssueTableGroup{
|
|
kind: "compound",
|
|
primary: &primary,
|
|
secondaryValues: normalizedSecondaryValues,
|
|
secondaryFiltered: group.SecondaryValues != nil,
|
|
secondaryCategory: secondaryCategory,
|
|
statusCustomKeys: customKeys,
|
|
}
|
|
if secondaryCategory {
|
|
customKeys, categoryKeys, err := h.resolveStatusCategoryMaps(r.Context(), workspaceID)
|
|
if err != nil {
|
|
slog.Warn("resolve compound status category group failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to resolve table group")
|
|
return resolvedIssueTableGroup{}, false
|
|
}
|
|
resolved.statusCustomKeys = customKeys
|
|
resolved.categoryKeys = categoryKeys
|
|
}
|
|
return resolved, 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 == "status_category" {
|
|
return statusCategoryExpr(group.statusCustomKeys, 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"
|
|
}
|
|
|
|
// Match the catalog's category / position / built-in / key ordering, including
|
|
// fallback built-ins before a legacy workspace's catalog has been seeded.
|
|
func issueTableStatusOrder(entries []db.IssueStatus) []string {
|
|
rows := append([]db.IssueStatus(nil), entries...)
|
|
seen := make(map[string]bool, len(rows))
|
|
for _, row := range rows {
|
|
seen[row.Key] = true
|
|
}
|
|
builtIns := []string{"backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"}
|
|
builtInRank := make(map[string]int)
|
|
for i, key := range builtIns {
|
|
builtInRank[key] = i
|
|
if !seen[key] {
|
|
category, _ := issuestatus.CategoryForBehavior(key)
|
|
rows = append(rows, db.IssueStatus{Key: key, Category: category, IsSystem: true})
|
|
}
|
|
}
|
|
categoryRank := map[string]int{"unstarted": 0, "started": 1, "done": 2, "closed": 3}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
a, b := rows[i], rows[j]
|
|
if categoryRank[a.Category] != categoryRank[b.Category] {
|
|
return categoryRank[a.Category] < categoryRank[b.Category]
|
|
}
|
|
if a.Position != b.Position {
|
|
return a.Position < b.Position
|
|
}
|
|
if a.IsSystem != b.IsSystem {
|
|
return a.IsSystem
|
|
}
|
|
if a.IsSystem && builtInRank[a.Key] != builtInRank[b.Key] {
|
|
return builtInRank[a.Key] < builtInRank[b.Key]
|
|
}
|
|
return a.Key < b.Key
|
|
})
|
|
keys := make([]string, len(rows))
|
|
for i, row := range rows {
|
|
keys[i] = row.Key
|
|
}
|
|
return keys
|
|
}
|
|
|
|
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 fmt.Sprintf("COALESCE(array_position(%s::text[], group_value), 100000)", addArg(group.statusOrder))
|
|
case "status_category":
|
|
return statusOrderExpression("group_value")
|
|
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)
|
|
}
|
|
|
|
const statusCategoryGroupKeyPrefix = "status_category:"
|
|
|
|
func statusCategoryGroupKey(category string) string {
|
|
return statusCategoryGroupKeyPrefix + category
|
|
}
|
|
|
|
func parseStatusCategoryGroupKey(key string) (string, bool) {
|
|
if !strings.HasPrefix(key, statusCategoryGroupKeyPrefix) {
|
|
return "", false
|
|
}
|
|
category := strings.TrimPrefix(key, statusCategoryGroupKeyPrefix)
|
|
return issuestatus.ParseCategory(category)
|
|
}
|
|
|
|
// categoryKeysFor returns the concrete status keys in a category. Its fallback
|
|
// contains the category's built-in behaviors, so a failed catalog read still
|
|
// produces correct default columns.
|
|
func (group resolvedIssueTableGroup) categoryKeysFor(category string) []string {
|
|
if keys := group.categoryKeys[category]; len(keys) > 0 {
|
|
return keys
|
|
}
|
|
return issuestatus.BehaviorsForCategory(category)
|
|
}
|
|
|
|
func compoundCellGroupKey(primaryKey, status string, category bool) string {
|
|
axis := ":status:"
|
|
if category {
|
|
axis = ":status_category:"
|
|
}
|
|
return "compound:" + base64.RawURLEncoding.EncodeToString([]byte(primaryKey)) + axis + 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))
|
|
secondaryValues := append([]string(nil), validIssueStatuses...)
|
|
customKeys := make([]string, 0, len(group.statusCustomKeys))
|
|
for key := range group.statusCustomKeys {
|
|
customKeys = append(customKeys, key)
|
|
}
|
|
sort.Strings(customKeys)
|
|
secondaryValues = append(secondaryValues, customKeys...)
|
|
if group.secondaryCategory {
|
|
secondaryValues = validIssueStatusCategories
|
|
}
|
|
for _, status := range secondaryValues {
|
|
statusCount := secondaryCounts[status]
|
|
descriptor.SecondaryGroups = append(descriptor.SecondaryGroups, issueTableGroupDescriptorResponse{
|
|
Key: compoundCellGroupKey(descriptor.Key, status, group.secondaryCategory),
|
|
Value: issueTableGroupValueResponse{
|
|
Kind: "status",
|
|
Status: status,
|
|
},
|
|
Count: statusCount,
|
|
})
|
|
}
|
|
return descriptor, nil
|
|
}
|
|
descriptor := issueTableGroupDescriptorResponse{Count: count}
|
|
switch group.kind {
|
|
case "status":
|
|
// Any non-empty status KEY, not just the 7 built-ins. Since MUL-6243 a
|
|
// workspace can hold custom statuses, and rejecting one here failed the
|
|
// WHOLE grouped response with a 500 — one custom status made "group by
|
|
// status" unusable for the entire workspace.
|
|
if raw == "" {
|
|
return descriptor, fmt.Errorf("unexpected status group value %q", raw)
|
|
}
|
|
descriptor.Key = "status:" + raw
|
|
descriptor.Value = issueTableGroupValueResponse{Kind: "status", Status: raw}
|
|
case "status_category":
|
|
if !issueTableContainsString(validIssueStatusCategories, raw) {
|
|
return descriptor, fmt.Errorf("unexpected status category group value %q", raw)
|
|
}
|
|
descriptor.Key = statusCategoryGroupKey(raw)
|
|
// value.kind stays "status" for compatibility with existing grouped
|
|
// response consumers; the group key identifies this as a category.
|
|
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)
|
|
axis := ":status:"
|
|
if group.secondaryCategory {
|
|
axis = ":status_category:"
|
|
}
|
|
encoded, status, ok := strings.Cut(encodedAndStatus, axis)
|
|
validSecondary := validIssueStatuses
|
|
if group.secondaryCategory {
|
|
validSecondary = validIssueStatusCategories
|
|
if normalized, valid := issuestatus.ParseCategory(status); valid {
|
|
status = normalized
|
|
}
|
|
}
|
|
_, custom := group.statusCustomKeys[status]
|
|
if !ok || (!issueTableContainsString(validSecondary, status) && (group.secondaryCategory || !custom)) {
|
|
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
|
|
}
|
|
if group.secondaryCategory {
|
|
return fmt.Sprintf("(%s) AND i.status = ANY(%s::text[])", primaryPredicate, addArg(group.categoryKeysFor(status))), true
|
|
}
|
|
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:"
|
|
status, found := strings.CutPrefix(key, prefix)
|
|
// A custom status is a valid group here too; the predicate is an exact
|
|
// key match either way. Length-bounded so an arbitrary blob cannot ride
|
|
// in as a status key.
|
|
if !found || status == "" || len(status) > 64 {
|
|
writeError(w, http.StatusBadRequest, "invalid group_key")
|
|
return "", false
|
|
}
|
|
return fmt.Sprintf("i.status = %s::text", addArg(status)), true
|
|
case "status_category":
|
|
category, ok := parseStatusCategoryGroupKey(key)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "invalid group_key")
|
|
return "", false
|
|
}
|
|
// Expanded to concrete keys rather than wrapping the column in a
|
|
// category function: `status = ANY(...)` keeps the (workspace_id,
|
|
// status) index, a function wrapper would force a workspace scan.
|
|
return fmt.Sprintf("i.status = ANY(%s::text[])", addArg(group.categoryKeysFor(category))), 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" {
|
|
// The secondary axis is either the status key itself or the CATEGORY it
|
|
// behaves as. In the category case a custom status counts into the cell
|
|
// of the column it renders in, never a cell of its own. (MUL-6243)
|
|
secondaryExpr := "i.status"
|
|
if group.secondaryCategory {
|
|
secondaryExpr = statusCategoryExpr(group.statusCustomKeys, addArg)
|
|
}
|
|
groupedCTE = fmt.Sprintf(`cells AS (
|
|
SELECT %s AS group_value, %s 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, secondaryExpr, compiled.where)
|
|
if group.secondaryFiltered {
|
|
visibleRef := addArg(group.secondaryValues)
|
|
// promoted_parents matches raw `child.status`, so a category axis has
|
|
// to expand back to concrete keys there; the cell filters compare
|
|
// against secondary_value, which is already a category.
|
|
visibleKeysRef := visibleRef
|
|
if group.secondaryCategory {
|
|
expanded := make([]string, 0, len(group.secondaryValues))
|
|
for _, category := range group.secondaryValues {
|
|
expanded = append(expanded, group.categoryKeysFor(category)...)
|
|
}
|
|
visibleKeysRef = addArg(expanded)
|
|
}
|
|
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[])
|
|
)`, visibleKeysRef)
|
|
}
|
|
groupedCTE = fmt.Sprintf(`membership AS NOT MATERIALIZED (
|
|
SELECT i.*
|
|
FROM issue i
|
|
WHERE %s
|
|
)%s, cells AS (
|
|
SELECT %s AS group_value, %s 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, secondaryExpr, 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)
|
|
}
|
|
|
|
// issueStatusCatalog is the querier catalog reads go through. Defaults to
|
|
// Queries; tests substitute a counting wrapper.
|
|
func (h *Handler) issueStatusCatalog() issuestatus.Querier {
|
|
if h.IssueStatusCatalog != nil {
|
|
return h.IssueStatusCatalog
|
|
}
|
|
return h.Queries
|
|
}
|