mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* perf(issues): resolve a page's duplicate originals in one read (MUL-7349) Filling duplicate_of read each distinct original with its own primary-key lookup, so a page of duplicates of different issues cost one round trip per row. Pages now pass the originals their rows point at to newStatusCategoryFiller, which loads them with one ListIssueRefsInWorkspace read, the same way labelsByIssue loads a page's labels. List, open issues, grouped, search and table rows do this; a response built on its own still resolves its original, so a caller that passes nothing is slower, never wrong. A pointer left on a reopened issue is not looked up at all, and an original that no longer exists is looked up once and left off. Co-authored-by: multica-agent <[email protected]> * fix(issues): stop logging stale duplicate pointers as unmarks (MUL-7349) Servers from before the duplicate column reopened duplicates and deleted originals without clearing the pointer. After an upgrade, the next write to such an issue clears it, and the activity log compared raw pointers, so it logged "removed duplicate mark" for a mark that no longer counted and dropped the real status row in its place. Event publishers now send only live marks: a pointer counts while the issue is cancelled, the same rule reads apply. Separately, the status row is dropped only when a duplicate row was actually written to stand in for it; when the original is gone there is no duplicate row, and the reopen used to leave no activity at all. Co-authored-by: multica-agent <[email protected]> * fix(issues): keep duplicate marks in background issue snapshots (MUL-7349) IssueToMap hardcoded duplicate_of to null on the assumption that no background publisher renders a marked issue. PublishAttachmentsChanged does: a channel /issue with media re-reads the issue after a download of up to 45 seconds, and a mark made in that window was broadcast as null. Clients patch their cache with that snapshot, so the list and board marker disappeared until the next refetch. IssueToMapResolved now resolves duplicate_of the way the HTTP rendering does; only a cancelled issue with a pointer costs a read. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
539 lines
18 KiB
Go
539 lines
18 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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 issueTableRowResponse struct {
|
|
Issue IssueResponse `json:"issue"`
|
|
DirectChildCount int64 `json:"direct_child_count"`
|
|
}
|
|
|
|
type issueTableRowsResponse struct {
|
|
QueryFingerprint string `json:"query_fingerprint"`
|
|
GroupKey *string `json:"group_key"`
|
|
ParentID *string `json:"parent_id"`
|
|
Total int64 `json:"total"`
|
|
Rows []issueTableRowResponse `json:"rows"`
|
|
BranchTotal int64 `json:"branch_total"` // Current page size; retained for response compatibility.
|
|
NextCursor *string `json:"next_cursor"`
|
|
}
|
|
|
|
type resolvedIssueTableSort struct {
|
|
expression string
|
|
direction string
|
|
castType string
|
|
nullsLast bool
|
|
idOnlyTie bool
|
|
}
|
|
|
|
func (sort resolvedIssueTableSort) orderBy() string {
|
|
orderBy := sort.expression + " " + strings.ToUpper(sort.direction)
|
|
if sort.nullsLast {
|
|
orderBy += " NULLS LAST"
|
|
}
|
|
if sort.idOnlyTie {
|
|
return orderBy + ", i.id DESC"
|
|
}
|
|
return orderBy + ", i.created_at DESC, i.id DESC"
|
|
}
|
|
|
|
func (sort resolvedIssueTableSort) cursorPredicate(w http.ResponseWriter, cursor *issueTableCursor, addArg func(any) string) (string, bool) {
|
|
if cursor == nil {
|
|
return "TRUE", true
|
|
}
|
|
if cursor.RowID == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid cursor")
|
|
return "", false
|
|
}
|
|
rowID, err := util.ParseUUID(cursor.RowID)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid cursor")
|
|
return "", false
|
|
}
|
|
idRef := addArg(rowID)
|
|
tie := fmt.Sprintf("i.id < %s::uuid", idRef)
|
|
if !sort.idOnlyTie {
|
|
createdAt, err := time.Parse(time.RFC3339Nano, cursor.RowCreatedAt)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid cursor")
|
|
return "", false
|
|
}
|
|
createdRef := addArg(createdAt)
|
|
tie = fmt.Sprintf("(i.created_at < %s::timestamptz OR (i.created_at = %s::timestamptz AND i.id < %s::uuid))", createdRef, createdRef, idRef)
|
|
}
|
|
if cursor.SortIsNull {
|
|
if !sort.nullsLast || cursor.SortValue != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid cursor")
|
|
return "", false
|
|
}
|
|
return fmt.Sprintf("(%s IS NULL AND %s)", sort.expression, tie), true
|
|
}
|
|
if cursor.SortValue == nil {
|
|
writeError(w, http.StatusBadRequest, "invalid cursor")
|
|
return "", false
|
|
}
|
|
switch sort.castType {
|
|
case "integer":
|
|
if _, err := strconv.ParseInt(*cursor.SortValue, 10, 64); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid cursor")
|
|
return "", false
|
|
}
|
|
case "double precision", "numeric":
|
|
if _, err := strconv.ParseFloat(*cursor.SortValue, 64); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid cursor")
|
|
return "", false
|
|
}
|
|
case "timestamptz":
|
|
if _, err := time.Parse(time.RFC3339Nano, *cursor.SortValue); err != nil {
|
|
if _, postgresErr := time.Parse("2006-01-02 15:04:05.999999999Z07", *cursor.SortValue); postgresErr != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid cursor")
|
|
return "", false
|
|
}
|
|
}
|
|
case "date":
|
|
if _, err := time.Parse("2006-01-02", *cursor.SortValue); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid cursor")
|
|
return "", false
|
|
}
|
|
}
|
|
valueRef := addArg(*cursor.SortValue)
|
|
valueExpr := fmt.Sprintf("%s::%s", valueRef, sort.castType)
|
|
comparison := ">"
|
|
if sort.direction == "desc" {
|
|
comparison = "<"
|
|
}
|
|
predicate := fmt.Sprintf("(%s %s %s OR (%s = %s AND %s))", sort.expression, comparison, valueExpr, sort.expression, valueExpr, tie)
|
|
if sort.expression == "i.position" {
|
|
// The exact mixed-direction keyset predicate is not itself indexable.
|
|
// This redundant lower bound lets PostgreSQL start the default position
|
|
// index at the cursor instead of filtering every preceding index entry.
|
|
predicate = fmt.Sprintf("(%s >= %s AND %s)", sort.expression, valueExpr, predicate)
|
|
}
|
|
if sort.nullsLast {
|
|
predicate = fmt.Sprintf("(%s IS NULL OR %s)", sort.expression, predicate)
|
|
}
|
|
return predicate, true
|
|
}
|
|
|
|
func (h *Handler) issueTableOrderBy(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
workspaceID pgtype.UUID,
|
|
sortRequest issueTableSortRequest,
|
|
addArg func(any) string,
|
|
) (resolvedIssueTableSort, bool) {
|
|
sortField := strings.TrimSpace(sortRequest.Field)
|
|
if sortField == "" {
|
|
sortField = "position"
|
|
}
|
|
resolved := resolvedIssueTableSort{
|
|
expression: "i.position",
|
|
direction: "asc",
|
|
castType: "double precision",
|
|
}
|
|
switch sortField {
|
|
case "position":
|
|
case "title":
|
|
resolved.expression = "i.title"
|
|
resolved.castType = "text"
|
|
case "created_at", "updated_at":
|
|
resolved.expression = "i." + sortField
|
|
resolved.castType = "timestamptz"
|
|
case "last_activity":
|
|
resolved.expression = "i.last_activity_at"
|
|
resolved.castType = "timestamptz"
|
|
resolved.nullsLast = true
|
|
resolved.idOnlyTie = true
|
|
case "start_date", "due_date":
|
|
resolved.expression = "i." + sortField
|
|
resolved.castType = "date"
|
|
resolved.nullsLast = true
|
|
case "status":
|
|
expr, err := h.issueStatusSortExpression(r.Context(), workspaceID, addArg)
|
|
if err != nil {
|
|
slog.Warn("resolve table status sort failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to resolve table sort")
|
|
return resolvedIssueTableSort{}, false
|
|
}
|
|
resolved.expression = expr
|
|
resolved.castType = "integer"
|
|
case "priority":
|
|
resolved.expression = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END"
|
|
resolved.castType = "integer"
|
|
default:
|
|
expr, handled, err := h.propertySortExpr(r, util.UUIDToString(workspaceID), sortField)
|
|
if !handled {
|
|
writeError(w, http.StatusBadRequest, "invalid query.sort.field")
|
|
return resolvedIssueTableSort{}, false
|
|
}
|
|
if err != nil {
|
|
if strings.HasPrefix(err.Error(), "resolve sort property:") {
|
|
slog.Warn("resolve table sort property failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to resolve table sort")
|
|
} else {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
}
|
|
return resolvedIssueTableSort{}, false
|
|
}
|
|
if expr != "" {
|
|
resolved.expression = expr
|
|
resolved.castType = "text"
|
|
if strings.Contains(expr, "::numeric") {
|
|
resolved.castType = "numeric"
|
|
}
|
|
resolved.nullsLast = true
|
|
}
|
|
}
|
|
|
|
direction := strings.ToLower(strings.TrimSpace(sortRequest.Direction))
|
|
if direction == "" {
|
|
direction = "asc"
|
|
if resolved.idOnlyTie {
|
|
direction = "desc"
|
|
}
|
|
}
|
|
if direction != "asc" && direction != "desc" {
|
|
writeError(w, http.StatusBadRequest, "invalid query.sort.direction")
|
|
return resolvedIssueTableSort{}, false
|
|
}
|
|
if sortField != "position" && resolved.expression != "i.position" {
|
|
resolved.direction = direction
|
|
}
|
|
return resolved, true
|
|
}
|
|
|
|
func normalizeIssueTableGroupKey(w http.ResponseWriter, group issueTableGroupSpec, groupKey *string) (*string, bool) {
|
|
if group.Kind == "none" {
|
|
if groupKey != nil && strings.TrimSpace(*groupKey) != "" {
|
|
writeError(w, http.StatusBadRequest, "group_key must be empty when group.kind=none")
|
|
return nil, false
|
|
}
|
|
return nil, true
|
|
}
|
|
if groupKey == nil || strings.TrimSpace(*groupKey) == "" {
|
|
writeError(w, http.StatusBadRequest, "group_key is required")
|
|
return nil, false
|
|
}
|
|
normalized := strings.TrimSpace(*groupKey)
|
|
return &normalized, true
|
|
}
|
|
|
|
func (h *Handler) ListIssueTableRows(w http.ResponseWriter, r *http.Request) {
|
|
if h.DB == nil {
|
|
writeError(w, http.StatusInternalServerError, "database is unavailable")
|
|
return
|
|
}
|
|
baseHandler := h
|
|
var request issueTableRowsRequest
|
|
if !decodeIssueTableJSON(w, r, &request) {
|
|
return
|
|
}
|
|
r, cancel := withIssueTableQueryTimeout(r)
|
|
defer cancel()
|
|
snapshot, tx, err := h.beginIssueTableSnapshot(r.Context())
|
|
if err != nil {
|
|
slog.Warn("ListIssueTableRows 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
|
|
}
|
|
groupKey, ok := normalizeIssueTableGroupKey(w, request.Group, request.GroupKey)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !request.Hierarchy.Enabled && request.ParentID != nil {
|
|
writeError(w, http.StatusBadRequest, "parent_id requires hierarchy.enabled=true")
|
|
return
|
|
}
|
|
compiled, ok := h.compileIssueTableQuery(w, r, request.Query)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !issueTableCursorMatches(w, cursor, compiled.fingerprint, groupKey, request.ParentID) {
|
|
return
|
|
}
|
|
branchIdentity := issueTableGroupIdentity(request.Group) + ":hierarchy=" + strconv.FormatBool(request.Hierarchy.Enabled)
|
|
if cursor != nil && cursor.BranchIdentity != branchIdentity {
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": "cursor_query_mismatch",
|
|
"message": "cursor does not belong to this table branch",
|
|
})
|
|
return
|
|
}
|
|
group, ok := h.resolveIssueTableGroup(w, r, compiled.workspaceID, request.Group, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
args := append([]any(nil), compiled.args...)
|
|
addArg := func(value any) string {
|
|
args = append(args, value)
|
|
return "$" + strconv.Itoa(len(args))
|
|
}
|
|
resolvedSort, ok := h.issueTableOrderBy(
|
|
w,
|
|
r,
|
|
compiled.workspaceID,
|
|
request.Query.Sort,
|
|
addArg,
|
|
)
|
|
if !ok {
|
|
return
|
|
}
|
|
predicateKey := ""
|
|
if groupKey != nil {
|
|
predicateKey = *groupKey
|
|
}
|
|
groupPredicate, ok := group.predicate(w, predicateKey, addArg)
|
|
if !ok {
|
|
return
|
|
}
|
|
branchPredicate := "TRUE"
|
|
if request.Hierarchy.Enabled {
|
|
if request.ParentID == nil {
|
|
// Keep parent membership as a scalar lookup. NOT EXISTS is equivalent
|
|
// semantically, but PostgreSQL turns it into a full hash anti-join and
|
|
// loses the ordered workspace index before LIMIT.
|
|
branchPredicate = "i.parent_issue_id IS NULL OR (SELECT parent.id FROM membership parent WHERE parent.id = i.parent_issue_id) IS NULL"
|
|
} else {
|
|
parentUUID, err := util.ParseUUID(*request.ParentID)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid parent_id")
|
|
return
|
|
}
|
|
parentRef := addArg(parentUUID)
|
|
branchPredicate = fmt.Sprintf("i.parent_issue_id = %s::uuid AND EXISTS (SELECT 1 FROM membership parent WHERE parent.id = %s::uuid)", parentRef, parentRef)
|
|
}
|
|
}
|
|
cursorPredicate, ok := resolvedSort.cursorPredicate(w, cursor, addArg)
|
|
if !ok {
|
|
return
|
|
}
|
|
limitRef := addArg(limit + 1)
|
|
|
|
// Pick the requested page before computing hierarchy metadata. The old query
|
|
// materialized every matching issue and aggregated every parent before LIMIT,
|
|
// which made a 51-row page spill the entire workspace membership to disk.
|
|
// NOT MATERIALIZED lets PostgreSQL push parent_id/id predicates into issue and
|
|
// use the parent indexes for child branches and per-page child counts.
|
|
ctePrefix := "WITH "
|
|
pageSource := "issue"
|
|
pagePredicate := fmt.Sprintf("(%s) AND (%s)", compiled.where, groupPredicate)
|
|
if request.Hierarchy.Enabled {
|
|
ctePrefix += fmt.Sprintf(`membership AS NOT MATERIALIZED (
|
|
SELECT i.*
|
|
FROM issue i
|
|
WHERE %s AND (%s)
|
|
), `, compiled.where, groupPredicate)
|
|
pageSource = "membership"
|
|
pagePredicate = branchPredicate
|
|
} else if group.kind == "compound" &&
|
|
group.primary != nil &&
|
|
group.primary.kind == "parent" &&
|
|
group.secondaryFiltered {
|
|
visibleKeys := group.secondaryValues
|
|
if group.secondaryCategory {
|
|
visibleKeys = nil
|
|
for _, category := range group.secondaryValues {
|
|
visibleKeys = append(visibleKeys, group.categoryKeysFor(category)...)
|
|
}
|
|
}
|
|
visibleRef := addArg(visibleKeys)
|
|
ctePrefix += fmt.Sprintf(`membership AS NOT MATERIALIZED (
|
|
SELECT i.*
|
|
FROM issue i
|
|
WHERE %s
|
|
), 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[])
|
|
), `, compiled.where, visibleRef)
|
|
pageSource = "membership"
|
|
pagePredicate = fmt.Sprintf(`(%s) AND NOT (
|
|
i.parent_issue_id IS NULL AND
|
|
EXISTS (SELECT 1 FROM promoted_parents p WHERE p.id = i.id)
|
|
)`, groupPredicate)
|
|
}
|
|
cte := fmt.Sprintf(`%spage AS MATERIALIZED (
|
|
SELECT i.*, (%s)::text AS table_sort_key
|
|
FROM %s i
|
|
WHERE (%s) AND %s
|
|
ORDER BY %s
|
|
LIMIT %s
|
|
)`, ctePrefix, resolvedSort.expression, pageSource, pagePredicate, cursorPredicate, resolvedSort.orderBy(), limitRef)
|
|
childCountExpr := "0::bigint"
|
|
if request.Hierarchy.Enabled {
|
|
childCountExpr = "(SELECT COUNT(*)::bigint FROM membership child WHERE child.parent_issue_id = i.id)"
|
|
}
|
|
|
|
query := fmt.Sprintf(`%s
|
|
SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority,
|
|
i.assignee_type, i.assignee_id, i.creator_type, i.creator_id,
|
|
i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at,
|
|
i.updated_at, i.last_activity_at, i.number, i.project_id, i.metadata, i.stage, i.properties,
|
|
i.revision, i.duplicate_of_issue_id,
|
|
%s AS direct_child_count, i.table_sort_key
|
|
FROM page i
|
|
ORDER BY %s`, cte, childCountExpr, resolvedSort.orderBy())
|
|
|
|
rows, err := h.DB.Query(r.Context(), query, args...)
|
|
if err != nil {
|
|
slog.Warn("ListIssueTableRows query failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to list table rows")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type scannedRow struct {
|
|
issue db.ListIssuesRow
|
|
childCount int64
|
|
sortKey pgtype.Text
|
|
}
|
|
scanned := make([]scannedRow, 0, limit+1)
|
|
for rows.Next() {
|
|
var row scannedRow
|
|
if err := rows.Scan(
|
|
&row.issue.ID,
|
|
&row.issue.WorkspaceID,
|
|
&row.issue.Title,
|
|
&row.issue.Description,
|
|
&row.issue.Status,
|
|
&row.issue.Priority,
|
|
&row.issue.AssigneeType,
|
|
&row.issue.AssigneeID,
|
|
&row.issue.CreatorType,
|
|
&row.issue.CreatorID,
|
|
&row.issue.ParentIssueID,
|
|
&row.issue.Position,
|
|
&row.issue.StartDate,
|
|
&row.issue.DueDate,
|
|
&row.issue.CreatedAt,
|
|
&row.issue.UpdatedAt,
|
|
&row.issue.LastActivityAt,
|
|
&row.issue.Number,
|
|
&row.issue.ProjectID,
|
|
&row.issue.Metadata,
|
|
&row.issue.Stage,
|
|
&row.issue.Properties,
|
|
&row.issue.Revision,
|
|
&row.issue.DuplicateOfIssueID,
|
|
&row.childCount,
|
|
&row.sortKey,
|
|
); err != nil {
|
|
writeIssueTableQueryFailure(w, r, "failed to list table rows")
|
|
return
|
|
}
|
|
scanned = append(scanned, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
writeIssueTableQueryFailure(w, r, "failed to list table rows")
|
|
return
|
|
}
|
|
rows.Close()
|
|
|
|
// Only the ungrouped root head consumes a query-wide total. Group headers
|
|
// get their exact totals from /groups; child branches and continuation pages
|
|
// must not pay for a full-membership COUNT.
|
|
var total int64
|
|
if cursor == nil && request.Group.Kind == "none" && request.ParentID == nil {
|
|
if err := h.DB.QueryRow(r.Context(), fmt.Sprintf("SELECT COUNT(*)::bigint FROM issue i WHERE %s", compiled.where), compiled.args...).Scan(&total); err != nil {
|
|
slog.Warn("ListIssueTableRows total count failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to count table rows")
|
|
return
|
|
}
|
|
}
|
|
|
|
var nextCursor *string
|
|
if len(scanned) > limit {
|
|
scanned = scanned[:limit]
|
|
last := scanned[len(scanned)-1]
|
|
var sortValue *string
|
|
if last.sortKey.Valid {
|
|
value := last.sortKey.String
|
|
sortValue = &value
|
|
}
|
|
nextCursor = encodeIssueTableCursor(issueTableCursor{
|
|
Version: 1,
|
|
QueryFingerprint: compiled.fingerprint,
|
|
GroupKey: groupKey,
|
|
ParentID: request.ParentID,
|
|
BranchIdentity: branchIdentity,
|
|
SortValue: sortValue,
|
|
SortIsNull: !last.sortKey.Valid,
|
|
RowCreatedAt: last.issue.CreatedAt.Time.UTC().Format(time.RFC3339Nano),
|
|
RowID: util.UUIDToString(last.issue.ID),
|
|
})
|
|
}
|
|
|
|
// The row window, optional root total and cursor are the authoritative snapshot. Commit
|
|
// it before the best-effort display enrichment below: PostgreSQL aborts a
|
|
// transaction after any statement error, so running getIssuePrefix or
|
|
// labelsByIssue inside the snapshot would turn their intentionally tolerated
|
|
// failures into a fatal Commit error for the entire page.
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
slog.Warn("ListIssueTableRows snapshot commit failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to finish table query")
|
|
return
|
|
}
|
|
committed = true
|
|
|
|
prefix := baseHandler.getIssuePrefix(r.Context(), compiled.workspaceID)
|
|
issueIDs := make([]pgtype.UUID, len(scanned))
|
|
var originals []pgtype.UUID
|
|
for index, row := range scanned {
|
|
issueIDs[index] = row.issue.ID
|
|
originals = appendDuplicateOriginal(originals, row.issue.Status, row.issue.DuplicateOfIssueID)
|
|
}
|
|
labelsByIssue := baseHandler.labelsByIssue(r.Context(), compiled.workspaceID, issueIDs)
|
|
// One Resolver and one originals read for the page — see
|
|
// newStatusCategoryFiller. (MUL-6243, MUL-7349)
|
|
fillTableRow := baseHandler.newStatusCategoryFiller(r.Context(), compiled.workspaceID, originals...)
|
|
responseRows := make([]issueTableRowResponse, len(scanned))
|
|
for index, row := range scanned {
|
|
issue := issueListRowToResponse(row.issue, prefix)
|
|
fillTableRow(&issue)
|
|
labels := labelsByIssue[issue.ID]
|
|
if labels == nil {
|
|
labels = []LabelResponse{}
|
|
}
|
|
issue.Labels = &labels
|
|
responseRows[index] = issueTableRowResponse{
|
|
Issue: issue,
|
|
DirectChildCount: row.childCount,
|
|
}
|
|
}
|
|
|
|
response := issueTableRowsResponse{
|
|
QueryFingerprint: compiled.fingerprint,
|
|
GroupKey: groupKey,
|
|
ParentID: request.ParentID,
|
|
Total: total,
|
|
Rows: responseRows,
|
|
BranchTotal: int64(len(responseRows)),
|
|
NextCursor: nextCursor,
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|