mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
- issueworkflow.Brief is the only renderer: the claim carries it into every run's per-turn prompt; the editor preview and timeline show the same text. - Workflow handoffs stop writing the brief into handoff_note, so a person's note no longer drops the brief and the handoff framing fits again. - Agents that are not the issue's assignee get an observer brief without the step's commands. - Clearer rule override and narrower wakeup guidance; user text is stripped of control characters. - multica-platform skill documents project workflows. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]>
5033 lines
186 KiB
Go
5033 lines
186 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/channelmedia"
|
|
"github.com/multica-ai/multica/server/internal/dispatch"
|
|
"github.com/multica-ai/multica/server/internal/issueguard"
|
|
"github.com/multica-ai/multica/server/internal/issuestatus"
|
|
"github.com/multica-ai/multica/server/internal/issueworkflow"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
agentpkg "github.com/multica-ai/multica/server/pkg/agent"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// IssueResponse is the JSON response for an issue.
|
|
type IssueResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Number int32 `json:"number"`
|
|
Identifier string `json:"identifier"`
|
|
Title string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Status string `json:"status"`
|
|
// StatusCategory encodes lifecycle using the legacy seven-value wire enum. It is
|
|
// omitted when an endpoint cannot resolve a custom status, so consumers must
|
|
// fall back to their catalog rather than treat a blank as "no category".
|
|
StatusCategory string `json:"status_category,omitempty"`
|
|
// StatusName is a CUSTOM status's display name, carried beside the key so a
|
|
// consumer that only ever sees `status` is not left holding a bare handle.
|
|
// A key derived from a non-Latin name is opaque by construction
|
|
// (`in_review_2`), and an agent reading an issue has nothing else to match
|
|
// against the status a human named for it.
|
|
//
|
|
// Always emitted, unlike StatusCategory. Empty is a MEANING here — "this is
|
|
// a built-in, localize it from the key" — not the "this endpoint did not
|
|
// resolve it" that an absent status_category signals. Keeping the key
|
|
// present is also what lets TestIssueToMap_KeysMatchIssueResponse see the
|
|
// field at all: with omitempty a built-in fixture hides it from BOTH
|
|
// renderings, and the drift guard goes green on a payload that has drifted.
|
|
// (MUL-6749)
|
|
StatusName string `json:"status_name"`
|
|
Priority string `json:"priority"`
|
|
AssigneeType *string `json:"assignee_type"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
CreatorType string `json:"creator_type"`
|
|
CreatorID string `json:"creator_id"`
|
|
ParentIssueID *string `json:"parent_issue_id"`
|
|
// DuplicateOf is the original this issue duplicates (MUL-7349), set only
|
|
// while the mark counts: the issue is cancelled and the original still
|
|
// exists. newStatusCategoryFiller resolves it from duplicateOfIssueID;
|
|
// an endpoint that skips the filler emits null rather than a bare pointer.
|
|
DuplicateOf *IssueRefResponse `json:"duplicate_of"`
|
|
ProjectID *string `json:"project_id"`
|
|
Position float64 `json:"position"`
|
|
// Stage groups sub-issues under the same parent into ordered barrier
|
|
// groups (null = unstaged). See service/issue_wakeup_system.go for how a
|
|
// closed stage wakes the parent's assignee.
|
|
Stage *int32 `json:"stage"`
|
|
StartDate *string `json:"start_date"`
|
|
DueDate *string `json:"due_date"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Revision int64 `json:"revision"`
|
|
// LastActivityAt is the latest semantic issue activity. It stays nullable
|
|
// while the operator-run historical backfill is incomplete.
|
|
LastActivityAt *string `json:"last_activity_at"`
|
|
// Metadata is the per-issue KV map (see issue_metadata.go). Always emitted
|
|
// (empty object when unset) so frontend code can `issue.metadata[key]`
|
|
// without nil-guarding the parent field.
|
|
Metadata map[string]any `json:"metadata"`
|
|
// Properties is the custom-property value bag keyed by property definition
|
|
// UUID (see property.go). Always emitted, mirroring Metadata.
|
|
Properties map[string]any `json:"properties"`
|
|
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
|
|
Attachments []AttachmentResponse `json:"attachments,omitempty"`
|
|
// Labels are bulk-attached by list/detail endpoints so the client can render
|
|
// chips without an N+1 round-trip per row. Pointer + omitempty so paths that
|
|
// don't load labels (e.g. UpdateIssue, batch UpdateIssues, the issue:updated
|
|
// WS broadcast) emit no `labels` field at all — the client merge then
|
|
// preserves whatever labels are already in cache. nil pointer = "field
|
|
// absent, do not touch"; non-nil (incl. empty slice) = authoritative list.
|
|
Labels *[]LabelResponse `json:"labels,omitempty"`
|
|
// SourceContext is detail-only. List, board, search, and children responses
|
|
// deliberately omit the potentially large immutable snapshot.
|
|
SourceContext *sourceContextDetailResponse `json:"source_context,omitempty"`
|
|
// duplicateOfIssueID is the raw mark, kept off the wire; see DuplicateOf.
|
|
duplicateOfIssueID pgtype.UUID
|
|
}
|
|
|
|
// IssueRefResponse names another issue inside a response: enough to render
|
|
// a link and its status without a second request.
|
|
type IssueRefResponse struct {
|
|
ID string `json:"id"`
|
|
Identifier string `json:"identifier"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// validIssuePriorities mirrors the CHECK constraint on the issue table. Write
|
|
// handlers pre-validate it so callers get a clean 400 with the allowed values
|
|
// instead of a database CHECK violation bubbling up as a 500.
|
|
var validIssuePriorities = []string{"urgent", "high", "medium", "low", "none"}
|
|
|
|
// validIssueStatuses contains the built-in status keys. Workspace writes,
|
|
// grouping, and filters also support custom keys from the workspace catalog.
|
|
var validIssueStatuses = issuestatus.Canonical()
|
|
var validIssueStatusCategories = issuestatus.Categories()
|
|
|
|
// Status sort ranks by the CONCRETE status key, in the catalog's own display
|
|
// order. Sorting is a display question, so it resolves on status; only behavior
|
|
// decisions aggregate on the four lifecycle categories (MUL-7379).
|
|
//
|
|
// Ranking by category instead collapses the seven built-ins into four buckets —
|
|
// Backlog ties with Todo, and In Progress / In Review / Blocked all tie — which
|
|
// leaves the created_at tiebreak deciding the visible order of a list the user
|
|
// explicitly asked to sort by status.
|
|
//
|
|
// issueTableStatusOrder is the same "board order" the status GROUPING path
|
|
// already builds, so sorting and grouping now share one source of truth instead
|
|
// of drifting apart. Archived statuses are included because issues stay on them
|
|
// after archival and still have to rank somewhere real.
|
|
//
|
|
// Keeping the indexed column bare avoids the per-row issue_effective_status()
|
|
// call that would otherwise turn a bounded page into a workspace scan.
|
|
func (h *Handler) issueStatusSortExpression(
|
|
ctx context.Context,
|
|
workspaceID pgtype.UUID,
|
|
addArg func(any) string,
|
|
) (string, error) {
|
|
entries, err := h.issueStatusCatalog().ListIssueStatusEntries(ctx, db.ListIssueStatusEntriesParams{
|
|
WorkspaceID: workspaceID,
|
|
IncludeArchived: true,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf(
|
|
"COALESCE(array_position(%s::text[], i.status), 100000)",
|
|
addArg(issueTableStatusOrder(entries)),
|
|
), nil
|
|
}
|
|
|
|
// resolveIssueStatusKey checks a status against the workspace's catalog and
|
|
// returns the CANONICAL key to store. This is the application-layer replacement
|
|
// for the enum CHECK that migration 337 dropped, so every write path must route
|
|
// through it — a missed entrypoint is how an unresolvable key would reach the
|
|
// column.
|
|
//
|
|
// Returning the resolved key (rather than a bare bool) is load-bearing:
|
|
// resolution is case- and whitespace-insensitive, so `" HUMAN_REVIEW "` and
|
|
// `"human_review"` both validate. Writing the caller's raw string back would
|
|
// then store a value the column's format constraint rejects, turning an input
|
|
// the API just accepted into a 500. Callers must persist what this returns.
|
|
func (h *Handler) resolveIssueStatusKey(w http.ResponseWriter, r *http.Request, workspaceID pgtype.UUID, status string) (string, bool) {
|
|
key, _, ok := h.resolveIssueStatusKeyKind(w, r, workspaceID, status)
|
|
return key, ok
|
|
}
|
|
|
|
// resolveIssueStatusKeyKind is resolveIssueStatusKey plus whether the target is
|
|
// a CUSTOM status. Callers use that to decide whether the write needs the
|
|
// shared catalog lock — see runWithIssueStatusGuard.
|
|
func (h *Handler) resolveIssueStatusKeyKind(w http.ResponseWriter, r *http.Request, workspaceID pgtype.UUID, status string) (string, bool, bool) {
|
|
entry, err := issuestatus.Resolve(r.Context(), h.Queries, workspaceID, status)
|
|
if err != nil {
|
|
if errors.Is(err, issuestatus.ErrUnknownStatus) {
|
|
// Labels, not bare keys: a derived key says nothing about what the
|
|
// status means, so listing `in_review_2` alone leaves the caller no
|
|
// way to find the one they were told to use. (MUL-6749)
|
|
allowed, listErr := issuestatus.ActiveKeyLabels(r.Context(), h.Queries, workspaceID)
|
|
if listErr != nil || len(allowed) == 0 {
|
|
allowed = issuestatus.Canonical()
|
|
}
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf(
|
|
"invalid status %q; valid values: %s", status, strings.Join(allowed, ", ")))
|
|
return "", false, false
|
|
}
|
|
slog.Warn("resolve issue status failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to validate status")
|
|
return "", false, false
|
|
}
|
|
return entry.Key, !entry.IsSystem, true
|
|
}
|
|
|
|
// errIssueStatusArchivedRace signals that the target custom status was archived
|
|
// between the request's pre-flight validation and the write itself. Callers map
|
|
// it to 409: the request was valid when it arrived, and retrying against the
|
|
// refreshed catalog is the right remedy.
|
|
var errIssueStatusArchivedRace = errors.New("issue status was archived while the write was in flight")
|
|
|
|
// assertIssueStatusStillActive is the write half of the archive race guard. It
|
|
// takes the SHARED catalog lock and RE-RESOLVES the status inside the caller's
|
|
// transaction.
|
|
//
|
|
// The re-resolve is the part that actually closes the race. Handlers resolve a
|
|
// status up front, before any transaction, to answer a bad request with a clean
|
|
// 400 — but an archive can commit between that pre-flight check and the write.
|
|
// Re-checking here, under the lock, means the status is provably active at the
|
|
// moment the row is written. ArchiveIssueStatus holds the EXCLUSIVE side around
|
|
// retirement, so the two orderings are both covered:
|
|
//
|
|
// - archive first: it commits, this re-resolve then fails and the write is
|
|
// rejected, so no new assignment lands on an archived status;
|
|
// - writer first: archive blocks until this transaction commits, then its
|
|
// issue count rejects archival of the now-occupied status.
|
|
//
|
|
// A built-in status is a no-op: it can never be archived (enforced by
|
|
// issue_status_system_not_archivable), so the common path takes no lock and
|
|
// pays nothing. (MUL-6243)
|
|
func assertIssueStatusStillActive(ctx context.Context, qtx *db.Queries, workspaceID pgtype.UUID, statusKey string) error {
|
|
if statusKey == "" || issuestatus.IsBuiltIn(statusKey) {
|
|
return nil
|
|
}
|
|
// Catalog lock before any row lock, everywhere, so the two write paths
|
|
// cannot deadlock against each other.
|
|
if err := qtx.LockIssueStatusCatalogShared(ctx, workspaceID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := issuestatus.Resolve(ctx, qtx, workspaceID, statusKey); err != nil {
|
|
if errors.Is(err, issuestatus.ErrUnknownStatus) {
|
|
return errIssueStatusArchivedRace
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runWithIssueStatusGuard runs an issue write that lands on a custom status
|
|
// inside a transaction that re-verifies the status under the shared catalog
|
|
// lock (see assertIssueStatusStillActive). Request writes also carry trusted
|
|
// wakeup actor identity in transaction-local settings, including built-in targets.
|
|
func (h *Handler) runWithIssueStatusGuard(ctx context.Context, workspaceID pgtype.UUID, statusKey string, fn func(q *db.Queries) error) error {
|
|
_, hasActor := ctx.Value(wakeupActorKey{}).(wakeupActor)
|
|
if !hasActor && (statusKey == "" || issuestatus.IsBuiltIn(statusKey)) {
|
|
return fn(h.Queries)
|
|
}
|
|
tx, err := h.beginWakeupWrite(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
qtx := h.Queries.WithTx(tx)
|
|
if err := assertIssueStatusStillActive(ctx, qtx, workspaceID, statusKey); err != nil {
|
|
return err
|
|
}
|
|
if err := fn(qtx); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// updateIssueWithStatusGuard writes params under the status-archive guard.
|
|
func (h *Handler) updateIssueWithStatusGuard(ctx context.Context, workspaceID pgtype.UUID, statusKey string, params db.UpdateIssueParams) (db.Issue, error) {
|
|
var issue db.Issue
|
|
var cancelledWakeups []db.AgentTaskQueue
|
|
err := h.runWithIssueStatusGuard(ctx, workspaceID, statusKey, func(q *db.Queries) error {
|
|
if params.DuplicateOfIssueID.Valid {
|
|
if err := lockAndCheckDuplicateMark(ctx, q, workspaceID, params.ID, params.DuplicateOfIssueID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var innerErr error
|
|
issue, cancelledWakeups, innerErr = updateIssueStoppingWakeups(ctx, q, params)
|
|
return innerErr
|
|
})
|
|
if err != nil {
|
|
return issue, err
|
|
}
|
|
h.broadcastCancelledWakeups(ctx, workspaceID, cancelledWakeups)
|
|
return issue, nil
|
|
}
|
|
|
|
// updateIssueStoppingWakeups writes params and, when the write moves the issue
|
|
// into a done/closed status, ends its wakeups with the same queries. Callers
|
|
// run it inside the status write's transaction and broadcast the returned runs
|
|
// after commit.
|
|
func updateIssueStoppingWakeups(ctx context.Context, q *db.Queries, params db.UpdateIssueParams) (db.Issue, []db.AgentTaskQueue, error) {
|
|
issue, err := q.UpdateIssue(ctx, params)
|
|
if err != nil || !params.Status.Valid {
|
|
return issue, nil, err
|
|
}
|
|
cancelled, err := service.StopClosedIssueWakeups(ctx, q, issue)
|
|
if err != nil {
|
|
return db.Issue{}, nil, fmt.Errorf("stop closed issue wakeups: %w", err)
|
|
}
|
|
return issue, cancelled, nil
|
|
}
|
|
|
|
func (h *Handler) broadcastCancelledWakeups(ctx context.Context, workspaceID pgtype.UUID, cancelled []db.AgentTaskQueue) {
|
|
if len(cancelled) > 0 {
|
|
h.TaskService.BroadcastCancelledTasks(ctx, uuidToString(workspaceID), cancelled)
|
|
}
|
|
}
|
|
|
|
// writeIssueStatusRaceError renders errIssueStatusArchivedRace as a 409 and
|
|
// reports whether it handled the error.
|
|
func writeIssueStatusRaceError(w http.ResponseWriter, err error) bool {
|
|
if errors.Is(err, errIssueStatusArchivedRace) {
|
|
writeError(w, http.StatusConflict,
|
|
"the target status was archived while this request was in flight; reload the status list and retry")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validateIssueEnum(w http.ResponseWriter, field, value string, allowed []string) bool {
|
|
for _, a := range allowed {
|
|
if value == a {
|
|
return true
|
|
}
|
|
}
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid %s %q; valid values: %s", field, value, strings.Join(allowed, ", ")))
|
|
return false
|
|
}
|
|
|
|
// fillStatusCategories resolves status_category for responses whose status is
|
|
// CUSTOM. The pure builders below map built-ins directly and leave custom
|
|
// statuses empty, so this is the step that makes the
|
|
// field authoritative on every payload a client caches or buckets by.
|
|
//
|
|
// Uses one Resolver for the whole slice: built-in statuses cost no query, and a
|
|
// page full of custom ones costs one catalog read rather than one per row. The
|
|
// Resolver includes ARCHIVED statuses, because an issue left on an archived
|
|
// status still belongs in its category's column. (MUL-6243)
|
|
func (h *Handler) fillStatusCategories(ctx context.Context, wsID pgtype.UUID, resps []IssueResponse) {
|
|
var originals []pgtype.UUID
|
|
for i := range resps {
|
|
originals = appendDuplicateOriginal(originals, resps[i].Status, resps[i].duplicateOfIssueID)
|
|
}
|
|
fill := h.newStatusCategoryFiller(ctx, wsID, originals...)
|
|
for i := range resps {
|
|
fill(&resps[i])
|
|
}
|
|
}
|
|
|
|
// newStatusCategoryFiller returns a request-scoped filler backed by ONE
|
|
// Resolver. Reuse it across every response a request builds: the Resolver reads
|
|
// the catalog at most once, so a page of custom-status rows costs one query
|
|
// rather than one per row. Creating a filler per row would reintroduce the N+1
|
|
// this exists to avoid. (MUL-6243)
|
|
//
|
|
// originals are the duplicate originals the caller's rows point at, collected
|
|
// with appendDuplicateOriginal. A page passes them so they resolve in one read
|
|
// up front, the way labelsByIssue loads a page's labels; an original that was
|
|
// not passed still resolves, one read per distinct original. (MUL-7349)
|
|
func (h *Handler) newStatusCategoryFiller(ctx context.Context, wsID pgtype.UUID, originals ...pgtype.UUID) func(*IssueResponse) {
|
|
resolver := issuestatus.NewResolver(wsID)
|
|
refs := duplicateOriginals{}
|
|
h.loadDuplicateOriginals(ctx, wsID, refs, originals)
|
|
return func(resp *IssueResponse) {
|
|
if resp == nil {
|
|
return
|
|
}
|
|
// Every response that resolves its status also resolves its duplicate
|
|
// mark, so the two never disagree about which endpoints carry them.
|
|
h.fillDuplicateOf(ctx, wsID, resp, refs)
|
|
if resp.StatusCategory != "" {
|
|
return
|
|
}
|
|
resp.StatusCategory = issuestatus.WireCategory(resp.Status, resolver.Category(ctx, h.Queries, resp.Status))
|
|
// Same Resolver, same single catalog read, so the name rides along for
|
|
// free. Built-ins return "" and stay omitted. (MUL-6749)
|
|
resp.StatusName = resolver.Name(ctx, h.Queries, resp.Status)
|
|
}
|
|
}
|
|
|
|
// fillStatusCategory is the single-response form. Only for endpoints that build
|
|
// exactly ONE response; anything looping must use newStatusCategoryFiller.
|
|
func (h *Handler) fillStatusCategory(ctx context.Context, wsID pgtype.UUID, resp *IssueResponse) {
|
|
h.newStatusCategoryFiller(ctx, wsID)(resp)
|
|
}
|
|
|
|
// duplicateOfPointer keeps a duplicate mark only while the issue is
|
|
// cancelled. Whether the original still exists is checked when the response
|
|
// is filled (fillDuplicateOf), so a pointer an older server left behind
|
|
// never surfaces. Same rule as IssueHasDuplicates in issue.sql.
|
|
func duplicateOfPointer(status string, id pgtype.UUID) pgtype.UUID {
|
|
if status != issuestatus.Cancelled {
|
|
return pgtype.UUID{}
|
|
}
|
|
return id
|
|
}
|
|
|
|
// duplicateOriginals memoises the originals a request's duplicates point at,
|
|
// by id. A nil entry is an original that no longer exists (or could not be
|
|
// read), so it is looked up once and then left off every response.
|
|
type duplicateOriginals map[pgtype.UUID]*db.ListIssueRefsInWorkspaceRow
|
|
|
|
// appendDuplicateOriginal adds the original a row points at, if the row
|
|
// carries a live duplicate mark.
|
|
func appendDuplicateOriginal(originals []pgtype.UUID, status string, id pgtype.UUID) []pgtype.UUID {
|
|
if pointer := duplicateOfPointer(status, id); pointer.Valid {
|
|
return append(originals, pointer)
|
|
}
|
|
return originals
|
|
}
|
|
|
|
// loadDuplicateOriginals reads every id not yet in refs in one query.
|
|
func (h *Handler) loadDuplicateOriginals(ctx context.Context, wsID pgtype.UUID, refs duplicateOriginals, ids []pgtype.UUID) {
|
|
var missing []pgtype.UUID
|
|
for _, id := range ids {
|
|
if _, seen := refs[id]; !seen {
|
|
refs[id] = nil
|
|
missing = append(missing, id)
|
|
}
|
|
}
|
|
if len(missing) == 0 {
|
|
return
|
|
}
|
|
rows, err := h.Queries.ListIssueRefsInWorkspace(ctx, db.ListIssueRefsInWorkspaceParams{
|
|
WorkspaceID: wsID,
|
|
Ids: missing,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("resolve duplicate originals failed", "error", err, "count", len(missing))
|
|
return
|
|
}
|
|
for i := range rows {
|
|
refs[rows[i].ID] = &rows[i]
|
|
}
|
|
}
|
|
|
|
// fillDuplicateOf resolves a duplicate mark to its original (MUL-7349),
|
|
// through refs so a page's originals cost one read (see
|
|
// newStatusCategoryFiller). A missing original leaves DuplicateOf unset.
|
|
func (h *Handler) fillDuplicateOf(ctx context.Context, wsID pgtype.UUID, resp *IssueResponse, refs duplicateOriginals) {
|
|
id := resp.duplicateOfIssueID
|
|
if !id.Valid {
|
|
return
|
|
}
|
|
h.loadDuplicateOriginals(ctx, wsID, refs, []pgtype.UUID{id})
|
|
row := refs[id]
|
|
if row == nil {
|
|
return
|
|
}
|
|
// The response already rendered its own identifier with the workspace
|
|
// prefix; the original shares it.
|
|
prefix := strings.TrimSuffix(resp.Identifier, "-"+strconv.Itoa(int(resp.Number)))
|
|
resp.DuplicateOf = &IssueRefResponse{
|
|
ID: uuidToString(row.ID),
|
|
Identifier: prefix + "-" + strconv.Itoa(int(row.Number)),
|
|
Title: row.Title,
|
|
Status: row.Status,
|
|
}
|
|
}
|
|
|
|
func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
|
|
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
|
|
// Built-ins map to public categories without a catalog lookup. A custom
|
|
// status is filled by endpoints that resolve the workspace catalog.
|
|
statusCategory := ""
|
|
if issuestatus.IsBuiltIn(i.Status) {
|
|
statusCategory = i.Status
|
|
}
|
|
return IssueResponse{
|
|
ID: uuidToString(i.ID),
|
|
WorkspaceID: uuidToString(i.WorkspaceID),
|
|
Number: i.Number,
|
|
Identifier: identifier,
|
|
Title: i.Title,
|
|
Description: textToPtr(i.Description),
|
|
Status: i.Status,
|
|
StatusCategory: statusCategory,
|
|
Priority: i.Priority,
|
|
AssigneeType: textToPtr(i.AssigneeType),
|
|
AssigneeID: uuidToPtr(i.AssigneeID),
|
|
CreatorType: i.CreatorType,
|
|
CreatorID: uuidToString(i.CreatorID),
|
|
ParentIssueID: uuidToPtr(i.ParentIssueID),
|
|
duplicateOfIssueID: duplicateOfPointer(i.Status, i.DuplicateOfIssueID),
|
|
ProjectID: uuidToPtr(i.ProjectID),
|
|
Position: i.Position,
|
|
Stage: int4ToPtr(i.Stage),
|
|
StartDate: dateToPtr(i.StartDate),
|
|
DueDate: dateToPtr(i.DueDate),
|
|
CreatedAt: timestampToString(i.CreatedAt),
|
|
UpdatedAt: timestampToString(i.UpdatedAt),
|
|
Revision: i.Revision,
|
|
LastActivityAt: timestampToNanoPtr(i.LastActivityAt),
|
|
Metadata: parseIssueMetadata(i.Metadata),
|
|
Properties: parseIssueProperties(i.Properties),
|
|
}
|
|
}
|
|
|
|
// issueListRowToResponse converts a list-query row (no description) to an IssueResponse.
|
|
func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueResponse {
|
|
// Same pure built-in resolution as issueToResponse. (MUL-6243)
|
|
statusCategory := ""
|
|
if issuestatus.IsBuiltIn(i.Status) {
|
|
statusCategory = i.Status
|
|
}
|
|
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
|
|
return IssueResponse{
|
|
ID: uuidToString(i.ID),
|
|
WorkspaceID: uuidToString(i.WorkspaceID),
|
|
Number: i.Number,
|
|
Identifier: identifier,
|
|
Title: i.Title,
|
|
Description: textToPtr(i.Description),
|
|
Status: i.Status,
|
|
StatusCategory: statusCategory,
|
|
Priority: i.Priority,
|
|
AssigneeType: textToPtr(i.AssigneeType),
|
|
AssigneeID: uuidToPtr(i.AssigneeID),
|
|
CreatorType: i.CreatorType,
|
|
CreatorID: uuidToString(i.CreatorID),
|
|
ParentIssueID: uuidToPtr(i.ParentIssueID),
|
|
duplicateOfIssueID: duplicateOfPointer(i.Status, i.DuplicateOfIssueID),
|
|
ProjectID: uuidToPtr(i.ProjectID),
|
|
Position: i.Position,
|
|
Stage: int4ToPtr(i.Stage),
|
|
StartDate: dateToPtr(i.StartDate),
|
|
DueDate: dateToPtr(i.DueDate),
|
|
CreatedAt: timestampToString(i.CreatedAt),
|
|
UpdatedAt: timestampToString(i.UpdatedAt),
|
|
Revision: i.Revision,
|
|
LastActivityAt: timestampToNanoPtr(i.LastActivityAt),
|
|
Metadata: parseIssueMetadata(i.Metadata),
|
|
Properties: parseIssueProperties(i.Properties),
|
|
}
|
|
}
|
|
|
|
// labelsByIssue bulk-loads labels for the given issue IDs and returns a map
|
|
// keyed by issue UUID string. On error or empty input, returns an empty map —
|
|
// label rendering is non-critical and we'd rather serve issues without labels
|
|
// than fail the whole list call.
|
|
func (h *Handler) labelsByIssue(ctx context.Context, wsUUID pgtype.UUID, issueIDs []pgtype.UUID) map[string][]LabelResponse {
|
|
out := map[string][]LabelResponse{}
|
|
if len(issueIDs) == 0 {
|
|
return out
|
|
}
|
|
rows, err := h.Queries.ListLabelsForIssues(ctx, db.ListLabelsForIssuesParams{
|
|
IssueIds: issueIDs,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("ListLabelsForIssues failed", "error", err)
|
|
return out
|
|
}
|
|
for _, r := range rows {
|
|
issueID := uuidToString(r.IssueID)
|
|
out[issueID] = append(out[issueID], LabelResponse{
|
|
ID: uuidToString(r.ID),
|
|
WorkspaceID: uuidToString(r.WorkspaceID),
|
|
ResourceType: r.ResourceType,
|
|
Name: r.Name,
|
|
Description: r.Description,
|
|
Color: r.Color,
|
|
CreatedAt: timestampToString(r.CreatedAt),
|
|
UpdatedAt: timestampToString(r.UpdatedAt),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueResponse {
|
|
// Same pure built-in resolution as issueToResponse. (MUL-6243)
|
|
statusCategory := ""
|
|
if issuestatus.IsBuiltIn(i.Status) {
|
|
statusCategory = i.Status
|
|
}
|
|
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
|
|
return IssueResponse{
|
|
ID: uuidToString(i.ID),
|
|
WorkspaceID: uuidToString(i.WorkspaceID),
|
|
Number: i.Number,
|
|
Identifier: identifier,
|
|
Title: i.Title,
|
|
Description: textToPtr(i.Description),
|
|
Status: i.Status,
|
|
StatusCategory: statusCategory,
|
|
Priority: i.Priority,
|
|
AssigneeType: textToPtr(i.AssigneeType),
|
|
AssigneeID: uuidToPtr(i.AssigneeID),
|
|
CreatorType: i.CreatorType,
|
|
CreatorID: uuidToString(i.CreatorID),
|
|
ParentIssueID: uuidToPtr(i.ParentIssueID),
|
|
duplicateOfIssueID: duplicateOfPointer(i.Status, i.DuplicateOfIssueID),
|
|
ProjectID: uuidToPtr(i.ProjectID),
|
|
Position: i.Position,
|
|
Stage: int4ToPtr(i.Stage),
|
|
StartDate: dateToPtr(i.StartDate),
|
|
DueDate: dateToPtr(i.DueDate),
|
|
CreatedAt: timestampToString(i.CreatedAt),
|
|
UpdatedAt: timestampToString(i.UpdatedAt),
|
|
Revision: i.Revision,
|
|
LastActivityAt: timestampToNanoPtr(i.LastActivityAt),
|
|
Metadata: parseIssueMetadata(i.Metadata),
|
|
Properties: parseIssueProperties(i.Properties),
|
|
}
|
|
}
|
|
|
|
type IssueAssigneeGroupResponse struct {
|
|
ID string `json:"id"`
|
|
AssigneeType *string `json:"assignee_type"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
Issues []IssueResponse `json:"issues"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
type GroupedIssuesResponse struct {
|
|
Groups []IssueAssigneeGroupResponse `json:"groups"`
|
|
}
|
|
|
|
type groupedIssueRow struct {
|
|
db.ListIssuesRow
|
|
GroupTotal int64
|
|
}
|
|
|
|
func assigneeGroupID(assigneeType pgtype.Text, assigneeID pgtype.UUID) string {
|
|
if assigneeType.Valid && assigneeID.Valid {
|
|
return "assignee:" + assigneeType.String + ":" + uuidToString(assigneeID)
|
|
}
|
|
return "assignee:unassigned"
|
|
}
|
|
|
|
// SearchIssueResponse extends IssueResponse with search metadata.
|
|
type SearchIssueResponse struct {
|
|
IssueResponse
|
|
MatchSource string `json:"match_source"`
|
|
MatchedSnippet *string `json:"matched_snippet,omitempty"`
|
|
MatchedDescriptionSnippet *string `json:"matched_description_snippet,omitempty"`
|
|
MatchedCommentSnippet *string `json:"matched_comment_snippet,omitempty"`
|
|
}
|
|
|
|
// extractSnippet extracts a snippet of text around the first occurrence of query.
|
|
// Returns up to ~120 runes centered on the match. Uses rune-based slicing to
|
|
// avoid splitting multi-byte UTF-8 characters (important for CJK content).
|
|
// For multi-word queries, tries phrase match first; if not found, locates the
|
|
// earliest occurring individual term and centers the snippet around it.
|
|
func extractSnippet(content, query string) string {
|
|
runes := []rune(content)
|
|
lowerRunes := []rune(strings.ToLower(content))
|
|
queryRunes := []rune(strings.ToLower(query))
|
|
|
|
idx := findRuneSubstring(lowerRunes, queryRunes)
|
|
|
|
// If phrase not found, try individual terms for multi-word queries.
|
|
matchLen := len(queryRunes)
|
|
if idx < 0 {
|
|
terms := strings.Fields(strings.ToLower(query))
|
|
if len(terms) > 1 {
|
|
earliest := -1
|
|
earliestLen := 0
|
|
for _, term := range terms {
|
|
termRunes := []rune(term)
|
|
pos := findRuneSubstring(lowerRunes, termRunes)
|
|
if pos >= 0 && (earliest < 0 || pos < earliest) {
|
|
earliest = pos
|
|
earliestLen = len(termRunes)
|
|
}
|
|
}
|
|
if earliest >= 0 {
|
|
idx = earliest
|
|
matchLen = earliestLen
|
|
}
|
|
}
|
|
}
|
|
|
|
if idx < 0 {
|
|
if len(runes) > 120 {
|
|
return string(runes[:120]) + "..."
|
|
}
|
|
return content
|
|
}
|
|
start := idx - 40
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := idx + matchLen + 80
|
|
if end > len(runes) {
|
|
end = len(runes)
|
|
}
|
|
snippet := string(runes[start:end])
|
|
if start > 0 {
|
|
snippet = "..." + snippet
|
|
}
|
|
if end < len(runes) {
|
|
snippet = snippet + "..."
|
|
}
|
|
return snippet
|
|
}
|
|
|
|
// findRuneSubstring returns the index of needle in haystack, or -1 if not found.
|
|
func findRuneSubstring(haystack, needle []rune) int {
|
|
if len(needle) == 0 || len(haystack) < len(needle) {
|
|
return -1
|
|
}
|
|
for i := 0; i <= len(haystack)-len(needle); i++ {
|
|
match := true
|
|
for j := range needle {
|
|
if haystack[i+j] != needle[j] {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// descriptionContains checks if the description text contains the search phrase or all terms.
|
|
func descriptionContains(desc pgtype.Text, phrase string, terms []string) bool {
|
|
if !desc.Valid || desc.String == "" {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(desc.String)
|
|
if strings.Contains(lower, strings.ToLower(phrase)) {
|
|
return true
|
|
}
|
|
if len(terms) > 1 {
|
|
for _, t := range terms {
|
|
if !strings.Contains(lower, strings.ToLower(t)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// escapeLike escapes LIKE special characters (%, _, \) in user input.
|
|
func escapeLike(s string) string {
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, `%`, `\%`)
|
|
s = strings.ReplaceAll(s, `_`, `\_`)
|
|
return s
|
|
}
|
|
|
|
// splitSearchTerms splits a query into individual search terms, filtering empty strings.
|
|
func splitSearchTerms(q string) []string {
|
|
fields := strings.FieldsFunc(q, func(r rune) bool {
|
|
return unicode.IsSpace(r)
|
|
})
|
|
terms := make([]string, 0, len(fields))
|
|
for _, f := range fields {
|
|
if f != "" {
|
|
terms = append(terms, f)
|
|
}
|
|
}
|
|
return terms
|
|
}
|
|
|
|
// identifierNumberRe matches letter-leading prefixes like "MUL-123" or "V2-12".
|
|
var identifierNumberRe = regexp.MustCompile(`(?i)^[a-z][a-z0-9]*-(\d+)$`)
|
|
|
|
// parseQueryNumber extracts an issue number from the query if it looks like
|
|
// an identifier (e.g. "MUL-123" or "V2-12") or a bare number (e.g. "123").
|
|
func parseQueryNumber(q string) (int, bool) {
|
|
q = strings.TrimSpace(q)
|
|
// Check for identifier pattern like "MUL-123"
|
|
if m := identifierNumberRe.FindStringSubmatch(q); m != nil {
|
|
if n, err := strconv.Atoi(m[1]); err == nil && n > 0 {
|
|
return n, true
|
|
}
|
|
}
|
|
// Check for bare number
|
|
if n, err := strconv.Atoi(q); err == nil && n > 0 {
|
|
return n, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// searchResult holds a raw row from the dynamic search query.
|
|
type searchResult struct {
|
|
issue db.Issue
|
|
matchSource string
|
|
matchedCommentContent string
|
|
}
|
|
|
|
// buildSearchQuery builds a two-stage, workspace-scoped candidate pipeline for issue search.
|
|
// Search patterns are lowercased and escaped in Go so every flag uses the same
|
|
// case-insensitive LIKE semantics as the legacy query. The pipeline deliberately
|
|
// trades the title, description, and comment content GIN fast paths for one
|
|
// predictable pass over each relation within the selected workspace.
|
|
func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, includeClosed bool, terminalStatusKeys []string) (string, []any) {
|
|
// Lowercase in Go so SQL only needs LOWER() on the column side.
|
|
phrase = strings.ToLower(phrase)
|
|
for i, term := range terms {
|
|
terms[i] = strings.ToLower(term)
|
|
}
|
|
|
|
args := []any{}
|
|
nextArg := func(value any) string {
|
|
args = append(args, value)
|
|
return fmt.Sprintf("$%d", len(args))
|
|
}
|
|
|
|
escapedPhrase := escapeLike(phrase)
|
|
phraseParam := nextArg(escapedPhrase) // $1: exact title
|
|
phraseContainsParam := nextArg("%" + escapedPhrase + "%") // $2: contains
|
|
phraseStartsWithParam := nextArg(escapedPhrase + "%") // $3: starts with
|
|
wsParam := nextArg(nil) // $4: workspace_id, filled by caller
|
|
|
|
var termContainsParams []string
|
|
if len(terms) > 1 {
|
|
for _, term := range terms {
|
|
termContainsParams = append(termContainsParams, nextArg("%"+escapeLike(term)+"%"))
|
|
}
|
|
}
|
|
|
|
numParam := ""
|
|
if hasNum {
|
|
numParam = nextArg(queryNum)
|
|
}
|
|
|
|
terminalStatusesParam := ""
|
|
if !includeClosed {
|
|
// Negate only known terminal keys so an unknown legacy key remains
|
|
// searchable instead of disappearing from the default result set.
|
|
terminalStatusesParam = nextArg(terminalStatusKeys)
|
|
}
|
|
|
|
limitParam := nextArg(nil)
|
|
offsetParam := nextArg(nil)
|
|
|
|
// Stage one scans this workspace's issues once and retains only the narrow
|
|
// flags and sort fields needed to choose a page. Do not force this CTE to be
|
|
// MATERIALIZED: production EXPLAIN showed 28-68% lower execution time after
|
|
// removing that fence. Full issue rows are hydrated after LIMIT/OFFSET below.
|
|
const (
|
|
loweredIssueTitle = "lowered_issue_title.lowered"
|
|
loweredIssueDescription = "lowered_issue_description.lowered"
|
|
)
|
|
titlePhrasePredicate := fmt.Sprintf("%s LIKE %s", loweredIssueTitle, phraseContainsParam)
|
|
titleTermPredicates := make([]string, 0, len(termContainsParams))
|
|
for _, termParam := range termContainsParams {
|
|
titleTermPredicates = append(titleTermPredicates, fmt.Sprintf("%s LIKE %s", loweredIssueTitle, termParam))
|
|
}
|
|
titleMatchParts := []string{titlePhrasePredicate}
|
|
if len(termContainsParams) > 1 {
|
|
titleMatchParts = append(titleMatchParts, "("+strings.Join(titleTermPredicates, " AND ")+")")
|
|
}
|
|
titleMatchExpr := "(" + strings.Join(titleMatchParts, " OR ") + ")"
|
|
|
|
issueFlagColumns := []string{
|
|
"i.id AS issue_id",
|
|
"i.status",
|
|
"i.updated_at",
|
|
fmt.Sprintf("%s = %s AS title_exact", loweredIssueTitle, phraseParam),
|
|
fmt.Sprintf("%s LIKE %s AS title_starts_with", loweredIssueTitle, phraseStartsWithParam),
|
|
fmt.Sprintf("%s AS title_phrase", titlePhrasePredicate),
|
|
fmt.Sprintf("COALESCE(%s LIKE %s, FALSE) AS description_phrase", loweredIssueDescription, phraseContainsParam),
|
|
}
|
|
if hasNum {
|
|
issueFlagColumns = append(issueFlagColumns, fmt.Sprintf("i.number = %s AS number_exact", numParam))
|
|
}
|
|
for index, termParam := range termContainsParams {
|
|
issueFlagColumns = append(issueFlagColumns,
|
|
fmt.Sprintf("%s AS title_term_%d", titleTermPredicates[index], index),
|
|
fmt.Sprintf("COALESCE(%s LIKE %s, FALSE) AS description_term_%d", loweredIssueDescription, termParam, index),
|
|
)
|
|
}
|
|
|
|
issueWhere := "i.workspace_id = " + wsParam
|
|
if terminalStatusesParam != "" {
|
|
issueWhere += fmt.Sprintf(" AND NOT (i.status = ANY(%s::text[]))", terminalStatusesParam)
|
|
}
|
|
// PostgreSQL otherwise inlines scalar LATERAL subqueries and recomputes the
|
|
// LOWER expressions for every flag. The OFFSET 0 fences cache the normalized
|
|
// title and description per issue row without materializing the whole CTE.
|
|
// Once the title contains the phrase or every search term, it has already
|
|
// satisfied eligibility and won both relevance-rank and match-source
|
|
// precedence, so the LEFT JOIN can skip normalizing the description and its
|
|
// flags safely fall back to FALSE. Correctness requires every title rank and
|
|
// match-source branch below to remain ahead of its description counterpart.
|
|
// Future indexable text predicates must stay outside these projection-only
|
|
// fences so expression indexes can still match them.
|
|
issueMatchesCTE := fmt.Sprintf(`issue_matches AS (
|
|
SELECT %s
|
|
FROM issue i
|
|
CROSS JOIN LATERAL (
|
|
SELECT LOWER(i.title) AS lowered
|
|
OFFSET 0
|
|
) lowered_issue_title
|
|
LEFT JOIN LATERAL (
|
|
SELECT LOWER(COALESCE(i.description, '')) AS lowered
|
|
WHERE NOT %s
|
|
OFFSET 0
|
|
) lowered_issue_description ON TRUE
|
|
WHERE %s
|
|
)`, strings.Join(issueFlagColumns, ",\n\t\t\t"), titleMatchExpr, issueWhere)
|
|
|
|
// Comments are also scanned once, workspace-first. This intentionally avoids
|
|
// the legacy planner choice between global content GIN postings and repeated
|
|
// correlated/hashed subplans (MUL-4059); idx_comment_workspace bounds the
|
|
// candidate scan instead. Aggregation retains only per-issue flags plus the
|
|
// latest matching comment ID, and content is fetched by primary key after the
|
|
// final page is known. Per-term BOOL_OR flags keep the legacy eligibility rule
|
|
// where terms may be spread across comments, while comment_all_terms keeps
|
|
// ranking/snippet tied to one comment.
|
|
const loweredCommentContent = "lowered_comment.lowered"
|
|
commentFlagColumns := []string{
|
|
"c.issue_id",
|
|
fmt.Sprintf("BOOL_OR(%s LIKE %s) AS comment_phrase", loweredCommentContent, phraseContainsParam),
|
|
}
|
|
commentCandidateFlags := []string{"aggregated_comments.comment_phrase"}
|
|
commentTerms := make([]string, 0, len(termContainsParams))
|
|
for index, termParam := range termContainsParams {
|
|
alias := fmt.Sprintf("comment_term_%d", index)
|
|
commentFlagColumns = append(commentFlagColumns,
|
|
fmt.Sprintf("BOOL_OR(%s LIKE %s) AS %s", loweredCommentContent, termParam, alias),
|
|
)
|
|
commentCandidateFlags = append(commentCandidateFlags, "aggregated_comments."+alias)
|
|
commentTerms = append(commentTerms, fmt.Sprintf("%s LIKE %s", loweredCommentContent, termParam))
|
|
}
|
|
|
|
commentSnippetPredicate := fmt.Sprintf("%s LIKE %s", loweredCommentContent, phraseContainsParam)
|
|
if len(commentTerms) > 1 {
|
|
commentAllTerms := "(" + strings.Join(commentTerms, " AND ") + ")"
|
|
commentFlagColumns = append(commentFlagColumns,
|
|
fmt.Sprintf("BOOL_OR(%s) AS comment_all_terms", commentAllTerms),
|
|
)
|
|
commentSnippetPredicate += " OR " + commentAllTerms
|
|
}
|
|
// Keep the ordered aggregate in the measured single comment pass. Replacing
|
|
// it with DISTINCT ON/window ranking changes that production-tested plan;
|
|
// looking the ID up later would repeat text predicates after pagination.
|
|
// The aggregate stores matching UUIDs per issue (not content), and the ID
|
|
// tie-break makes equal created_at values deterministic.
|
|
commentFlagColumns = append(commentFlagColumns, fmt.Sprintf(
|
|
"(ARRAY_AGG(c.id ORDER BY c.created_at DESC, c.id DESC) FILTER (WHERE %s))[1] AS snippet_comment_id",
|
|
commentSnippetPredicate,
|
|
))
|
|
|
|
// PostgreSQL otherwise inlines this scalar LATERAL subquery and recomputes
|
|
// LOWER(c.content) for every flag. OFFSET 0 is the intentional planner fence
|
|
// that keeps the long comment body lowercased once per row. Future indexable
|
|
// text predicates must stay outside this projection-only fence so an index on
|
|
// LOWER(c.content) can still match them.
|
|
commentMatchesCTE := fmt.Sprintf(`comment_matches AS MATERIALIZED (
|
|
SELECT *
|
|
FROM (
|
|
SELECT %s
|
|
FROM comment c
|
|
CROSS JOIN LATERAL (
|
|
SELECT LOWER(c.content) AS lowered
|
|
OFFSET 0
|
|
) lowered_comment
|
|
WHERE c.workspace_id = %s
|
|
GROUP BY c.issue_id
|
|
) aggregated_comments
|
|
WHERE %s
|
|
)`,
|
|
strings.Join(commentFlagColumns, ",\n\t\t\t\t"),
|
|
wsParam,
|
|
strings.Join(commentCandidateFlags, " OR "),
|
|
)
|
|
|
|
// Stage two combines the two narrow sources, applies the legacy eligibility
|
|
// and ranking rules once, and materializes only the requested page.
|
|
eligibleParts := []string{
|
|
"im.title_phrase",
|
|
"im.description_phrase",
|
|
"COALESCE(cm.comment_phrase, FALSE)",
|
|
}
|
|
if len(termContainsParams) > 1 {
|
|
var allTerms []string
|
|
for index := range termContainsParams {
|
|
allTerms = append(allTerms, fmt.Sprintf(
|
|
"(im.title_term_%[1]d OR im.description_term_%[1]d OR COALESCE(cm.comment_term_%[1]d, FALSE))",
|
|
index,
|
|
))
|
|
}
|
|
eligibleParts = append(eligibleParts, "("+strings.Join(allTerms, " AND ")+")")
|
|
}
|
|
if hasNum {
|
|
eligibleParts = append(eligibleParts, "im.number_exact")
|
|
}
|
|
eligibleExpr := "(" + strings.Join(eligibleParts, " OR ") + ")"
|
|
|
|
rankCases := []string{}
|
|
if hasNum {
|
|
rankCases = append(rankCases, "WHEN im.number_exact THEN 0")
|
|
}
|
|
rankCases = append(rankCases,
|
|
"WHEN im.title_exact THEN 1",
|
|
"WHEN im.title_starts_with THEN 2",
|
|
"WHEN im.title_phrase THEN 3",
|
|
)
|
|
if len(termContainsParams) > 1 {
|
|
var titleTerms []string
|
|
for index := range termContainsParams {
|
|
titleTerms = append(titleTerms, fmt.Sprintf("im.title_term_%d", index))
|
|
}
|
|
rankCases = append(rankCases, "WHEN ("+strings.Join(titleTerms, " AND ")+") THEN 4")
|
|
}
|
|
rankCases = append(rankCases, "WHEN im.description_phrase THEN 5")
|
|
if len(termContainsParams) > 1 {
|
|
var descriptionTerms []string
|
|
for index := range termContainsParams {
|
|
descriptionTerms = append(descriptionTerms, fmt.Sprintf("im.description_term_%d", index))
|
|
}
|
|
rankCases = append(rankCases, "WHEN ("+strings.Join(descriptionTerms, " AND ")+") THEN 6")
|
|
}
|
|
rankCases = append(rankCases, "WHEN COALESCE(cm.comment_phrase, FALSE) THEN 7")
|
|
if len(termContainsParams) > 1 {
|
|
rankCases = append(rankCases, "WHEN COALESCE(cm.comment_all_terms, FALSE) THEN 8")
|
|
}
|
|
rankExpr := "CASE " + strings.Join(rankCases, " ") + " ELSE 9 END"
|
|
|
|
// title_exact deliberately preserves the legacy escapeLike quirk: a title
|
|
// containing _, %, or \\ is still searchable, but the escaped phrase does
|
|
// not compare equal and therefore is not treated as a cancelled direct hit.
|
|
directHitParts := []string{"im.title_exact"}
|
|
if hasNum {
|
|
directHitParts = append(directHitParts, "im.number_exact")
|
|
}
|
|
// Cancelled issues sort behind every live match unless an exact title or
|
|
// identifier shows that the user is targeting that specific issue.
|
|
cancelledRank := fmt.Sprintf(
|
|
"CASE WHEN im.status = 'cancelled' AND NOT (%s) THEN 1 ELSE 0 END",
|
|
strings.Join(directHitParts, " OR "),
|
|
)
|
|
statusRank := `CASE im.status
|
|
WHEN 'in_progress' THEN 0
|
|
WHEN 'in_review' THEN 1
|
|
WHEN 'todo' THEN 2
|
|
WHEN 'blocked' THEN 3
|
|
WHEN 'backlog' THEN 4
|
|
WHEN 'done' THEN 5
|
|
WHEN 'cancelled' THEN 6
|
|
ELSE 7
|
|
END`
|
|
|
|
matchSourceParts := []string{"WHEN im.title_phrase THEN 'title'"}
|
|
if len(termContainsParams) > 1 {
|
|
var titleTerms []string
|
|
for index := range termContainsParams {
|
|
titleTerms = append(titleTerms, fmt.Sprintf("im.title_term_%d", index))
|
|
}
|
|
matchSourceParts = append(matchSourceParts, "WHEN ("+strings.Join(titleTerms, " AND ")+") THEN 'title'")
|
|
}
|
|
matchSourceParts = append(matchSourceParts, "WHEN im.description_phrase THEN 'description'")
|
|
if len(termContainsParams) > 1 {
|
|
var descriptionTerms []string
|
|
for index := range termContainsParams {
|
|
descriptionTerms = append(descriptionTerms, fmt.Sprintf("im.description_term_%d", index))
|
|
}
|
|
matchSourceParts = append(matchSourceParts, "WHEN ("+strings.Join(descriptionTerms, " AND ")+") THEN 'description'")
|
|
}
|
|
matchSourceExpr := "CASE " + strings.Join(matchSourceParts, " ") + " ELSE 'comment' END"
|
|
|
|
rankedCandidatesCTE := fmt.Sprintf(`ranked_candidates AS (
|
|
SELECT im.issue_id, im.updated_at, cm.snippet_comment_id,
|
|
%s AS cancelled_rank,
|
|
%s AS relevance_rank,
|
|
%s AS status_rank,
|
|
%s AS match_source
|
|
FROM issue_matches im
|
|
LEFT JOIN comment_matches cm ON cm.issue_id = im.issue_id
|
|
WHERE %s
|
|
)`, cancelledRank, rankExpr, statusRank, matchSourceExpr, eligibleExpr)
|
|
|
|
pageCandidatesCTE := fmt.Sprintf(`page_candidates AS MATERIALIZED (
|
|
SELECT issue_id, updated_at, snippet_comment_id, cancelled_rank, relevance_rank, status_rank, match_source
|
|
FROM ranked_candidates
|
|
ORDER BY cancelled_rank, relevance_rank, status_rank, updated_at DESC, issue_id ASC
|
|
LIMIT %s OFFSET %s
|
|
)`, limitParam, offsetParam)
|
|
|
|
query := fmt.Sprintf(`WITH %s,
|
|
%s,
|
|
%s,
|
|
%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.acceptance_criteria, i.context_refs, i.position,
|
|
i.start_date, i.due_date, i.created_at, i.updated_at, i.last_activity_at, i.number, i.project_id,
|
|
i.revision, i.duplicate_of_issue_id,
|
|
pc.match_source,
|
|
COALESCE(c.content, '') AS matched_comment_content
|
|
FROM page_candidates pc
|
|
JOIN issue i ON i.id = pc.issue_id AND i.workspace_id = %s
|
|
LEFT JOIN comment c ON c.id = pc.snippet_comment_id AND c.workspace_id = %s
|
|
ORDER BY pc.cancelled_rank, pc.relevance_rank, pc.status_rank, pc.updated_at DESC, pc.issue_id ASC`,
|
|
issueMatchesCTE,
|
|
commentMatchesCTE,
|
|
rankedCandidatesCTE,
|
|
pageCandidatesCTE,
|
|
wsParam,
|
|
wsParam,
|
|
)
|
|
|
|
return query, args
|
|
}
|
|
|
|
func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
q := r.URL.Query().Get("q")
|
|
if q == "" {
|
|
writeError(w, http.StatusBadRequest, "q parameter is required")
|
|
return
|
|
}
|
|
|
|
limit := 20
|
|
offset := 0
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
|
limit = v
|
|
}
|
|
}
|
|
if limit > 50 {
|
|
limit = 50
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
|
offset = v
|
|
}
|
|
}
|
|
|
|
includeClosed := r.URL.Query().Get("include_closed") == "true"
|
|
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
terms := splitSearchTerms(q)
|
|
queryNum, hasNum := parseQueryNumber(q)
|
|
var terminalStatusKeys []string
|
|
if !includeClosed {
|
|
resolvedKeys, err := h.terminalIssueStatusKeys(ctx, wsUUID)
|
|
if err != nil {
|
|
if writeSearchCancellation(w, r, err) {
|
|
return
|
|
}
|
|
slog.Warn("expand terminal status categories failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve status categories")
|
|
return
|
|
}
|
|
terminalStatusKeys = resolvedKeys
|
|
}
|
|
|
|
sqlQuery, args := buildSearchQuery(q, terms, queryNum, hasNum, includeClosed, terminalStatusKeys)
|
|
// Fill placeholder args: $4 = workspace_id, last two = limit, offset
|
|
args[3] = wsUUID
|
|
args[len(args)-2] = limit
|
|
args[len(args)-1] = offset
|
|
|
|
var results []searchResult
|
|
err := runSearchQuery(ctx, h.TxStarter, sqlQuery, args, func(rows pgx.Rows) error {
|
|
for rows.Next() {
|
|
var sr searchResult
|
|
if err := rows.Scan(
|
|
&sr.issue.ID,
|
|
&sr.issue.WorkspaceID,
|
|
&sr.issue.Title,
|
|
&sr.issue.Description,
|
|
&sr.issue.Status,
|
|
&sr.issue.Priority,
|
|
&sr.issue.AssigneeType,
|
|
&sr.issue.AssigneeID,
|
|
&sr.issue.CreatorType,
|
|
&sr.issue.CreatorID,
|
|
&sr.issue.ParentIssueID,
|
|
&sr.issue.AcceptanceCriteria,
|
|
&sr.issue.ContextRefs,
|
|
&sr.issue.Position,
|
|
&sr.issue.StartDate,
|
|
&sr.issue.DueDate,
|
|
&sr.issue.CreatedAt,
|
|
&sr.issue.UpdatedAt,
|
|
&sr.issue.LastActivityAt,
|
|
&sr.issue.Number,
|
|
&sr.issue.ProjectID,
|
|
&sr.issue.Revision,
|
|
&sr.issue.DuplicateOfIssueID,
|
|
&sr.matchSource,
|
|
&sr.matchedCommentContent,
|
|
); err != nil {
|
|
return fmt.Errorf("scan: %w", err)
|
|
}
|
|
results = append(results, sr)
|
|
}
|
|
return rows.Err()
|
|
})
|
|
if err != nil {
|
|
if writeSearchCancellation(w, r, err) {
|
|
return
|
|
}
|
|
// Statement-timeout surfaces as SQLSTATE 57014. Return a 503
|
|
// so the frontend can distinguish a timeout ("try a more
|
|
// specific query") from a generic 500. This is the fail-fast
|
|
// path when GIN search indexes are absent or the database is
|
|
// overloaded; see runSearchQuery header for context.
|
|
if isSearchStatementTimeout(err) {
|
|
slog.Warn("search issues timed out",
|
|
"workspace_id", workspaceID,
|
|
"query", q,
|
|
"timeout", searchStatementTimeout)
|
|
writeError(w, http.StatusServiceUnavailable, "search timed out; please refine your query or try again")
|
|
return
|
|
}
|
|
slog.Warn("search issues failed", "error", err, "workspace_id", workspaceID, "query", q)
|
|
writeError(w, http.StatusInternalServerError, "failed to search issues")
|
|
return
|
|
}
|
|
if writeSearchCancellation(w, r, ctx.Err()) {
|
|
return
|
|
}
|
|
|
|
prefix := h.getIssuePrefix(ctx, wsUUID)
|
|
var originals []pgtype.UUID
|
|
for _, sr := range results {
|
|
originals = appendDuplicateOriginal(originals, sr.issue.Status, sr.issue.DuplicateOfIssueID)
|
|
}
|
|
fillSearch := h.newStatusCategoryFiller(ctx, wsUUID, originals...)
|
|
resp := make([]SearchIssueResponse, len(results))
|
|
for i, sr := range results {
|
|
sir := SearchIssueResponse{
|
|
IssueResponse: issueToResponse(sr.issue, prefix),
|
|
MatchSource: sr.matchSource,
|
|
}
|
|
fillSearch(&sir.IssueResponse)
|
|
// Always populate comment snippet when a matching comment exists
|
|
if sr.matchedCommentContent != "" {
|
|
snippet := extractSnippet(sr.matchedCommentContent, q)
|
|
sir.MatchedCommentSnippet = &snippet
|
|
// Keep backward compat: also set MatchedSnippet for comment-source matches
|
|
if sr.matchSource == "comment" {
|
|
sir.MatchedSnippet = &snippet
|
|
}
|
|
}
|
|
// Populate description snippet when description matches
|
|
if sr.matchSource == "description" || descriptionContains(sr.issue.Description, q, terms) {
|
|
if sr.issue.Description.Valid && sr.issue.Description.String != "" {
|
|
snippet := extractSnippet(sr.issue.Description.String, q)
|
|
sir.MatchedDescriptionSnippet = &snippet
|
|
}
|
|
}
|
|
resp[i] = sir
|
|
}
|
|
|
|
if writeSearchCancellation(w, r, ctx.Err()) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"issues": resp,
|
|
})
|
|
}
|
|
|
|
// QueryIssues is the POST twin of ListIssues for filter sets too large for a
|
|
// GET request line — the table's agents-working facet can carry hundreds of
|
|
// issue ids, and common reverse proxies cap request lines around 8 KB. The
|
|
// body is a flat JSON object with EXACTLY the same keys and string encodings
|
|
// as ListIssues' query parameters; the handler rebuilds the query string and
|
|
// delegates, so the two transports cannot drift.
|
|
func (h *Handler) QueryIssues(w http.ResponseWriter, r *http.Request) {
|
|
var params map[string]string
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(¶ms); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
values := make(url.Values, len(params))
|
|
for key, value := range params {
|
|
values.Set(key, value)
|
|
}
|
|
r.URL.RawQuery = values.Encode()
|
|
h.ListIssues(w, r)
|
|
}
|
|
|
|
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Parse optional filter params. Malformed UUIDs in filters return 400 —
|
|
// silently coercing them to a zero UUID would mask a client bug and let
|
|
// the query return an empty result set (or worse, match a NULL row).
|
|
var priorityFilter pgtype.Text
|
|
if p := r.URL.Query().Get("priority"); p != "" {
|
|
priorityFilter = pgtype.Text{String: p, Valid: true}
|
|
}
|
|
var assigneeFilter pgtype.UUID
|
|
if a := r.URL.Query().Get("assignee_id"); a != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, a, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
assigneeFilter = id
|
|
}
|
|
var assigneeIdsFilter []pgtype.UUID
|
|
if ids := r.URL.Query().Get("assignee_ids"); ids != "" {
|
|
for _, raw := range strings.Split(ids, ",") {
|
|
if s := strings.TrimSpace(raw); s != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, s, "assignee_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
assigneeIdsFilter = append(assigneeIdsFilter, id)
|
|
}
|
|
}
|
|
}
|
|
var creatorFilter pgtype.UUID
|
|
if c := r.URL.Query().Get("creator_id"); c != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, c, "creator_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
creatorFilter = id
|
|
}
|
|
var projectFilter pgtype.UUID
|
|
if p := r.URL.Query().Get("project_id"); p != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, p, "project_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
projectFilter = id
|
|
}
|
|
// involves_user_id widens the assignee filter to surface issues where the
|
|
// user is the indirect assignee (their owned agent, or a squad they belong
|
|
// to / lead / have an agent inside). Direct member-assignment is excluded
|
|
// by design — that is the meaning of `assignee_id` (tab 1), and tab 3 must
|
|
// be disjoint from tab 1.
|
|
var involvesUserFilter pgtype.UUID
|
|
if u := r.URL.Query().Get("involves_user_id"); u != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, u, "involves_user_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
involvesUserFilter = id
|
|
}
|
|
|
|
metadataFilter, ok := parseMetadataFilterParam(w, r.URL.Query().Get("metadata"))
|
|
if !ok {
|
|
return
|
|
}
|
|
propertiesFilter, ok := parsePropertiesFilterParam(w, r.URL.Query().Get("properties"))
|
|
if !ok {
|
|
return
|
|
}
|
|
dateFilter, ok := parseIssueDateFilter(w, r.URL.Query())
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// open_only=true returns all non-done/cancelled issues (no limit).
|
|
if r.URL.Query().Get("open_only") == "true" {
|
|
// Serialize the parsed AND-of-ORs groups into the single jsonb param
|
|
// the static query unrolls (see properties_filter in ListOpenIssues).
|
|
var openPropertiesFilter []byte
|
|
if len(propertiesFilter) > 0 {
|
|
marshaled, marshalErr := json.Marshal(propertiesFilter)
|
|
if marshalErr != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
|
return
|
|
}
|
|
openPropertiesFilter = marshaled
|
|
}
|
|
terminalStatusKeys, err := h.terminalIssueStatusKeys(ctx, wsUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve status categories")
|
|
return
|
|
}
|
|
issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{
|
|
WorkspaceID: wsUUID,
|
|
TerminalStatusKeys: terminalStatusKeys,
|
|
Priority: priorityFilter,
|
|
AssigneeID: assigneeFilter,
|
|
AssigneeIds: assigneeIdsFilter,
|
|
CreatorID: creatorFilter,
|
|
ProjectID: projectFilter,
|
|
InvolvesUserID: involvesUserFilter,
|
|
MetadataFilter: metadataFilter,
|
|
PropertiesFilter: openPropertiesFilter,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
|
return
|
|
}
|
|
|
|
prefix := h.getIssuePrefix(ctx, wsUUID)
|
|
ids := make([]pgtype.UUID, len(issues))
|
|
var originals []pgtype.UUID
|
|
for i, issue := range issues {
|
|
ids[i] = issue.ID
|
|
originals = appendDuplicateOriginal(originals, issue.Status, issue.DuplicateOfIssueID)
|
|
}
|
|
labelsMap := h.labelsByIssue(ctx, wsUUID, ids)
|
|
fillOpen := h.newStatusCategoryFiller(ctx, wsUUID, originals...)
|
|
resp := make([]IssueResponse, len(issues))
|
|
for i, issue := range issues {
|
|
resp[i] = openIssueRowToResponse(issue, prefix)
|
|
fillOpen(&resp[i])
|
|
labels := labelsMap[resp[i].ID]
|
|
if labels == nil {
|
|
labels = []LabelResponse{}
|
|
}
|
|
resp[i].Labels = &labels
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"issues": resp,
|
|
"total": len(resp),
|
|
})
|
|
return
|
|
}
|
|
|
|
limit := 100
|
|
offset := 0
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
|
limit = v
|
|
}
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
|
offset = v
|
|
}
|
|
}
|
|
|
|
statusesFilter := splitCommaParam(r.URL.Query().Get("statuses"))
|
|
if len(statusesFilter) == 0 {
|
|
statusesFilter = splitCommaParam(r.URL.Query().Get("status"))
|
|
}
|
|
// status_category filters by lifecycle phase rather than by exact key, so
|
|
// one board column can hold all concrete and custom statuses in that phase.
|
|
// Without this the board would need one column — and one request — per
|
|
// status. (MUL-6243, MUL-7240)
|
|
statusCategoriesFilter := splitCommaParam(r.URL.Query().Get("status_categories"))
|
|
if len(statusCategoriesFilter) == 0 {
|
|
statusCategoriesFilter = splitCommaParam(r.URL.Query().Get("status_category"))
|
|
}
|
|
prioritiesFilter := splitCommaParam(r.URL.Query().Get("priorities"))
|
|
if len(prioritiesFilter) == 0 {
|
|
prioritiesFilter = splitCommaParam(r.URL.Query().Get("priority"))
|
|
}
|
|
|
|
// assignee_types narrows the list to issues assigned to the given actor
|
|
// kinds (member / agent / squad). Mirrors the same param on
|
|
// ListGroupedIssues so the workspace Members/Agents tabs can filter
|
|
// server-side instead of post-filtering loaded pages on the client.
|
|
assigneeTypesFilter := splitCommaParam(r.URL.Query().Get("assignee_types"))
|
|
for _, assigneeType := range assigneeTypesFilter {
|
|
if !isIssueActorType(assigneeType) {
|
|
writeError(w, http.StatusBadRequest, "invalid assignee_types")
|
|
return
|
|
}
|
|
}
|
|
|
|
// scheduled=true restricts the result to issues that have at least one of
|
|
// start_date / due_date set. Used by the Project Gantt view, which only
|
|
// renders schedulable rows and shouldn't pay for the full project list.
|
|
var scheduledFilter pgtype.Bool
|
|
if r.URL.Query().Get("scheduled") == "true" {
|
|
scheduledFilter = pgtype.Bool{Bool: true, Valid: true}
|
|
}
|
|
|
|
// Parse sort and direction params for dynamic ORDER BY.
|
|
// Manual sort (position) is always ASC — direction is ignored because
|
|
// the user defines order through drag-and-drop, reversing it has no
|
|
// product meaning.
|
|
sortCol := "position"
|
|
sortIsExpr := false
|
|
sortIsProperty := false
|
|
sortByStatus := false
|
|
if s := r.URL.Query().Get("sort"); s != "" {
|
|
switch s {
|
|
case "position", "title", "created_at", "updated_at", "start_date", "due_date":
|
|
sortCol = s
|
|
case "last_activity":
|
|
sortCol = "last_activity_at"
|
|
case "status":
|
|
sortCol = "status"
|
|
sortIsExpr = true
|
|
sortByStatus = true
|
|
case "priority":
|
|
sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END"
|
|
sortIsExpr = true
|
|
default:
|
|
// property:<definitionId> sorts by the custom-property value
|
|
// (typed expression); unknown/archived definitions degrade to
|
|
// position order instead of erroring stale clients.
|
|
expr, handled, sortErr := h.propertySortExpr(r, workspaceID, s)
|
|
if !handled {
|
|
writeError(w, http.StatusBadRequest, "invalid sort value")
|
|
return
|
|
}
|
|
if sortErr != nil {
|
|
if sortErr.Error() == "invalid sort value" || sortErr.Error() == "invalid workspace id" {
|
|
writeError(w, http.StatusBadRequest, sortErr.Error())
|
|
return
|
|
}
|
|
slog.Warn("propertySortExpr failed", append(logger.RequestAttrs(r), "error", sortErr)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve sort")
|
|
return
|
|
}
|
|
if expr != "" {
|
|
sortCol = expr
|
|
sortIsExpr = true
|
|
sortIsProperty = true
|
|
}
|
|
}
|
|
}
|
|
sortDir := "ASC"
|
|
if sortCol == "last_activity_at" {
|
|
sortDir = "DESC"
|
|
}
|
|
if sortCol != "position" {
|
|
if d := r.URL.Query().Get("direction"); d != "" {
|
|
switch strings.ToLower(d) {
|
|
case "asc":
|
|
sortDir = "ASC"
|
|
case "desc":
|
|
sortDir = "DESC"
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid direction value")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build dynamic SQL — same approach as ListGroupedIssues.
|
|
where := []string{"i.workspace_id = $1"}
|
|
args := []any{wsUUID}
|
|
addArg := func(v any) string {
|
|
args = append(args, v)
|
|
return "$" + strconv.Itoa(len(args))
|
|
}
|
|
if len(statusCategoriesFilter) > 0 {
|
|
// Expanded to concrete status keys rather than filtered through
|
|
// issue_effective_status(): wrapping the column in a function makes the
|
|
// (workspace_id, status) index unusable and turns a two-page index read
|
|
// into a full workspace scan. (MUL-6243)
|
|
keys, err := issuestatus.ExpandCategories(r.Context(), h.Queries, wsUUID, statusCategoriesFilter)
|
|
if err != nil {
|
|
slog.Warn("expand status categories failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve status categories")
|
|
return
|
|
}
|
|
where = append(where, fmt.Sprintf("i.status = ANY(%s::text[])", addArg(keys)))
|
|
}
|
|
if len(statusesFilter) > 0 {
|
|
where = append(where, fmt.Sprintf("i.status = ANY(%s::text[])", addArg(statusesFilter)))
|
|
}
|
|
if len(prioritiesFilter) > 0 {
|
|
where = append(where, fmt.Sprintf("i.priority = ANY(%s::text[])", addArg(prioritiesFilter)))
|
|
}
|
|
if assigneeFilter.Valid {
|
|
where = append(where, fmt.Sprintf("i.assignee_id = %s::uuid", addArg(assigneeFilter)))
|
|
}
|
|
if len(assigneeIdsFilter) > 0 {
|
|
where = append(where, fmt.Sprintf("i.assignee_id = ANY(%s::uuid[])", addArg(assigneeIdsFilter)))
|
|
}
|
|
if len(assigneeTypesFilter) > 0 {
|
|
where = append(where, fmt.Sprintf("i.assignee_type = ANY(%s::text[])", addArg(assigneeTypesFilter)))
|
|
}
|
|
if creatorFilter.Valid {
|
|
where = append(where, fmt.Sprintf("i.creator_id = %s::uuid", addArg(creatorFilter)))
|
|
}
|
|
if projectFilter.Valid {
|
|
where = append(where, fmt.Sprintf("i.project_id = %s::uuid", addArg(projectFilter)))
|
|
}
|
|
|
|
// Table facets must be part of the server window. Applying them after
|
|
// LIMIT/OFFSET hides matches that live on later pages and makes `total`
|
|
// disagree with the rows the user sees/exports.
|
|
assigneeFilters, ok := parseActorFilterList(w, r.URL.Query().Get("assignee_filters"), "assignee_filters")
|
|
if !ok {
|
|
return
|
|
}
|
|
includeNoAssignee := r.URL.Query().Get("include_no_assignee") == "true"
|
|
if len(assigneeFilters) > 0 || includeNoAssignee {
|
|
ors := make([]string, 0, len(assigneeFilters)+1)
|
|
for _, filter := range assigneeFilters {
|
|
ors = append(ors, fmt.Sprintf(
|
|
"(i.assignee_type = %s::text AND i.assignee_id = %s::uuid)",
|
|
addArg(filter.actorType),
|
|
addArg(filter.actorID),
|
|
))
|
|
}
|
|
if includeNoAssignee {
|
|
ors = append(ors, "(i.assignee_type IS NULL AND i.assignee_id IS NULL)")
|
|
}
|
|
where = append(where, "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
creatorFilters, ok := parseActorFilterList(w, r.URL.Query().Get("creator_filters"), "creator_filters")
|
|
if !ok {
|
|
return
|
|
}
|
|
if len(creatorFilters) > 0 {
|
|
ors := make([]string, 0, len(creatorFilters))
|
|
for _, filter := range creatorFilters {
|
|
ors = append(ors, fmt.Sprintf(
|
|
"(i.creator_type = %s::text AND i.creator_id = %s::uuid)",
|
|
addArg(filter.actorType),
|
|
addArg(filter.actorID),
|
|
))
|
|
}
|
|
where = append(where, "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
projectIDs, ok := parseUUIDParamList(w, r.URL.Query().Get("project_ids"), "project_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
includeNoProject := r.URL.Query().Get("include_no_project") == "true"
|
|
if len(projectIDs) > 0 || includeNoProject {
|
|
ors := make([]string, 0, 2)
|
|
if len(projectIDs) > 0 {
|
|
ors = append(ors, fmt.Sprintf("i.project_id = ANY(%s::uuid[])", addArg(projectIDs)))
|
|
}
|
|
if includeNoProject {
|
|
ors = append(ors, "i.project_id IS NULL")
|
|
}
|
|
where = append(where, "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
labelIDs, ok := parseUUIDParamList(w, r.URL.Query().Get("label_ids"), "label_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
if len(labelIDs) > 0 {
|
|
where = append(where, fmt.Sprintf(
|
|
"EXISTS (SELECT 1 FROM issue_to_label itl WHERE itl.issue_id = i.id AND itl.label_id = ANY(%s::uuid[]))",
|
|
addArg(labelIDs),
|
|
))
|
|
}
|
|
// ids restricts the window to an explicit id set (the table's
|
|
// agents-working facet sends the live running-issue ids). Presence with an
|
|
// EMPTY list is meaningful — it must yield an empty window, not degrade to
|
|
// the unrestricted one, so gate on Has() rather than the parsed length.
|
|
if r.URL.Query().Has("ids") {
|
|
idsFilter, ok := parseUUIDParamList(w, r.URL.Query().Get("ids"), "ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
if idsFilter == nil {
|
|
idsFilter = []pgtype.UUID{}
|
|
}
|
|
where = append(where, fmt.Sprintf("i.id = ANY(%s::uuid[])", addArg(idsFilter)))
|
|
}
|
|
if r.URL.Query().Get("top_level_only") == "true" {
|
|
where = append(where, "i.parent_issue_id IS NULL")
|
|
}
|
|
where = appendIssueTableSearchFilter(where, addArg, r.URL.Query().Get("q"))
|
|
if scheduledFilter.Valid {
|
|
where = append(where, "(i.start_date IS NOT NULL OR i.due_date IS NOT NULL)")
|
|
}
|
|
if metadataFilter != nil {
|
|
where = append(where, fmt.Sprintf("i.metadata @> %s::jsonb", addArg(string(metadataFilter))))
|
|
}
|
|
if propertiesFilter != nil {
|
|
where = append(where, propertiesFilterPredicate(propertiesFilter, addArg))
|
|
}
|
|
where = appendIssueDateFilter(where, addArg, dateFilter)
|
|
if involvesUserFilter.Valid {
|
|
ref := addArg(involvesUserFilter)
|
|
where = append(where, fmt.Sprintf(`(
|
|
(i.assignee_type = 'agent' AND i.assignee_id IN (
|
|
SELECT a.id FROM agent a
|
|
WHERE a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
))
|
|
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
|
|
SELECT sm.squad_id
|
|
FROM squad_member sm
|
|
JOIN squad s ON s.id = sm.squad_id
|
|
WHERE s.workspace_id = $1
|
|
AND sm.member_type = 'member'
|
|
AND sm.member_id = %[1]s::uuid
|
|
UNION
|
|
SELECT s.id
|
|
FROM squad s
|
|
JOIN agent a ON a.id = s.leader_id
|
|
WHERE s.workspace_id = $1
|
|
AND a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
UNION
|
|
SELECT sm.squad_id
|
|
FROM squad_member sm
|
|
JOIN squad s ON s.id = sm.squad_id
|
|
JOIN agent a ON a.id = sm.member_id
|
|
WHERE s.workspace_id = $1
|
|
AND sm.member_type = 'agent'
|
|
AND a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
))
|
|
)`, ref))
|
|
}
|
|
|
|
whereSql := strings.Join(where, " AND ")
|
|
|
|
// Build ORDER BY clause.
|
|
// Count queries use only filter parameters. Sort-only CASE parameters must
|
|
// be appended afterwards or COUNT receives unused/untyped bind positions.
|
|
filterArgCount := len(args)
|
|
if sortByStatus {
|
|
var err error
|
|
sortCol, err = h.issueStatusSortExpression(r.Context(), wsUUID, addArg)
|
|
if err != nil {
|
|
slog.Warn("resolve status sort failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve sort")
|
|
return
|
|
}
|
|
}
|
|
orderBy := sortCol
|
|
if !sortIsExpr {
|
|
orderBy = "i." + sortCol
|
|
}
|
|
orderBy += " " + sortDir
|
|
if sortCol == "start_date" || sortCol == "due_date" || sortCol == "last_activity_at" || sortIsProperty {
|
|
// Property values are sparse: issues without one sort last in both
|
|
// directions (mirrors the client comparator).
|
|
orderBy += " NULLS LAST"
|
|
}
|
|
// created_at alone is not unique (bulk imports share timestamps); without
|
|
// a unique final key the database may reorder ties between two
|
|
// LIMIT/OFFSET requests, duplicating or dropping rows at page boundaries.
|
|
if sortCol == "last_activity_at" {
|
|
orderBy += ", i.id DESC"
|
|
} else {
|
|
orderBy += ", i.created_at DESC, i.id DESC"
|
|
}
|
|
|
|
offsetRef := addArg(int64(offset))
|
|
limitRef := addArg(int64(limit))
|
|
|
|
query := fmt.Sprintf(`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
|
|
FROM issue i
|
|
WHERE %s
|
|
ORDER BY %s
|
|
LIMIT %s OFFSET %s`, whereSql, orderBy, limitRef, offsetRef)
|
|
|
|
rows, err := h.DB.Query(ctx, query, args...)
|
|
if err != nil {
|
|
slog.Warn("ListIssues query failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var issues []db.ListIssuesRow
|
|
for rows.Next() {
|
|
var row db.ListIssuesRow
|
|
if err := rows.Scan(
|
|
&row.ID,
|
|
&row.WorkspaceID,
|
|
&row.Title,
|
|
&row.Description,
|
|
&row.Status,
|
|
&row.Priority,
|
|
&row.AssigneeType,
|
|
&row.AssigneeID,
|
|
&row.CreatorType,
|
|
&row.CreatorID,
|
|
&row.ParentIssueID,
|
|
&row.Position,
|
|
&row.StartDate,
|
|
&row.DueDate,
|
|
&row.CreatedAt,
|
|
&row.UpdatedAt,
|
|
&row.LastActivityAt,
|
|
&row.Number,
|
|
&row.ProjectID,
|
|
&row.Metadata,
|
|
&row.Stage,
|
|
&row.Properties,
|
|
&row.Revision,
|
|
&row.DuplicateOfIssueID,
|
|
); err != nil {
|
|
slog.Warn("ListIssues scan failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
|
return
|
|
}
|
|
issues = append(issues, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("ListIssues rows failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
|
return
|
|
}
|
|
|
|
// Get the true total count for pagination awareness.
|
|
countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM issue i WHERE %s`, whereSql)
|
|
// Count query uses the same args minus the OFFSET and LIMIT params (last two added).
|
|
countArgs := args[:filterArgCount]
|
|
var total int64
|
|
if err := h.DB.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
|
|
slog.Warn("ListIssues count failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to count issues")
|
|
return
|
|
}
|
|
|
|
prefix := h.getIssuePrefix(ctx, wsUUID)
|
|
ids := make([]pgtype.UUID, len(issues))
|
|
for i, issue := range issues {
|
|
ids[i] = issue.ID
|
|
}
|
|
labelsMap := h.labelsByIssue(ctx, wsUUID, ids)
|
|
resp := make([]IssueResponse, len(issues))
|
|
for i, issue := range issues {
|
|
resp[i] = issueListRowToResponse(issue, prefix)
|
|
labels := labelsMap[resp[i].ID]
|
|
if labels == nil {
|
|
labels = []LabelResponse{}
|
|
}
|
|
resp[i].Labels = &labels
|
|
}
|
|
h.fillStatusCategories(ctx, wsUUID, resp)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"issues": resp,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
type issueActorFilter struct {
|
|
actorType string
|
|
actorID pgtype.UUID
|
|
}
|
|
|
|
type issueDateFilter struct {
|
|
column string
|
|
start time.Time
|
|
end time.Time
|
|
}
|
|
|
|
func parseIssueDateFilter(w http.ResponseWriter, values url.Values) (*issueDateFilter, bool) {
|
|
field := strings.TrimSpace(values.Get("date_field"))
|
|
startRaw := strings.TrimSpace(values.Get("date_start"))
|
|
endRaw := strings.TrimSpace(values.Get("date_end"))
|
|
if field == "" && startRaw == "" && endRaw == "" {
|
|
return nil, true
|
|
}
|
|
if field == "" || startRaw == "" || endRaw == "" {
|
|
writeError(w, http.StatusBadRequest, "date_field, date_start, and date_end are required together")
|
|
return nil, false
|
|
}
|
|
|
|
column := ""
|
|
switch field {
|
|
case "created_at":
|
|
column = "created_at"
|
|
case "updated_at":
|
|
column = "updated_at"
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid date_field")
|
|
return nil, false
|
|
}
|
|
|
|
start, err := time.Parse(time.RFC3339Nano, startRaw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid date_start")
|
|
return nil, false
|
|
}
|
|
end, err := time.Parse(time.RFC3339Nano, endRaw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid date_end")
|
|
return nil, false
|
|
}
|
|
if !start.Before(end) {
|
|
writeError(w, http.StatusBadRequest, "date_start must be before date_end")
|
|
return nil, false
|
|
}
|
|
|
|
return &issueDateFilter{column: column, start: start, end: end}, true
|
|
}
|
|
|
|
func appendIssueDateFilter(where []string, addArg func(any) string, filter *issueDateFilter) []string {
|
|
if filter == nil {
|
|
return where
|
|
}
|
|
startRef := addArg(filter.start)
|
|
endRef := addArg(filter.end)
|
|
return append(where, fmt.Sprintf(
|
|
"i.%s >= %s AND i.%s < %s",
|
|
filter.column,
|
|
startRef,
|
|
filter.column,
|
|
endRef,
|
|
))
|
|
}
|
|
|
|
// appendIssueTableSearchFilter adds a quick identity search to the ordinary
|
|
// ListIssues window. Unlike the ranked global search endpoint, this predicate
|
|
// preserves the table's active filters, explicit sort, total, and pagination.
|
|
// Every word must appear in the title; a complete identifier (or bare issue
|
|
// number) also matches the immutable numeric issue number.
|
|
func appendIssueTableSearchFilter(where []string, addArg func(any) string, raw string) []string {
|
|
query := strings.TrimSpace(raw)
|
|
if query == "" {
|
|
return where
|
|
}
|
|
|
|
words := splitSearchTerms(strings.ToLower(query))
|
|
ors := make([]string, 0, 2)
|
|
if len(words) > 0 {
|
|
titleMatches := make([]string, 0, len(words))
|
|
for _, word := range words {
|
|
pattern := "%" + escapeLike(word) + "%"
|
|
titleMatches = append(titleMatches, fmt.Sprintf("LOWER(i.title) LIKE %s", addArg(pattern)))
|
|
}
|
|
ors = append(ors, "("+strings.Join(titleMatches, " AND ")+")")
|
|
}
|
|
if number, ok := parseQueryNumber(query); ok {
|
|
ors = append(ors, fmt.Sprintf("i.number = %s", addArg(number)))
|
|
}
|
|
if len(ors) == 0 {
|
|
return where
|
|
}
|
|
return append(where, "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
func splitCommaParam(raw string) []string {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
|
out = append(out, trimmed)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isIssueActorType(s string) bool {
|
|
return s == "member" || s == "agent" || s == "squad"
|
|
}
|
|
|
|
func parseUUIDParamList(w http.ResponseWriter, raw, fieldName string) ([]pgtype.UUID, bool) {
|
|
parts := splitCommaParam(raw)
|
|
if len(parts) == 0 {
|
|
return nil, true
|
|
}
|
|
ids := make([]pgtype.UUID, 0, len(parts))
|
|
for _, part := range parts {
|
|
id, ok := parseUUIDOrBadRequest(w, part, fieldName)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, true
|
|
}
|
|
|
|
func parseActorFilterList(w http.ResponseWriter, raw, fieldName string) ([]issueActorFilter, bool) {
|
|
parts := splitCommaParam(raw)
|
|
if len(parts) == 0 {
|
|
return nil, true
|
|
}
|
|
filters := make([]issueActorFilter, 0, len(parts))
|
|
for _, part := range parts {
|
|
pieces := strings.SplitN(part, ":", 2)
|
|
if len(pieces) != 2 || !isIssueActorType(pieces[0]) || strings.TrimSpace(pieces[1]) == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid "+fieldName)
|
|
return nil, false
|
|
}
|
|
id, ok := parseUUIDOrBadRequest(w, strings.TrimSpace(pieces[1]), fieldName)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
filters = append(filters, issueActorFilter{
|
|
actorType: pieces[0],
|
|
actorID: id,
|
|
})
|
|
}
|
|
return filters, true
|
|
}
|
|
|
|
func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
if h.DB == nil {
|
|
writeError(w, http.StatusInternalServerError, "database is unavailable")
|
|
return
|
|
}
|
|
|
|
groupBy := r.URL.Query().Get("group_by")
|
|
if groupBy == "" {
|
|
groupBy = "assignee"
|
|
}
|
|
if groupBy != "assignee" {
|
|
writeError(w, http.StatusBadRequest, "unsupported group_by")
|
|
return
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
limit := 50
|
|
offset := 0
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
|
limit = v
|
|
}
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if v, err := strconv.Atoi(o); err == nil && v > 0 {
|
|
offset = v
|
|
}
|
|
}
|
|
|
|
where := []string{"i.workspace_id = $1"}
|
|
args := []any{wsUUID}
|
|
addArg := func(v any) string {
|
|
args = append(args, v)
|
|
return "$" + strconv.Itoa(len(args))
|
|
}
|
|
|
|
statuses := splitCommaParam(r.URL.Query().Get("statuses"))
|
|
if len(statuses) == 0 {
|
|
statuses = splitCommaParam(r.URL.Query().Get("status"))
|
|
}
|
|
if len(statuses) > 0 {
|
|
where = append(where, fmt.Sprintf("i.status = ANY(%s::text[])", addArg(statuses)))
|
|
}
|
|
// See ListIssues: category filtering is what lets the board keep a fixed
|
|
// column count as a workspace adds custom statuses. (MUL-6243)
|
|
statusCategories := splitCommaParam(r.URL.Query().Get("status_categories"))
|
|
if len(statusCategories) == 0 {
|
|
statusCategories = splitCommaParam(r.URL.Query().Get("status_category"))
|
|
}
|
|
if len(statusCategories) > 0 {
|
|
// See ListIssues: expanded to keys so the (workspace_id, status) index
|
|
// still drives the scan. (MUL-6243)
|
|
keys, err := issuestatus.ExpandCategories(r.Context(), h.Queries, wsUUID, statusCategories)
|
|
if err != nil {
|
|
slog.Warn("expand status categories failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve status categories")
|
|
return
|
|
}
|
|
where = append(where, fmt.Sprintf("i.status = ANY(%s::text[])", addArg(keys)))
|
|
}
|
|
|
|
priorities := splitCommaParam(r.URL.Query().Get("priorities"))
|
|
if len(priorities) == 0 {
|
|
priorities = splitCommaParam(r.URL.Query().Get("priority"))
|
|
}
|
|
if len(priorities) > 0 {
|
|
where = append(where, fmt.Sprintf("i.priority = ANY(%s::text[])", addArg(priorities)))
|
|
}
|
|
|
|
assigneeTypes := splitCommaParam(r.URL.Query().Get("assignee_types"))
|
|
if len(assigneeTypes) > 0 {
|
|
for _, assigneeType := range assigneeTypes {
|
|
if !isIssueActorType(assigneeType) {
|
|
writeError(w, http.StatusBadRequest, "invalid assignee_types")
|
|
return
|
|
}
|
|
}
|
|
where = append(where, fmt.Sprintf("i.assignee_type = ANY(%s::text[])", addArg(assigneeTypes)))
|
|
}
|
|
|
|
if raw := r.URL.Query().Get("assignee_id"); raw != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, raw, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
where = append(where, fmt.Sprintf("i.assignee_id = %s::uuid", addArg(id)))
|
|
}
|
|
if raw := r.URL.Query().Get("assignee_ids"); raw != "" {
|
|
ids, ok := parseUUIDParamList(w, raw, "assignee_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
if len(ids) > 0 {
|
|
where = append(where, fmt.Sprintf("i.assignee_id = ANY(%s::uuid[])", addArg(ids)))
|
|
}
|
|
}
|
|
if raw := r.URL.Query().Get("creator_id"); raw != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, raw, "creator_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
where = append(where, fmt.Sprintf("i.creator_id = %s::uuid", addArg(id)))
|
|
}
|
|
if raw := r.URL.Query().Get("project_id"); raw != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, raw, "project_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
where = append(where, fmt.Sprintf("i.project_id = %s::uuid", addArg(id)))
|
|
}
|
|
if filter, ok := parseMetadataFilterParam(w, r.URL.Query().Get("metadata")); !ok {
|
|
return
|
|
} else if filter != nil {
|
|
where = append(where, fmt.Sprintf("i.metadata @> %s::jsonb", addArg(string(filter))))
|
|
}
|
|
if filter, ok := parsePropertiesFilterParam(w, r.URL.Query().Get("properties")); !ok {
|
|
return
|
|
} else if filter != nil {
|
|
where = append(where, propertiesFilterPredicate(filter, addArg))
|
|
}
|
|
// Mirror the involves_user_id 4-branch UNION from sqlc's ListIssues /
|
|
// ListOpenIssues / CountIssues. ListGroupedIssues is a hand-written dynamic
|
|
// SQL builder that does not share parameters with sqlc, so the fragment is
|
|
// re-implemented here in lock-step. Member-direct assignment is excluded by
|
|
// design: that semantics belongs to tab 1 (`assignee_id`), and tab 3 must
|
|
// stay disjoint from tab 1.
|
|
if raw := r.URL.Query().Get("involves_user_id"); raw != "" {
|
|
id, ok := parseUUIDOrBadRequest(w, raw, "involves_user_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
ref := addArg(id)
|
|
where = append(where, fmt.Sprintf(`(
|
|
(i.assignee_type = 'agent' AND i.assignee_id IN (
|
|
SELECT a.id FROM agent a
|
|
WHERE a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
))
|
|
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
|
|
SELECT sm.squad_id
|
|
FROM squad_member sm
|
|
JOIN squad s ON s.id = sm.squad_id
|
|
WHERE s.workspace_id = $1
|
|
AND sm.member_type = 'member'
|
|
AND sm.member_id = %[1]s::uuid
|
|
UNION
|
|
SELECT s.id
|
|
FROM squad s
|
|
JOIN agent a ON a.id = s.leader_id
|
|
WHERE s.workspace_id = $1
|
|
AND a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
UNION
|
|
SELECT sm.squad_id
|
|
FROM squad_member sm
|
|
JOIN squad s ON s.id = sm.squad_id
|
|
JOIN agent a ON a.id = sm.member_id
|
|
WHERE s.workspace_id = $1
|
|
AND sm.member_type = 'agent'
|
|
AND a.workspace_id = $1
|
|
AND a.owner_id = %[1]s::uuid
|
|
))
|
|
)`, ref))
|
|
}
|
|
|
|
assigneeFilters, ok := parseActorFilterList(w, r.URL.Query().Get("assignee_filters"), "assignee_filters")
|
|
if !ok {
|
|
return
|
|
}
|
|
includeNoAssignee := r.URL.Query().Get("include_no_assignee") == "true"
|
|
if len(assigneeFilters) > 0 || includeNoAssignee {
|
|
ors := make([]string, 0, len(assigneeFilters)+1)
|
|
for _, filter := range assigneeFilters {
|
|
ors = append(ors, fmt.Sprintf(
|
|
"(i.assignee_type = %s::text AND i.assignee_id = %s::uuid)",
|
|
addArg(filter.actorType),
|
|
addArg(filter.actorID),
|
|
))
|
|
}
|
|
if includeNoAssignee {
|
|
ors = append(ors, "(i.assignee_type IS NULL AND i.assignee_id IS NULL)")
|
|
}
|
|
where = append(where, "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
creatorFilters, ok := parseActorFilterList(w, r.URL.Query().Get("creator_filters"), "creator_filters")
|
|
if !ok {
|
|
return
|
|
}
|
|
if len(creatorFilters) > 0 {
|
|
ors := make([]string, 0, len(creatorFilters))
|
|
for _, filter := range creatorFilters {
|
|
ors = append(ors, fmt.Sprintf(
|
|
"(i.creator_type = %s::text AND i.creator_id = %s::uuid)",
|
|
addArg(filter.actorType),
|
|
addArg(filter.actorID),
|
|
))
|
|
}
|
|
where = append(where, "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
projectIDs, ok := parseUUIDParamList(w, r.URL.Query().Get("project_ids"), "project_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
includeNoProject := r.URL.Query().Get("include_no_project") == "true"
|
|
if len(projectIDs) > 0 || includeNoProject {
|
|
ors := make([]string, 0, 2)
|
|
if len(projectIDs) > 0 {
|
|
ors = append(ors, fmt.Sprintf("i.project_id = ANY(%s::uuid[])", addArg(projectIDs)))
|
|
}
|
|
if includeNoProject {
|
|
ors = append(ors, "i.project_id IS NULL")
|
|
}
|
|
where = append(where, "("+strings.Join(ors, " OR ")+")")
|
|
}
|
|
|
|
labelIDs, ok := parseUUIDParamList(w, r.URL.Query().Get("label_ids"), "label_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
if len(labelIDs) > 0 {
|
|
where = append(where, fmt.Sprintf(
|
|
"EXISTS (SELECT 1 FROM issue_to_label itl WHERE itl.issue_id = i.id AND itl.label_id = ANY(%s::uuid[]))",
|
|
addArg(labelIDs),
|
|
))
|
|
}
|
|
|
|
dateFilter, ok := parseIssueDateFilter(w, r.URL.Query())
|
|
if !ok {
|
|
return
|
|
}
|
|
where = appendIssueDateFilter(where, addArg, dateFilter)
|
|
|
|
if groupAssigneeType := r.URL.Query().Get("group_assignee_type"); groupAssigneeType != "" {
|
|
if groupAssigneeType == "none" {
|
|
where = append(where, "(i.assignee_type IS NULL AND i.assignee_id IS NULL)")
|
|
} else {
|
|
if !isIssueActorType(groupAssigneeType) {
|
|
writeError(w, http.StatusBadRequest, "invalid group_assignee_type")
|
|
return
|
|
}
|
|
rawID := r.URL.Query().Get("group_assignee_id")
|
|
if rawID == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid group_assignee_id")
|
|
return
|
|
}
|
|
assigneeID, ok := parseUUIDOrBadRequest(w, rawID, "group_assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
where = append(where, fmt.Sprintf(
|
|
"(i.assignee_type = %s::text AND i.assignee_id = %s::uuid)",
|
|
addArg(groupAssigneeType),
|
|
addArg(assigneeID),
|
|
))
|
|
}
|
|
}
|
|
|
|
sortCol := "position"
|
|
sortIsExpr := false
|
|
sortIsProperty := false
|
|
if s := r.URL.Query().Get("sort"); s != "" {
|
|
switch s {
|
|
case "position", "title", "created_at", "updated_at", "start_date", "due_date":
|
|
sortCol = s
|
|
case "last_activity":
|
|
sortCol = "last_activity_at"
|
|
case "status":
|
|
var err error
|
|
sortCol, err = h.issueStatusSortExpression(r.Context(), wsUUID, addArg)
|
|
if err != nil {
|
|
slog.Warn("resolve grouped status sort failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve sort")
|
|
return
|
|
}
|
|
sortIsExpr = true
|
|
case "priority":
|
|
sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END"
|
|
sortIsExpr = true
|
|
default:
|
|
// property:<definitionId> sorts by the custom-property value
|
|
// (typed expression); unknown/archived definitions degrade to
|
|
// position order instead of erroring stale clients.
|
|
expr, handled, sortErr := h.propertySortExpr(r, workspaceID, s)
|
|
if !handled {
|
|
writeError(w, http.StatusBadRequest, "invalid sort value")
|
|
return
|
|
}
|
|
if sortErr != nil {
|
|
if sortErr.Error() == "invalid sort value" || sortErr.Error() == "invalid workspace id" {
|
|
writeError(w, http.StatusBadRequest, sortErr.Error())
|
|
return
|
|
}
|
|
slog.Warn("propertySortExpr failed", append(logger.RequestAttrs(r), "error", sortErr)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve sort")
|
|
return
|
|
}
|
|
if expr != "" {
|
|
sortCol = expr
|
|
sortIsExpr = true
|
|
sortIsProperty = true
|
|
}
|
|
}
|
|
}
|
|
sortDir := "ASC"
|
|
if sortCol == "last_activity_at" {
|
|
sortDir = "DESC"
|
|
}
|
|
if sortCol != "position" {
|
|
if d := r.URL.Query().Get("direction"); d != "" {
|
|
switch strings.ToLower(d) {
|
|
case "asc":
|
|
sortDir = "ASC"
|
|
case "desc":
|
|
sortDir = "DESC"
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid direction value")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
intraGroupOrder := sortCol
|
|
if !sortIsExpr {
|
|
intraGroupOrder = "i." + sortCol
|
|
}
|
|
intraGroupOrder += " " + sortDir
|
|
if sortCol == "start_date" || sortCol == "due_date" || sortCol == "last_activity_at" || sortIsProperty {
|
|
intraGroupOrder += " NULLS LAST"
|
|
}
|
|
// Unique final key — see ListIssues: created_at ties would otherwise make
|
|
// ROW_NUMBER() unstable across per-group offset pages.
|
|
if sortCol == "last_activity_at" {
|
|
intraGroupOrder += ", i.id DESC"
|
|
} else {
|
|
intraGroupOrder += ", i.created_at DESC, i.id DESC"
|
|
}
|
|
|
|
offsetRef := addArg(int64(offset))
|
|
limitRef := addArg(int64(limit))
|
|
query := fmt.Sprintf(`
|
|
WITH ranked AS (
|
|
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,
|
|
COUNT(*) OVER (PARTITION BY i.assignee_type, i.assignee_id) AS group_total,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY i.assignee_type, i.assignee_id
|
|
ORDER BY %s
|
|
) AS rn
|
|
FROM issue i
|
|
WHERE %s
|
|
)
|
|
SELECT
|
|
id, workspace_id, title, description, status, priority,
|
|
assignee_type, assignee_id, creator_type, creator_id,
|
|
parent_issue_id, position, start_date, due_date, created_at, updated_at, last_activity_at,
|
|
number, project_id, metadata, stage, properties, revision, duplicate_of_issue_id, group_total
|
|
FROM ranked
|
|
WHERE rn > %s AND rn <= %s + %s
|
|
ORDER BY
|
|
CASE assignee_type
|
|
WHEN 'member' THEN 0
|
|
WHEN 'agent' THEN 1
|
|
WHEN 'squad' THEN 2
|
|
ELSE 3
|
|
END,
|
|
assignee_type NULLS LAST,
|
|
assignee_id NULLS LAST,
|
|
rn`, intraGroupOrder, strings.Join(where, " AND "), offsetRef, offsetRef, limitRef)
|
|
|
|
rows, err := h.DB.Query(ctx, query, args...)
|
|
if err != nil {
|
|
slog.Warn("ListGroupedIssues query failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list grouped issues")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
groupedRows := []groupedIssueRow{}
|
|
for rows.Next() {
|
|
var row groupedIssueRow
|
|
if err := rows.Scan(
|
|
&row.ID,
|
|
&row.WorkspaceID,
|
|
&row.Title,
|
|
&row.Description,
|
|
&row.Status,
|
|
&row.Priority,
|
|
&row.AssigneeType,
|
|
&row.AssigneeID,
|
|
&row.CreatorType,
|
|
&row.CreatorID,
|
|
&row.ParentIssueID,
|
|
&row.Position,
|
|
&row.StartDate,
|
|
&row.DueDate,
|
|
&row.CreatedAt,
|
|
&row.UpdatedAt,
|
|
&row.LastActivityAt,
|
|
&row.Number,
|
|
&row.ProjectID,
|
|
&row.Metadata,
|
|
&row.Stage,
|
|
&row.Properties,
|
|
&row.Revision,
|
|
&row.DuplicateOfIssueID,
|
|
&row.GroupTotal,
|
|
); err != nil {
|
|
slog.Warn("ListGroupedIssues scan failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list grouped issues")
|
|
return
|
|
}
|
|
groupedRows = append(groupedRows, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("ListGroupedIssues rows failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list grouped issues")
|
|
return
|
|
}
|
|
|
|
ids := make([]pgtype.UUID, len(groupedRows))
|
|
var originals []pgtype.UUID
|
|
for i, row := range groupedRows {
|
|
ids[i] = row.ID
|
|
originals = appendDuplicateOriginal(originals, row.Status, row.DuplicateOfIssueID)
|
|
}
|
|
labelsMap := h.labelsByIssue(ctx, wsUUID, ids)
|
|
prefix := h.getIssuePrefix(ctx, wsUUID)
|
|
// One Resolver for the whole page — a per-row filler would query the
|
|
// catalog once per custom-status row. (MUL-6243)
|
|
fillGrouped := h.newStatusCategoryFiller(ctx, wsUUID, originals...)
|
|
|
|
groups := []IssueAssigneeGroupResponse{}
|
|
groupIndex := map[string]int{}
|
|
for _, row := range groupedRows {
|
|
groupID := assigneeGroupID(row.AssigneeType, row.AssigneeID)
|
|
idx, exists := groupIndex[groupID]
|
|
if !exists {
|
|
idx = len(groups)
|
|
groupIndex[groupID] = idx
|
|
groups = append(groups, IssueAssigneeGroupResponse{
|
|
ID: groupID,
|
|
AssigneeType: textToPtr(row.AssigneeType),
|
|
AssigneeID: uuidToPtr(row.AssigneeID),
|
|
Issues: []IssueResponse{},
|
|
Total: row.GroupTotal,
|
|
})
|
|
}
|
|
|
|
issue := issueListRowToResponse(row.ListIssuesRow, prefix)
|
|
fillGrouped(&issue)
|
|
labels := labelsMap[issue.ID]
|
|
if labels == nil {
|
|
labels = []LabelResponse{}
|
|
}
|
|
issue.Labels = &labels
|
|
groups[idx].Issues = append(groups[idx].Issues, issue)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, GroupedIssuesResponse{Groups: groups})
|
|
}
|
|
|
|
func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
|
resp := issueToResponse(issue, prefix)
|
|
h.fillStatusCategory(r.Context(), issue.WorkspaceID, &resp)
|
|
detailLabels := h.labelsByIssue(r.Context(), issue.WorkspaceID, []pgtype.UUID{issue.ID})[uuidToString(issue.ID)]
|
|
if detailLabels == nil {
|
|
detailLabels = []LabelResponse{}
|
|
}
|
|
resp.Labels = &detailLabels
|
|
|
|
// Fetch issue reactions.
|
|
reactions, err := h.Queries.ListIssueReactions(r.Context(), issue.ID)
|
|
if err == nil && len(reactions) > 0 {
|
|
resp.Reactions = make([]IssueReactionResponse, len(reactions))
|
|
for i, rx := range reactions {
|
|
resp.Reactions[i] = issueReactionToResponse(rx)
|
|
}
|
|
}
|
|
|
|
// Fetch issue-level attachments.
|
|
attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err == nil && len(attachments) > 0 {
|
|
mode := attachmentURLModeFromRequest(r)
|
|
resp.Attachments = make([]AttachmentResponse, len(attachments))
|
|
for i, a := range attachments {
|
|
resp.Attachments[i] = h.attachmentToResponse(a, mode)
|
|
}
|
|
}
|
|
|
|
if sourceContext, err := h.issueSourceContextDetail(r.Context(), issue); err == nil {
|
|
resp.SourceContext = sourceContext
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
slog.Warn("load issue source context failed", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "error", err)...)
|
|
// The frozen context is part of the issue's execution contract, not an
|
|
// optional decoration. Returning a successful detail response without it
|
|
// would let agents run with silently incomplete instructions.
|
|
writeError(w, http.StatusInternalServerError, "failed to load issue source context")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) ListChildIssues(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
children, err := h.Queries.ListChildIssues(r.Context(), issue.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list child issues")
|
|
return
|
|
}
|
|
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
|
ids := make([]pgtype.UUID, len(children))
|
|
for i, child := range children {
|
|
ids[i] = child.ID
|
|
}
|
|
labelsMap := h.labelsByIssue(r.Context(), issue.WorkspaceID, ids)
|
|
// Sub-issue progress is computed from these rows (the CLI's `issue children`
|
|
// stage counts, among others), so they carry the resolved category — a
|
|
// custom completed status must count as completed. One Resolver for the whole list:
|
|
// built-in statuses still cost no query, and a list full of custom ones
|
|
// costs one catalog read rather than one per row.
|
|
statusResolver := issuestatus.NewResolver(issue.WorkspaceID)
|
|
resp := make([]IssueResponse, len(children))
|
|
for i, child := range children {
|
|
resp[i] = issueToResponse(child, prefix)
|
|
resp[i].StatusCategory = issuestatus.WireCategory(child.Status, statusResolver.Category(r.Context(), h.Queries, child.Status))
|
|
labels := labelsMap[resp[i].ID]
|
|
if labels == nil {
|
|
labels = []LabelResponse{}
|
|
}
|
|
resp[i].Labels = &labels
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"issues": resp,
|
|
})
|
|
}
|
|
|
|
// Cap on the number of parents we'll fan-out children for in one request.
|
|
// Swimlane's visible-lane count is naturally bounded by what fits on screen
|
|
// (typically <= 50), but cap explicitly so a malicious caller can't ANY()
|
|
// across the whole workspace's issue set in a single round trip.
|
|
const listChildrenByParentsLimit = 200
|
|
|
|
// ListChildrenByParents returns the union of children for the
|
|
// provided parent ids. Replaces the N-call fan-out Swimlane would otherwise
|
|
// have to make on mount (one /issues/:id/children per visible parent lane).
|
|
//
|
|
// Workspace scope is enforced at the query level — any parent_id that doesn't
|
|
// belong to the caller's workspace simply yields zero children, so callers
|
|
// can't probe parents across workspace boundaries.
|
|
func (h *Handler) ListChildrenByParents(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
raw := r.URL.Query().Get("parent_ids")
|
|
if raw == "" {
|
|
// Empty input is a no-op response (not an error) — simplifies the
|
|
// client which calls this unconditionally on Swimlane mount even
|
|
// when there are zero visible parent lanes.
|
|
writeJSON(w, http.StatusOK, map[string]any{"issues": []IssueResponse{}})
|
|
return
|
|
}
|
|
|
|
parts := strings.Split(raw, ",")
|
|
if len(parts) > listChildrenByParentsLimit {
|
|
writeError(w, http.StatusBadRequest, "too many parent_ids")
|
|
return
|
|
}
|
|
parentIDs := make([]pgtype.UUID, 0, len(parts))
|
|
for _, s := range parts {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
id, ok := parseUUIDOrBadRequest(w, s, "parent_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
parentIDs = append(parentIDs, id)
|
|
}
|
|
if len(parentIDs) == 0 {
|
|
writeJSON(w, http.StatusOK, map[string]any{"issues": []IssueResponse{}})
|
|
return
|
|
}
|
|
|
|
children, err := h.Queries.ListChildrenByParents(r.Context(), db.ListChildrenByParentsParams{
|
|
WorkspaceID: wsUUID,
|
|
ParentIds: parentIDs,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list child issues")
|
|
return
|
|
}
|
|
prefix := h.getIssuePrefix(r.Context(), wsUUID)
|
|
ids := make([]pgtype.UUID, len(children))
|
|
for i, child := range children {
|
|
ids[i] = child.ID
|
|
}
|
|
labelsMap := h.labelsByIssue(r.Context(), wsUUID, ids)
|
|
// Sub-issue progress is computed from these rows (the CLI's `issue children`
|
|
// stage counts, among others), so they carry the resolved category — a
|
|
// custom completed status must count as completed. One Resolver for the whole list:
|
|
// built-in statuses still cost no query, and a list full of custom ones
|
|
// costs one catalog read rather than one per row.
|
|
statusResolver := issuestatus.NewResolver(wsUUID)
|
|
resp := make([]IssueResponse, len(children))
|
|
for i, child := range children {
|
|
resp[i] = issueToResponse(child, prefix)
|
|
resp[i].StatusCategory = issuestatus.WireCategory(child.Status, statusResolver.Category(r.Context(), h.Queries, child.Status))
|
|
labels := labelsMap[resp[i].ID]
|
|
if labels == nil {
|
|
labels = []LabelResponse{}
|
|
}
|
|
resp[i].Labels = &labels
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"issues": resp,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) {
|
|
wsID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
terminalStatusKeys, err := h.terminalIssueStatusKeys(r.Context(), wsUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve status categories")
|
|
return
|
|
}
|
|
rows, err := h.Queries.ChildIssueProgress(r.Context(), db.ChildIssueProgressParams{
|
|
WorkspaceID: wsUUID,
|
|
TerminalStatusKeys: terminalStatusKeys,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to get child issue progress")
|
|
return
|
|
}
|
|
|
|
type progressEntry struct {
|
|
ParentIssueID string `json:"parent_issue_id"`
|
|
Total int64 `json:"total"`
|
|
Done int64 `json:"done"`
|
|
}
|
|
resp := make([]progressEntry, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = progressEntry{
|
|
ParentIssueID: uuidToString(row.ParentIssueID),
|
|
Total: row.Total,
|
|
Done: row.Done,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"progress": resp,
|
|
})
|
|
}
|
|
|
|
// QuickCreateIssueRequest is the body for POST /api/issues/quick-create. The
|
|
// user picks an actor (agent or squad) in the modal and types one line of
|
|
// natural language; the server validates the actor's reachability up front,
|
|
// queues a quick-create task, and returns 202 immediately. The agent
|
|
// translates the prompt into a `multica issue create` invocation in the
|
|
// background; success and failure both surface as inbox notifications to
|
|
// the requester.
|
|
//
|
|
// Exactly one of AgentID / SquadID is required. When SquadID is set, the
|
|
// task is enqueued against the squad's leader agent and the leader receives
|
|
// the same Operating Protocol briefing it would for an issue assigned to
|
|
// the squad, so it can choose to delegate to a squad member as usual.
|
|
//
|
|
// ProjectID is optional and lets the modal target a specific project so
|
|
// the agent's `multica issue create` invocation passes `--project <uuid>`
|
|
// instead of letting it default. The frontend remembers the user's last
|
|
// pick per workspace, so frequent users skip retyping "in project X".
|
|
//
|
|
// ParentIssueID is optional and is set by the "Add sub issue" entry point
|
|
// when the modal is opened from an existing issue. The agent passes it
|
|
// through as `--parent <uuid>` so the new issue is filed as a sub-issue,
|
|
// keeping the sub-issue intent of the entry point regardless of whether
|
|
// the user submits via manual or agent mode.
|
|
type QuickCreateIssueRequest struct {
|
|
AgentID string `json:"agent_id,omitempty"`
|
|
SquadID string `json:"squad_id,omitempty"`
|
|
Prompt string `json:"prompt"`
|
|
Priority string `json:"priority,omitempty"`
|
|
DueDate string `json:"due_date,omitempty"`
|
|
ProjectID string `json:"project_id,omitempty"`
|
|
ParentIssueID string `json:"parent_issue_id,omitempty"`
|
|
AttachmentIDs []string `json:"attachment_ids,omitempty"`
|
|
}
|
|
|
|
// QuickCreateIssueResponse echoes the queued task id so the frontend can
|
|
// correlate the eventual inbox item, even though completion is fully async.
|
|
type QuickCreateIssueResponse struct {
|
|
TaskID string `json:"task_id"`
|
|
}
|
|
|
|
func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
|
|
var req QuickCreateIssueRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
prompt := strings.TrimSpace(req.Prompt)
|
|
if prompt == "" {
|
|
writeError(w, http.StatusBadRequest, "prompt is required")
|
|
return
|
|
}
|
|
priority := strings.ToLower(strings.TrimSpace(req.Priority))
|
|
if priority != "" && priority != "urgent" && priority != "high" && priority != "medium" && priority != "low" {
|
|
writeError(w, http.StatusBadRequest, "priority must be one of: urgent, high, medium, low")
|
|
return
|
|
}
|
|
dueDate := strings.TrimSpace(req.DueDate)
|
|
if dueDate != "" {
|
|
parsed, err := util.ParseCalendarDate(dueDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
|
|
return
|
|
}
|
|
dueDate = parsed.Time.Format("2006-01-02")
|
|
}
|
|
|
|
hasAgent := strings.TrimSpace(req.AgentID) != ""
|
|
hasSquad := strings.TrimSpace(req.SquadID) != ""
|
|
if hasAgent == hasSquad {
|
|
writeError(w, http.StatusBadRequest, "exactly one of agent_id or squad_id is required")
|
|
return
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
requesterID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
requesterUUID, ok := parseUUIDOrBadRequest(w, requesterID, "requester_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Resolve the actor to the agent that will actually run the task. For
|
|
// agent picks that's the agent itself; for squad picks it's the squad's
|
|
// leader agent. The leader receives a squad-leader briefing on dispatch
|
|
// (see daemon.go), matching the behavior of an issue assigned to the
|
|
// squad — picking a squad here is functionally "ask the squad leader to
|
|
// create this issue, on behalf of the squad".
|
|
var agentUUID pgtype.UUID
|
|
var squadUUID pgtype.UUID
|
|
if hasSquad {
|
|
var ok bool
|
|
squadUUID, ok = parseUUIDOrBadRequest(w, req.SquadID, "squad_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
|
|
ID: squadUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "squad not found")
|
|
return
|
|
}
|
|
if squad.ArchivedAt.Valid {
|
|
writeError(w, http.StatusBadRequest, "squad is archived")
|
|
return
|
|
}
|
|
agentUUID = squad.LeaderID
|
|
} else {
|
|
var ok bool
|
|
agentUUID, ok = parseUUIDOrBadRequest(w, req.AgentID, "agent_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Reuse the same workspace-membership / archived / private-agent
|
|
// ownership rules as `validateAssigneePair` so a user can't POST a
|
|
// private agent_id they shouldn't be able to dispatch (the frontend
|
|
// filters them out, but the handler is the trust boundary). Squad
|
|
// picks reach this with the resolved leader agent; the same rules
|
|
// apply — a private leader behind a squad the user can't reach
|
|
// should still be rejected.
|
|
if status, msg := h.validateAssigneePair(
|
|
r.Context(), r, workspaceID,
|
|
pgtype.Text{String: "agent", Valid: true},
|
|
agentUUID,
|
|
); status != 0 {
|
|
writeError(w, status, msg)
|
|
return
|
|
}
|
|
|
|
// Re-load the agent for the runtime liveness check below. Safe by
|
|
// construction: validateAssigneePair just confirmed it exists in this
|
|
// workspace and the caller has visibility.
|
|
agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: agentUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "agent not found")
|
|
return
|
|
}
|
|
// Quick-create needs the agent to run NOW, so any non-ready verdict refuses
|
|
// — but with the verdict's own code, so "CLI cannot run" no longer arrives
|
|
// as "runtime is offline" and sends the user to reconnect a machine that is
|
|
// already connected (MUL-6164).
|
|
if verdict, err := service.AgentReadiness(r.Context(), h.runtimeLookup(obsmetrics.RuntimeLookupSourceIssue), agent); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to check agent runtime")
|
|
return
|
|
} else if !verdict.Ready() {
|
|
writeAgentUnavailable(w, verdict.Detail, verdict.Reason)
|
|
return
|
|
}
|
|
|
|
// Daemon CLI version gate. The agent-side prompt + create-flow rely on
|
|
// behaviors introduced in MinQuickCreateCLIVersion (URL attachment
|
|
// handling, quick-create attachment binding, no-retry on partial failure).
|
|
// Older daemons either double-create issues on partial CLI failures, drop
|
|
// attachment bindings, or mishandle pasted screenshot URLs; fail closed
|
|
// before enqueuing rather than surface the breakage as an inbox failure
|
|
// twenty seconds later. Dev-built
|
|
// daemons (git-describe shape) are exempted inside CheckMinCLIVersion
|
|
// so `make daemon` works without weakening staging or production.
|
|
if status, payload := h.checkQuickCreateDaemonVersion(r.Context(), obsmetrics.RuntimeLookupSourceIssue, agent.RuntimeID); status != 0 {
|
|
writeJSON(w, status, payload)
|
|
return
|
|
}
|
|
if priority != "" || dueDate != "" {
|
|
if status, payload := h.checkQuickCreateDaemonVersionAtLeast(
|
|
r.Context(), obsmetrics.RuntimeLookupSourceIssue, agent.RuntimeID, agentpkg.MinQuickCreateFieldsCLIVersion,
|
|
); status != 0 {
|
|
writeJSON(w, status, payload)
|
|
return
|
|
}
|
|
}
|
|
|
|
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Optional project_id — validate it belongs to the same workspace before
|
|
// pinning the task to it. The handler is the trust boundary; the frontend
|
|
// already only shows projects from the active workspace, but we re-check
|
|
// here so a forged request can't smuggle a foreign project ID through.
|
|
var projectUUID pgtype.UUID
|
|
if strings.TrimSpace(req.ProjectID) != "" {
|
|
pid, ok := parseUUIDOrBadRequest(w, req.ProjectID, "project_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
|
|
ID: pid,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusBadRequest, "project not found")
|
|
return
|
|
}
|
|
projectUUID = pid
|
|
}
|
|
|
|
// Optional parent_issue_id — validate same-workspace membership just like
|
|
// the regular CreateIssue path. Frontend seeds this from the "Add sub
|
|
// issue" entry, but the handler re-checks so a forged request can't
|
|
// smuggle a foreign parent UUID through.
|
|
var parentIssueUUID pgtype.UUID
|
|
if strings.TrimSpace(req.ParentIssueID) != "" {
|
|
pid, ok := parseUUIDOrBadRequest(w, req.ParentIssueID, "parent_issue_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: pid,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil || !parent.ID.Valid {
|
|
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
|
return
|
|
}
|
|
parentIssueUUID = pid
|
|
}
|
|
|
|
task, err := h.TaskService.EnqueueQuickCreateTask(r.Context(), wsUUID, requesterUUID, agentUUID, squadUUID, prompt, priority, dueDate, projectUUID, parentIssueUUID, attachmentIDs)
|
|
if err != nil {
|
|
if writeIssueLimitReached(w, err) {
|
|
return
|
|
}
|
|
slog.Warn("quick-create enqueue failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to enqueue quick-create task")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusAccepted, QuickCreateIssueResponse{TaskID: uuidToString(task.ID)})
|
|
}
|
|
|
|
// writeAgentUnavailable returns 422 with a stable error code so the modal
|
|
// can show a "switch agent" hint without parsing the human-readable reason.
|
|
// writeAgentUnavailable refuses a trigger whose agent cannot run. `code` stays
|
|
// agent_unavailable for installed clients; reason_code carries the machine-
|
|
// readable distinction they need to phrase the fix — agent_runtime_required
|
|
// ("bind a runtime") is not runtime_offline ("reconnect the machine").
|
|
func writeAgentUnavailable(w http.ResponseWriter, reason string, reasonCode dispatch.ReasonCode) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"code": "agent_unavailable",
|
|
"reason": reason,
|
|
"reason_code": reasonCode,
|
|
})
|
|
}
|
|
|
|
// isRuntimeOnline returns true when the given runtime is currently
|
|
// reachable (status == "online"). Quick-create rejects submissions whose
|
|
// agent's runtime is offline so the user gets immediate feedback in the
|
|
// modal instead of an inbox failure twenty seconds later.
|
|
func (h *Handler) isRuntimeOnline(ctx context.Context, runtimeID pgtype.UUID) bool {
|
|
rt, err := h.getAgentRuntime(ctx, obsmetrics.RuntimeLookupSourceIssue, runtimeID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rt.Status == "online"
|
|
}
|
|
|
|
// checkQuickCreateDaemonVersion enforces MinQuickCreateCLIVersion against the
|
|
// CLI version the daemon reported at registration time (stored on the runtime
|
|
// row's metadata.cli_version). Returns (0, nil) when the version is
|
|
// acceptable, otherwise (status, payload) ready to hand to writeJSON.
|
|
//
|
|
// Failure shape is stable so the modal can branch on the `code` field and
|
|
// surface a "needs upgrade" hint that points at the specific runtime:
|
|
//
|
|
// 422 {
|
|
// "code": "daemon_version_unsupported",
|
|
// "current_version": "0.2.18" | "",
|
|
// "min_version": "0.2.21",
|
|
// "runtime_id": "<uuid>"
|
|
// }
|
|
func (h *Handler) checkQuickCreateDaemonVersion(ctx context.Context, source string, runtimeID pgtype.UUID) (int, map[string]any) {
|
|
return h.checkQuickCreateDaemonVersionAtLeast(ctx, source, runtimeID, agentpkg.MinQuickCreateCLIVersion)
|
|
}
|
|
|
|
func (h *Handler) checkQuickCreateDaemonVersionAtLeast(ctx context.Context, source string, runtimeID pgtype.UUID, minimum string) (int, map[string]any) {
|
|
rt, err := h.getAgentRuntime(ctx, source, runtimeID)
|
|
if err != nil {
|
|
// Runtime row vanished between the online check and here — treat
|
|
// as unavailable rather than wedging the request on a 500.
|
|
return http.StatusUnprocessableEntity, map[string]any{
|
|
"code": "agent_unavailable",
|
|
"reason": "agent's runtime is no longer registered",
|
|
}
|
|
}
|
|
current := readRuntimeCLIVersion(rt.Metadata)
|
|
switch err := agentpkg.CheckMinCLIVersionFor(current, minimum); {
|
|
case err == nil:
|
|
return 0, nil
|
|
case errors.Is(err, agentpkg.ErrCLIVersionMissing), errors.Is(err, agentpkg.ErrCLIVersionTooOld):
|
|
return http.StatusUnprocessableEntity, map[string]any{
|
|
"code": "daemon_version_unsupported",
|
|
"current_version": current,
|
|
"min_version": minimum,
|
|
"runtime_id": uuidToString(runtimeID),
|
|
}
|
|
default:
|
|
// Defensive fall-through: unknown error from the version check is
|
|
// also fail-closed, since the gate exists precisely because we
|
|
// can't trust older daemons with this flow.
|
|
return http.StatusUnprocessableEntity, map[string]any{
|
|
"code": "daemon_version_unsupported",
|
|
"current_version": current,
|
|
"min_version": minimum,
|
|
"runtime_id": uuidToString(runtimeID),
|
|
}
|
|
}
|
|
}
|
|
|
|
// readRuntimeCLIVersion pulls metadata.cli_version off a runtime row. The
|
|
// metadata column is JSONB on the wire; the daemon stores the multica CLI
|
|
// version under that key during registration (see DaemonRegister).
|
|
func readRuntimeCLIVersion(metadata []byte) string {
|
|
if len(metadata) == 0 {
|
|
return ""
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(metadata, &m); err != nil {
|
|
return ""
|
|
}
|
|
if v, ok := m["cli_version"].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type CreateIssueRequest struct {
|
|
Title string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Status string `json:"status"`
|
|
Priority string `json:"priority"`
|
|
AssigneeType *string `json:"assignee_type"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
ParentIssueID *string `json:"parent_issue_id"`
|
|
ProjectID *string `json:"project_id"`
|
|
Stage *int32 `json:"stage,omitempty"`
|
|
StartDate *string `json:"start_date"`
|
|
DueDate *string `json:"due_date"`
|
|
AttachmentIDs []string `json:"attachment_ids,omitempty"`
|
|
// LabelIDs are issue-scoped labels to attach to the new issue in the same
|
|
// transaction as the create. Unknown or non-issue ids are rejected with
|
|
// 400 (service.ErrIssueLabelNotFound) rather than silently dropped.
|
|
LabelIDs []string `json:"label_ids,omitempty"`
|
|
// Properties is an ID-keyed bag whose values use the same typed wire shape
|
|
// as Issue.properties and the standalone property PUT endpoint.
|
|
Properties map[string]json.RawMessage `json:"properties,omitempty"`
|
|
// OriginType / OriginID stamp the new issue with its provenance so
|
|
// platform-internal flows can deterministically locate it later. Only
|
|
// trusted callers should set these — currently the daemon CLI passes
|
|
// them through for quick-create tasks (origin_type=quick_create,
|
|
// origin_id=agent_task_queue.id).
|
|
OriginType *string `json:"origin_type,omitempty"`
|
|
OriginID *string `json:"origin_id,omitempty"`
|
|
|
|
AllowDuplicate bool `json:"allow_duplicate,omitempty"`
|
|
}
|
|
|
|
// UnmarshalJSON rejects duplicate object members before Go's ordinary map
|
|
// decoding can silently apply last-wins semantics. This is especially
|
|
// important for properties: two values for one definition must reject the
|
|
// whole create, never choose one implicitly.
|
|
func (r *CreateIssueRequest) UnmarshalJSON(data []byte) error {
|
|
if err := rejectDuplicateJSONKeys(data); err != nil {
|
|
return err
|
|
}
|
|
type createIssueRequestAlias CreateIssueRequest
|
|
var decoded createIssueRequestAlias
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
return err
|
|
}
|
|
*r = CreateIssueRequest(decoded)
|
|
return nil
|
|
}
|
|
|
|
func rejectDuplicateJSONKeys(data []byte) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.UseNumber()
|
|
if err := rejectDuplicateJSONValue(decoder); err != nil {
|
|
return err
|
|
}
|
|
if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
|
|
if err == nil {
|
|
return errors.New("request body must contain one JSON value")
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func rejectDuplicateJSONValue(decoder *json.Decoder) error {
|
|
token, err := decoder.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
delimiter, ok := token.(json.Delim)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
switch delimiter {
|
|
case '{':
|
|
seen := make(map[string]struct{})
|
|
for decoder.More() {
|
|
keyToken, err := decoder.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key, ok := keyToken.(string)
|
|
if !ok {
|
|
return errors.New("object key must be a string")
|
|
}
|
|
if _, duplicate := seen[key]; duplicate {
|
|
return fmt.Errorf("duplicate JSON key %q", key)
|
|
}
|
|
seen[key] = struct{}{}
|
|
if err := rejectDuplicateJSONValue(decoder); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = decoder.Token()
|
|
return err
|
|
case '[':
|
|
for decoder.More() {
|
|
if err := rejectDuplicateJSONValue(decoder); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = decoder.Token()
|
|
return err
|
|
default:
|
|
return errors.New("invalid JSON delimiter")
|
|
}
|
|
}
|
|
|
|
func parseIssueCreateProperties(w http.ResponseWriter, values map[string]json.RawMessage) (map[pgtype.UUID]json.RawMessage, bool) {
|
|
if len(values) == 0 {
|
|
return nil, true
|
|
}
|
|
parsed := make(map[pgtype.UUID]json.RawMessage, len(values))
|
|
for propertyID, value := range values {
|
|
id, err := util.ParseUUID(propertyID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"code": "invalid_issue_property", "property_id": propertyID,
|
|
"error": "property id must be a UUID",
|
|
})
|
|
return nil, false
|
|
}
|
|
if _, duplicate := parsed[id]; duplicate {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"code": "invalid_issue_property", "property_id": propertyID,
|
|
"error": "property was provided more than once",
|
|
})
|
|
return nil, false
|
|
}
|
|
parsed[id] = value
|
|
}
|
|
return parsed, true
|
|
}
|
|
|
|
func writeIssueCreatePropertyError(w http.ResponseWriter, err error) bool {
|
|
var propertyErr *service.IssuePropertyValidationError
|
|
if errors.As(err, &propertyErr) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"code": "invalid_issue_property", "property_id": propertyErr.PropertyID,
|
|
"error": propertyErr.Message,
|
|
})
|
|
return true
|
|
}
|
|
if errors.Is(err, service.ErrIssuePropertiesTooLarge) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"code": "issue_properties_too_large", "error": err.Error(),
|
|
})
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func duplicateIssueMessage(issue IssueResponse) string {
|
|
return issueguard.DuplicateMessage(issue.Identifier, issue.Title, issue.Status)
|
|
}
|
|
|
|
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|
r = h.withWakeupActor(r)
|
|
var req CreateIssueRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Title == "" {
|
|
writeError(w, http.StatusBadRequest, "title is required")
|
|
return
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Get creator from context (set by auth middleware)
|
|
creatorID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
status := req.Status
|
|
if status == "" {
|
|
status = "todo"
|
|
}
|
|
priority := req.Priority
|
|
if priority == "" {
|
|
priority = "none"
|
|
}
|
|
status, ok = h.resolveIssueStatusKey(w, r, wsUUID, status)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validateIssueEnum(w, "priority", priority, validIssuePriorities) {
|
|
return
|
|
}
|
|
if req.Stage != nil && *req.Stage < 1 {
|
|
writeError(w, http.StatusBadRequest, "stage must be >= 1")
|
|
return
|
|
}
|
|
|
|
var assigneeType pgtype.Text
|
|
var assigneeID pgtype.UUID
|
|
if req.AssigneeType != nil {
|
|
assigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
|
|
}
|
|
if req.AssigneeID != nil {
|
|
id, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
assigneeID = id
|
|
}
|
|
|
|
var parentIssueID pgtype.UUID
|
|
var projectID pgtype.UUID
|
|
if req.ParentIssueID != nil {
|
|
id, ok := parseUUIDOrBadRequest(w, *req.ParentIssueID, "parent_issue_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
parentIssueID = id
|
|
// The parent is loaded only to reject a cross-workspace or missing one
|
|
// BEFORE the assignee gate runs, so the caller gets 400 "parent issue not
|
|
// found" rather than a 403 that leaks nothing about which input was wrong.
|
|
// The row itself is no longer needed: the assignee gate keys on the actor's
|
|
// originator, not on a scope bound to the parent (MUL-6951).
|
|
if assigneeType.Valid && (assigneeType.String == "agent" || assigneeType.String == "squad") {
|
|
parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: parentIssueID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil || !parent.ID.Valid {
|
|
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if status, msg := h.validateAssigneePair(r.Context(), r, workspaceID, assigneeType, assigneeID); status != 0 {
|
|
writeError(w, status, msg)
|
|
return
|
|
}
|
|
|
|
if req.ProjectID != nil {
|
|
id, ok := parseUUIDOrBadRequest(w, *req.ProjectID, "project_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID = id
|
|
}
|
|
|
|
// Project workflow (MUL-7420). A new issue in a project on a named
|
|
// workflow must start on a status the workflow lists, and a column the
|
|
// project hands off gives the new issue to its handler unless the caller
|
|
// chose an assignee. A sub-issue without its own project inherits the
|
|
// parent's, as IssueService.Create does.
|
|
workflowProjectID := projectID
|
|
if !workflowProjectID.Valid && parentIssueID.Valid {
|
|
if parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: parentIssueID, WorkspaceID: wsUUID,
|
|
}); err == nil {
|
|
workflowProjectID = parent.ProjectID
|
|
}
|
|
}
|
|
createWorkflow, createWorkflowProject, err := issueworkflow.ForProject(r.Context(), h.Queries, wsUUID, workflowProjectID)
|
|
if err != nil {
|
|
slog.Error("create issue: resolve project workflow", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve project workflow")
|
|
return
|
|
}
|
|
if createWorkflow != nil {
|
|
if err := issueworkflow.CheckStatus(createWorkflow, status); err != nil {
|
|
writeIssueWorkflowError(w, err)
|
|
return
|
|
}
|
|
if req.AssigneeType == nil && req.AssigneeID == nil {
|
|
if step, ok := createWorkflow.Step(status); ok && step.HandsOff() {
|
|
handoffCreatorType, handoffCreatorID := h.resolveActor(r, creatorID, workspaceID)
|
|
if t, id, ok := issueworkflow.ResolveHandler(step, createWorkflowProject, handoffCreatorType, parseUUID(handoffCreatorID)); ok {
|
|
assigneeType = pgtype.Text{String: t, Valid: true}
|
|
assigneeID = id
|
|
if status, msg := h.validateAssigneePair(r.Context(), r, workspaceID, assigneeType, assigneeID); status != 0 {
|
|
writeError(w, status, msg)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Project existence and the final parent boundary check are enforced inside
|
|
// IssueService.Create atomically with the create. The handler preloads a
|
|
// supplied parent only because the assignee gate must bind any autopilot
|
|
// authority fallback to that server-verified issue before admission.
|
|
|
|
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
labelIDs, ok := parseUUIDSliceOrBadRequest(w, req.LabelIDs, "label_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
properties, ok := parseIssueCreateProperties(w, req.Properties)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var startDate pgtype.Date
|
|
if req.StartDate != nil && *req.StartDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.StartDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
|
|
return
|
|
}
|
|
startDate = d
|
|
}
|
|
|
|
var dueDate pgtype.Date
|
|
if req.DueDate != nil && *req.DueDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.DueDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
|
|
return
|
|
}
|
|
dueDate = d
|
|
}
|
|
|
|
// Determine creator identity: agent (via X-Agent-ID header) or member.
|
|
creatorType, actualCreatorID := h.resolveActor(r, creatorID, workspaceID)
|
|
|
|
// Optional origin stamping (quick-create / autopilot). Only the
|
|
// allowed origin types are accepted; anything else is rejected so a
|
|
// rogue caller can't mint arbitrary origin labels. Both fields must
|
|
// be provided together.
|
|
var originType pgtype.Text
|
|
var originID pgtype.UUID
|
|
if req.OriginType != nil || req.OriginID != nil {
|
|
if req.OriginType == nil || req.OriginID == nil {
|
|
writeError(w, http.StatusBadRequest, "origin_type and origin_id must be provided together")
|
|
return
|
|
}
|
|
switch *req.OriginType {
|
|
case "quick_create":
|
|
// Allowed — daemon CLI passes this through from a quick-create task.
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "unsupported origin_type")
|
|
return
|
|
}
|
|
oid, ok := parseUUIDOrBadRequest(w, *req.OriginID, "origin_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
originType = pgtype.Text{String: *req.OriginType, Valid: true}
|
|
originID = oid
|
|
} else if creatorType == "agent" {
|
|
// MUL-4305: an agent creating an issue via the ordinary create path
|
|
// carries no explicit origin, which historically left the new issue
|
|
// unattributed. Any run later derived from it (agent assignment,
|
|
// squad-leader trigger) then lost the top-of-chain human originator,
|
|
// so A2A @-mentions from those runs failed the canInvokeAgent gate
|
|
// against private agents. Stamp the acting task as the issue's origin
|
|
// so resolveOriginatorForIssueTask can inherit its originator — the
|
|
// same trick CreateComment uses with comment.source_task_id (MUL-4015).
|
|
//
|
|
// The task id is taken from the SERVER-trusted X-Task-ID: the auth
|
|
// middleware deletes whatever the client sent and re-stamps
|
|
// X-Agent-ID / X-Task-ID only from a validated mat_ token (MUL-3428), so
|
|
// a member-forged pair never reaches here — it is gone before
|
|
// resolveActor runs, and the request resolves to creatorType=="member".
|
|
// We still re-check the task belongs to the acting agent before trusting
|
|
// it.
|
|
if taskIDHeader := r.Header.Get("X-Task-ID"); taskIDHeader != "" {
|
|
if taskUUID, perr := util.ParseUUID(taskIDHeader); perr == nil {
|
|
if task, terr := h.Queries.GetAgentTask(r.Context(), taskUUID); terr == nil && uuidToString(task.AgentID) == actualCreatorID {
|
|
originType = pgtype.Text{String: "agent_create", Valid: true}
|
|
originID = taskUUID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prefix is workspace-level; pre-compute once so both the broadcast
|
|
// payload builder and the HTTP response share the same value.
|
|
prefix := h.getIssuePrefix(r.Context(), wsUUID)
|
|
|
|
// One filler for this create, shared by the broadcast payload and the HTTP
|
|
// response below, so a custom-status create reads the catalog once per
|
|
// request rather than once per payload. (MUL-6243)
|
|
fillCreated := h.newStatusCategoryFiller(r.Context(), wsUUID)
|
|
|
|
// Analytics agent ID: assignee agent when the issue is being assigned
|
|
// to an agent, otherwise the creator agent for agent-authored issues.
|
|
// Resolved here (not in the service) because creator identity is HTTP-side.
|
|
analyticsAgentID := ""
|
|
if assigneeType.Valid && assigneeType.String == "agent" {
|
|
analyticsAgentID = uuidToString(assigneeID)
|
|
}
|
|
if creatorType == "agent" && analyticsAgentID == "" {
|
|
analyticsAgentID = actualCreatorID
|
|
}
|
|
|
|
attachmentMode := attachmentURLModeFromRequest(r)
|
|
buildAttachmentResponses := func(atts []db.Attachment) []AttachmentResponse {
|
|
if len(atts) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]AttachmentResponse, len(atts))
|
|
for i, a := range atts {
|
|
out[i] = h.attachmentToResponse(a, attachmentMode)
|
|
}
|
|
return out
|
|
}
|
|
|
|
res, err := h.IssueService.Create(r.Context(), service.IssueCreateParams{
|
|
WorkspaceID: wsUUID,
|
|
Title: req.Title,
|
|
Description: ptrToText(req.Description),
|
|
Status: status,
|
|
Priority: priority,
|
|
AssigneeType: assigneeType,
|
|
AssigneeID: assigneeID,
|
|
CreatorType: creatorType,
|
|
CreatorID: parseUUID(actualCreatorID),
|
|
ParentIssueID: parentIssueID,
|
|
ProjectID: projectID,
|
|
StartDate: startDate,
|
|
DueDate: dueDate,
|
|
OriginType: originType,
|
|
OriginID: originID,
|
|
Stage: ptrToInt4(req.Stage),
|
|
AttachmentIDs: attachmentIDs,
|
|
LabelIDs: labelIDs,
|
|
Properties: properties,
|
|
AllowDuplicate: req.AllowDuplicate,
|
|
}, service.IssueCreateOpts{
|
|
ActorID: actualCreatorID,
|
|
AnalyticsAgentID: analyticsAgentID,
|
|
Platform: func() string { p, _, _ := middleware.ClientMetadataFromContext(r.Context()); return p }(),
|
|
BroadcastPayload: func(issue db.Issue, atts []db.Attachment, labels []db.IssueLabel) map[string]any {
|
|
payload := issueToResponse(issue, prefix)
|
|
// The event other tabs receive must carry the category too — filling
|
|
// only the HTTP response below is too late for them, and a create
|
|
// they cannot bucket forces a full refetch. Shares one filler with
|
|
// the HTTP response so a custom-status create reads the catalog once
|
|
// per request, not once per payload. (MUL-6243)
|
|
fillCreated(&payload)
|
|
payload.Attachments = buildAttachmentResponses(atts)
|
|
// Carry the authoritative label snapshot so every online client
|
|
// renders the new issue already labeled. Non-nil (even empty)
|
|
// pointer = authoritative list; the old flow's separate
|
|
// issue_labels:changed broadcast is gone.
|
|
labelResponses := labelsToResponse(labels)
|
|
payload.Labels = &labelResponses
|
|
return map[string]any{"issue": payload}
|
|
},
|
|
})
|
|
|
|
if errors.Is(err, service.ErrActiveDuplicate) {
|
|
dup := *res.DuplicateIssue
|
|
existing := issueToResponse(dup, h.getIssuePrefix(r.Context(), dup.WorkspaceID))
|
|
h.fillStatusCategory(r.Context(), dup.WorkspaceID, &existing)
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"code": "active_duplicate_issue",
|
|
"error": duplicateIssueMessage(existing),
|
|
"issue": existing,
|
|
})
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrParentIssueNotFound) {
|
|
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrProjectNotFound) {
|
|
writeError(w, http.StatusBadRequest, "project not found in this workspace")
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrIssueLabelNotFound) {
|
|
writeError(w, http.StatusBadRequest, "one or more labels not found in this workspace")
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrIssueStatusUnavailable) {
|
|
writeError(w, http.StatusConflict,
|
|
"the target status was archived while this request was in flight; reload the status list and retry")
|
|
return
|
|
}
|
|
if writeIssueCreatePropertyError(w, err) {
|
|
return
|
|
}
|
|
if writeIssueLimitReached(w, err) {
|
|
return
|
|
}
|
|
if err != nil {
|
|
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to create issue")
|
|
return
|
|
}
|
|
|
|
issue := res.Issue
|
|
slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...)
|
|
|
|
resp := issueToResponse(issue, prefix)
|
|
fillCreated(&resp)
|
|
resp.Attachments = buildAttachmentResponses(res.Attachments)
|
|
// Echo the authoritative labels attached in the create transaction. Always
|
|
// non-nil (empty slice when none) so a newer client can tell the backend
|
|
// understood label_ids and skip its legacy post-create attach fallback.
|
|
labelResponses := labelsToResponse(res.Labels)
|
|
resp.Labels = &labelResponses
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
type UpdateIssueRequest struct {
|
|
ExpectedRevision *int64 `json:"expected_revision,omitempty"`
|
|
Title *string `json:"title"`
|
|
// TitleBase is the title adopted by the editor before producing Title. It
|
|
// protects title edits without coupling them to unrelated issue mutations.
|
|
TitleBase *string `json:"title_base,omitempty"`
|
|
Description *string `json:"description"`
|
|
// DescriptionBase is the authoritative Markdown the editor had adopted
|
|
// before producing Description. It lets the server preserve channel media
|
|
// that landed asynchronously after that base without making media already
|
|
// present in the base impossible for the user to delete. Older clients omit
|
|
// it and receive conservative channel-media preservation.
|
|
DescriptionBase *string `json:"description_base,omitempty"`
|
|
Status *string `json:"status"`
|
|
Priority *string `json:"priority"`
|
|
AssigneeType *string `json:"assignee_type"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
Position *float64 `json:"position"`
|
|
StartDate *string `json:"start_date"`
|
|
DueDate *string `json:"due_date"`
|
|
ParentIssueID *string `json:"parent_issue_id"`
|
|
ProjectID *string `json:"project_id"`
|
|
Stage *int32 `json:"stage"`
|
|
// AttachmentIDs lets the description editor bind newly uploaded files to
|
|
// this issue so they surface in `GET /api/issues/:id/attachments` and the
|
|
// editor's preview Eye keeps working past a refresh. Existing bindings
|
|
// are idempotent — re-sending the same id is a no-op.
|
|
AttachmentIDs []string `json:"attachment_ids"`
|
|
// SuppressRun, when true, applies the assignee/status change as usual but
|
|
// skips starting the agent run this write would otherwise trigger
|
|
// ("暂时不启动" — MUL-3375). It is not an undo: the change takes effect and
|
|
// the issue can be run later via manual run/rerun. Optional; omitted or
|
|
// false keeps today's behavior. Mirrors comment suppress_agent_ids.
|
|
SuppressRun bool `json:"suppress_run,omitempty"`
|
|
// HandoffNote is retained at the API boundary for installed clients that
|
|
// predate the handoff UI removal. It is consumed only when this write starts
|
|
// a run and is never stored on the issue itself.
|
|
HandoffNote string `json:"handoff_note,omitempty"`
|
|
// StopPreviousAssigneeRuns, when a status change hands the issue off to a
|
|
// project workflow step's handler, cancels the active runs of the agent or
|
|
// squad it was taken from. Ignored when the write does not hand off.
|
|
// (MUL-7420)
|
|
StopPreviousAssigneeRuns bool `json:"stop_previous_assignee_runs,omitempty"`
|
|
// EnterWorkflowStep makes a project move enter the destination workflow's
|
|
// step the issue lands on, handing it off like a status change would. A
|
|
// project move without it is administrative. Batch updates ignore it.
|
|
// (MUL-7420)
|
|
EnterWorkflowStep bool `json:"enter_workflow_step,omitempty"`
|
|
// DuplicateOfIssueID marks this issue as a duplicate of another issue in
|
|
// the same workspace (MUL-7349). A duplicate is an ordinary cancelled issue
|
|
// that remembers its original, so this also sets status to cancelled.
|
|
// Any later status change away from cancelled removes the mark; there is
|
|
// no explicit null. Write-only: read the relation back from
|
|
// GET /api/issues/{id}/duplicates. Not accepted by batch updates.
|
|
DuplicateOfIssueID *string `json:"duplicate_of_issue_id,omitempty"`
|
|
}
|
|
|
|
func mergeIssueChannelMediaDescription(current, incoming string, base *string, attachments []db.Attachment) string {
|
|
currentIDs := channelmedia.MarkedIDs(current)
|
|
if len(currentIDs) == 0 {
|
|
return incoming
|
|
}
|
|
|
|
baseIDs := map[string]bool{}
|
|
if base != nil {
|
|
for _, id := range channelmedia.MarkedIDs(*base) {
|
|
baseIDs[id] = true
|
|
}
|
|
}
|
|
attachmentsByID := make(map[string]db.Attachment, len(attachments))
|
|
for _, attachment := range attachments {
|
|
attachmentsByID[uuidToString(attachment.ID)] = attachment
|
|
}
|
|
|
|
merged := incoming
|
|
for _, id := range currentIDs {
|
|
attachment, exists := attachmentsByID[id]
|
|
if !exists {
|
|
// A deleted attachment must not be resurrected from stale Markdown.
|
|
continue
|
|
}
|
|
downloadPath := channelmedia.DownloadPath(id)
|
|
hasLink := strings.Contains(merged, downloadPath)
|
|
knownToEditor := base != nil && baseIDs[id]
|
|
if knownToEditor && !hasLink {
|
|
// The editor adopted this media and then removed its link: preserve
|
|
// the user's explicit deletion rather than treating it as a race.
|
|
continue
|
|
}
|
|
if !hasLink {
|
|
merged = channelmedia.Append(merged, channelmedia.Block(
|
|
id,
|
|
attachment.Filename,
|
|
strings.HasPrefix(attachment.ContentType, "image/"),
|
|
))
|
|
continue
|
|
}
|
|
// Tiptap may omit HTML comments when serializing an otherwise intact
|
|
// image. Restore provenance without duplicating the visible link.
|
|
if !channelmedia.HasMarker(merged, id) {
|
|
merged = channelmedia.Append(merged, channelmedia.Marker(id))
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func refreshUntouchedNullableIssueParams(params *db.UpdateIssueParams, current db.Issue, rawFields map[string]json.RawMessage) {
|
|
_, assigneeTypeTouched := rawFields["assignee_type"]
|
|
_, assigneeIDTouched := rawFields["assignee_id"]
|
|
// Assignee type and id form one validated value. If either half was
|
|
// supplied, retain the pre-validation counterpart in params rather than
|
|
// combining the supplied half with a concurrently-written counterpart that
|
|
// has never been validated with it.
|
|
if !assigneeTypeTouched && !assigneeIDTouched {
|
|
params.AssigneeType = current.AssigneeType
|
|
params.AssigneeID = current.AssigneeID
|
|
}
|
|
if _, touched := rawFields["start_date"]; !touched {
|
|
params.StartDate = current.StartDate
|
|
}
|
|
if _, touched := rawFields["due_date"]; !touched {
|
|
params.DueDate = current.DueDate
|
|
}
|
|
if _, touched := rawFields["parent_issue_id"]; !touched {
|
|
params.ParentIssueID = current.ParentIssueID
|
|
}
|
|
if _, touched := rawFields["project_id"]; !touched {
|
|
params.ProjectID = current.ProjectID
|
|
}
|
|
if _, touched := rawFields["stage"]; !touched {
|
|
params.Stage = current.Stage
|
|
}
|
|
}
|
|
|
|
var errIssueFieldConflict = errors.New("issue text field conflict")
|
|
|
|
func (h *Handler) updateIssueAtomically(ctx context.Context, workspaceID pgtype.UUID, params db.UpdateIssueParams, rawFields map[string]json.RawMessage, titleBase, descriptionBase *string, attachmentIDs []pgtype.UUID, statusKey string) (db.Issue, db.Issue, bool, error) {
|
|
if h.TxStarter == nil {
|
|
return db.Issue{}, db.Issue{}, false, errors.New("atomic issue update requires transaction starter")
|
|
}
|
|
tx, err := h.beginWakeupWrite(ctx)
|
|
if err != nil {
|
|
return db.Issue{}, db.Issue{}, false, fmt.Errorf("begin atomic issue update: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
qtx := h.Queries.WithTx(tx)
|
|
// This path opens its own transaction, so it carries the archive-race guard
|
|
// itself rather than going through runWithIssueStatusGuard. The catalog lock
|
|
// must precede both attachment and issue row locks everywhere. (MUL-6243)
|
|
if err := assertIssueStatusStillActive(ctx, qtx, workspaceID, statusKey); err != nil {
|
|
return db.Issue{}, db.Issue{}, false, err
|
|
}
|
|
if len(attachmentIDs) > 0 {
|
|
if _, err := qtx.LockAttachmentsForIssueLink(ctx, db.LockAttachmentsForIssueLinkParams{
|
|
WorkspaceID: workspaceID,
|
|
AttachmentIds: attachmentIDs,
|
|
}); err != nil {
|
|
return db.Issue{}, db.Issue{}, false, fmt.Errorf("lock issue attachments: %w", err)
|
|
}
|
|
}
|
|
current, err := qtx.LockIssueForDescriptionUpdate(ctx, db.LockIssueForDescriptionUpdateParams{
|
|
ID: params.ID,
|
|
WorkspaceID: workspaceID,
|
|
})
|
|
if err != nil {
|
|
return db.Issue{}, db.Issue{}, false, fmt.Errorf("lock issue for update: %w", err)
|
|
}
|
|
|
|
if params.Title.Valid && titleBase != nil && current.Title != *titleBase && current.Title != params.Title.String {
|
|
return db.Issue{}, current, false, errIssueFieldConflict
|
|
}
|
|
|
|
if params.Description.Valid {
|
|
attachments, listErr := qtx.ListAttachmentsByIssue(ctx, db.ListAttachmentsByIssueParams{
|
|
IssueID: current.ID,
|
|
WorkspaceID: current.WorkspaceID,
|
|
})
|
|
if listErr != nil {
|
|
return db.Issue{}, current, false, fmt.Errorf("list issue attachments for description merge: %w", listErr)
|
|
}
|
|
currentDescription := ""
|
|
if current.Description.Valid {
|
|
currentDescription = current.Description.String
|
|
}
|
|
incomingDescription := params.Description.String
|
|
// No baseline REJECTION here, deliberately: the description editor
|
|
// autosaves on a debounce, and its base could not be kept in step with
|
|
// what the server had already accepted — a save whose own echo landed
|
|
// while the editor was dirty, or any stored description that was not
|
|
// byte-identical to its own trimmed form, reported a conflict with no
|
|
// second writer present and then wedged the editor for the session
|
|
// (MUL-6971). The guard also never covered the writers most likely to
|
|
// race a human here — mobile and the CLI/agent path send no base at
|
|
// all — so it mostly rejected the user's own autosave.
|
|
//
|
|
// `descriptionBase` stays in the request: it is ALSO the merge metadata
|
|
// below, which is what lets a user delete channel media the editor had
|
|
// adopted instead of having it restored on every save. Description
|
|
// writes are last-write-wins; concurrent edits are recorded by the
|
|
// `description_updated` activity.
|
|
params.Description = pgtype.Text{
|
|
String: mergeIssueChannelMediaDescription(currentDescription, incomingDescription, descriptionBase, attachments),
|
|
Valid: true,
|
|
}
|
|
}
|
|
refreshUntouchedNullableIssueParams(¶ms, current, rawFields)
|
|
|
|
issue, cancelledWakeups, err := updateIssueStoppingWakeups(ctx, qtx, params)
|
|
if err != nil {
|
|
return db.Issue{}, current, false, fmt.Errorf("update locked issue: %w", err)
|
|
}
|
|
|
|
attachmentsChanged := false
|
|
if len(attachmentIDs) > 0 {
|
|
linked, linkErr := qtx.LinkAttachmentsToIssue(ctx, db.LinkAttachmentsToIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
AttachmentIds: attachmentIDs,
|
|
BumpRevision: issue.Revision == current.Revision,
|
|
})
|
|
if linkErr != nil {
|
|
return db.Issue{}, current, false, fmt.Errorf("link issue attachments: %w", linkErr)
|
|
}
|
|
attachmentsChanged = linked.LinkedCount > 0
|
|
if linked.IssueRevision > 0 {
|
|
issue, err = qtx.GetIssueInWorkspace(ctx, db.GetIssueInWorkspaceParams{
|
|
ID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
return db.Issue{}, current, false, fmt.Errorf("reload issue after attachment link: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return db.Issue{}, current, false, fmt.Errorf("commit atomic issue update: %w", err)
|
|
}
|
|
h.broadcastCancelledWakeups(ctx, workspaceID, cancelledWakeups)
|
|
return issue, current, attachmentsChanged, nil
|
|
}
|
|
|
|
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|
r = h.withWakeupActor(r)
|
|
id := chi.URLParam(r, "id")
|
|
prevIssue, ok := h.loadIssueForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
userID := requestUserID(r)
|
|
workspaceID := uuidToString(prevIssue.WorkspaceID)
|
|
|
|
// Read body as raw bytes so we can detect which fields were explicitly sent.
|
|
bodyBytes, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "failed to read request body")
|
|
return
|
|
}
|
|
|
|
var req UpdateIssueRequest
|
|
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Track which fields were explicitly present in JSON (even if null)
|
|
var rawFields map[string]json.RawMessage
|
|
json.Unmarshal(bodyBytes, &rawFields)
|
|
|
|
if prevIssue.TriageState.Valid {
|
|
if field := triageLockedField(rawFields); field != "" {
|
|
writeIssueInTriage(w, field)
|
|
return
|
|
}
|
|
}
|
|
|
|
duplicateOfID, ok := parseDuplicateMark(w, &req, rawFields, prevIssue.ID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Pre-fill nullable fields (bare sqlc.narg) with current values
|
|
params := db.UpdateIssueParams{
|
|
SourceTaskID: h.wakeupSourceTaskID(r),
|
|
ID: prevIssue.ID,
|
|
AssigneeType: prevIssue.AssigneeType,
|
|
AssigneeID: prevIssue.AssigneeID,
|
|
StartDate: prevIssue.StartDate,
|
|
DueDate: prevIssue.DueDate,
|
|
ParentIssueID: prevIssue.ParentIssueID,
|
|
ProjectID: prevIssue.ProjectID,
|
|
Stage: prevIssue.Stage,
|
|
// Checked against the locked rows in updateIssueWithStatusGuard.
|
|
DuplicateOfIssueID: duplicateOfID,
|
|
}
|
|
if req.ExpectedRevision != nil {
|
|
if *req.ExpectedRevision < 1 {
|
|
writeError(w, http.StatusBadRequest, "expected_revision must be a positive integer")
|
|
return
|
|
}
|
|
if prevIssue.Revision != *req.ExpectedRevision {
|
|
writeRevisionConflict(w, "issue", prevIssue.ID, *req.ExpectedRevision, prevIssue.Revision)
|
|
return
|
|
}
|
|
params.ExpectedRevision = pgtype.Int8{Int64: *req.ExpectedRevision, Valid: true}
|
|
}
|
|
|
|
// COALESCE fields — only set when explicitly provided
|
|
if req.Title != nil {
|
|
params.Title = pgtype.Text{String: *req.Title, Valid: true}
|
|
}
|
|
if req.Description != nil {
|
|
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
|
}
|
|
// statusKeyForGuard is the resolved key when this request sets a status, and
|
|
// empty otherwise. Empty means "this write does not touch status", which the
|
|
// guard treats as nothing to protect.
|
|
statusKeyForGuard := ""
|
|
if req.Status != nil {
|
|
statusKey, _, ok := h.resolveIssueStatusKeyKind(w, r, prevIssue.WorkspaceID, *req.Status)
|
|
if !ok {
|
|
return
|
|
}
|
|
statusKeyForGuard = statusKey
|
|
params.Status = pgtype.Text{String: statusKey, Valid: true}
|
|
}
|
|
if req.Priority != nil {
|
|
if !validateIssueEnum(w, "priority", *req.Priority, validIssuePriorities) {
|
|
return
|
|
}
|
|
params.Priority = pgtype.Text{String: *req.Priority, Valid: true}
|
|
}
|
|
if req.Position != nil {
|
|
params.Position = pgtype.Float8{Float64: *req.Position, Valid: true}
|
|
}
|
|
// Nullable fields — only override when explicitly present in JSON
|
|
if _, ok := rawFields["assignee_type"]; ok {
|
|
if req.AssigneeType != nil {
|
|
params.AssigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
|
|
} else {
|
|
params.AssigneeType = pgtype.Text{Valid: false} // explicit null = unassign
|
|
}
|
|
}
|
|
if _, ok := rawFields["assignee_id"]; ok {
|
|
if req.AssigneeID != nil {
|
|
id, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
params.AssigneeID = id
|
|
} else {
|
|
params.AssigneeID = pgtype.UUID{Valid: false} // explicit null = unassign
|
|
}
|
|
}
|
|
if _, ok := rawFields["start_date"]; ok {
|
|
if req.StartDate != nil && *req.StartDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.StartDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
|
|
return
|
|
}
|
|
params.StartDate = d
|
|
} else {
|
|
params.StartDate = pgtype.Date{Valid: false} // explicit null = clear date
|
|
}
|
|
}
|
|
if _, ok := rawFields["due_date"]; ok {
|
|
if req.DueDate != nil && *req.DueDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.DueDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
|
|
return
|
|
}
|
|
params.DueDate = d
|
|
} else {
|
|
params.DueDate = pgtype.Date{Valid: false} // explicit null = clear date
|
|
}
|
|
}
|
|
if _, ok := rawFields["parent_issue_id"]; ok {
|
|
if req.ParentIssueID != nil {
|
|
newParentID, ok := parseUUIDOrBadRequest(w, *req.ParentIssueID, "parent_issue_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
// Cannot set self as parent. Compare against prevIssue.ID (the
|
|
// resolved entity), not the raw URL string — `id` may be an
|
|
// identifier like "MUL-7".
|
|
if newParentID == prevIssue.ID {
|
|
writeError(w, http.StatusBadRequest, "an issue cannot be its own parent")
|
|
return
|
|
}
|
|
// Validate parent exists in the same workspace.
|
|
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: newParentID,
|
|
WorkspaceID: prevIssue.WorkspaceID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
|
return
|
|
}
|
|
// Cycle detection: walk up from the new parent to ensure we don't reach this issue.
|
|
cursor := newParentID
|
|
for depth := 0; depth < 10; depth++ {
|
|
ancestor, err := h.Queries.GetIssue(r.Context(), cursor)
|
|
if err != nil || !ancestor.ParentIssueID.Valid {
|
|
break
|
|
}
|
|
if ancestor.ParentIssueID == prevIssue.ID {
|
|
writeError(w, http.StatusBadRequest, "circular parent relationship detected")
|
|
return
|
|
}
|
|
cursor = ancestor.ParentIssueID
|
|
}
|
|
params.ParentIssueID = newParentID
|
|
} else {
|
|
params.ParentIssueID = pgtype.UUID{Valid: false} // explicit null = remove parent
|
|
}
|
|
}
|
|
if _, ok := rawFields["project_id"]; ok {
|
|
if req.ProjectID != nil {
|
|
projectUUID, ok := parseUUIDOrBadRequest(w, *req.ProjectID, "project_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
|
|
ID: projectUUID,
|
|
WorkspaceID: prevIssue.WorkspaceID,
|
|
}); err != nil {
|
|
if !isNotFound(err) {
|
|
slog.Error("update issue: validate project scope",
|
|
append(logger.RequestAttrs(r), "project_id", uuidToString(projectUUID), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to validate project")
|
|
return
|
|
}
|
|
writeError(w, http.StatusBadRequest, "project not found in this workspace")
|
|
return
|
|
}
|
|
params.ProjectID = projectUUID
|
|
} else {
|
|
params.ProjectID = pgtype.UUID{Valid: false}
|
|
}
|
|
}
|
|
if _, ok := rawFields["stage"]; ok {
|
|
if req.Stage != nil {
|
|
if *req.Stage < 1 {
|
|
writeError(w, http.StatusBadRequest, "stage must be >= 1")
|
|
return
|
|
}
|
|
params.Stage = pgtype.Int4{Int32: *req.Stage, Valid: true}
|
|
} else {
|
|
params.Stage = pgtype.Int4{Valid: false} // explicit null = unstage
|
|
}
|
|
}
|
|
|
|
// Validate the resulting (assignee_type, assignee_id) pair when the caller
|
|
// touches either field. Existing data on the issue is left alone if the
|
|
// caller is not changing it.
|
|
//
|
|
// The scope is THIS issue: an unattributed autopilot run that verifiably owns
|
|
// the work on it may point it at a private agent, exactly as it may when
|
|
// creating a child under it. Before MUL-6691 this passed nil, so the reported
|
|
// flow — create DRA-109 unassigned, then assign it — was refused even though
|
|
// the identical lineage was accepted on the create path.
|
|
_, touchedType := rawFields["assignee_type"]
|
|
_, touchedID := rawFields["assignee_id"]
|
|
|
|
// Project workflow (MUL-7420): keep the status inside the project's
|
|
// workflow, and hand the issue off when it enters a step with a handler.
|
|
// A handoff assignee passes the same gate as a hand-picked one.
|
|
// An agent's run may only move the issue from the step it works on.
|
|
callerType, _ := h.resolveActor(r, userID, workspaceID)
|
|
callerRun := h.agentRunOnIssue(r, callerType, prevIssue)
|
|
handoff, err := h.applyIssueWorkflow(r.Context(), prevIssue, ¶ms, req.Status != nil, touchedType || touchedID, req.EnterWorkflowStep, callerRun)
|
|
if err != nil {
|
|
if writeIssueWorkflowError(w, err) {
|
|
return
|
|
}
|
|
slog.Error("update issue: resolve project workflow", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve project workflow")
|
|
return
|
|
}
|
|
if params.Status.Valid && params.Status.String != statusKeyForGuard {
|
|
// A project move remapped the status; guard the key actually written.
|
|
statusKeyForGuard = params.Status.String
|
|
}
|
|
if touchedType || touchedID || handoff != nil {
|
|
if status, msg := h.validateAssigneePair(r.Context(), r, workspaceID, params.AssigneeType, params.AssigneeID); status != 0 {
|
|
writeError(w, status, msg)
|
|
return
|
|
}
|
|
}
|
|
|
|
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// The agent this write hands the issue to gets the change as its own run,
|
|
// not also through its event wakeups.
|
|
writeCtx := withWakeupHandedTo(r.Context(), h.handedToAgent(r.Context(), prevIssue, params, handoff != nil))
|
|
var issue db.Issue
|
|
attachmentsChanged := false
|
|
if req.Description != nil || req.TitleBase != nil || req.DescriptionBase != nil || len(attachmentIDs) > 0 {
|
|
var lockedPrev db.Issue
|
|
issue, lockedPrev, attachmentsChanged, err = h.updateIssueAtomically(
|
|
writeCtx, prevIssue.WorkspaceID, params, rawFields, req.TitleBase, req.DescriptionBase, attachmentIDs, statusKeyForGuard,
|
|
)
|
|
if lockedPrev.ID.Valid {
|
|
prevIssue = lockedPrev
|
|
}
|
|
} else {
|
|
issue, err = h.updateIssueWithStatusGuard(writeCtx, prevIssue.WorkspaceID, statusKeyForGuard, params)
|
|
}
|
|
if err != nil {
|
|
if writeIssueStatusRaceError(w, err) {
|
|
return
|
|
}
|
|
if writeDuplicateMarkError(w, err) {
|
|
return
|
|
}
|
|
if errors.Is(err, errIssueFieldConflict) {
|
|
writeEditConflict(w, "issue", prevIssue.ID)
|
|
return
|
|
}
|
|
if errors.Is(err, pgx.ErrNoRows) && req.ExpectedRevision != nil {
|
|
current, reloadErr := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ID: prevIssue.ID, WorkspaceID: prevIssue.WorkspaceID})
|
|
if reloadErr == nil {
|
|
writeRevisionConflict(w, "issue", current.ID, *req.ExpectedRevision, current.Revision)
|
|
return
|
|
}
|
|
}
|
|
slog.Warn("update issue failed", append(logger.RequestAttrs(r), "error", err, "issue_id", id, "workspace_id", workspaceID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to update issue")
|
|
return
|
|
}
|
|
|
|
// Determine actor identity: agent (via X-Agent-ID header) or member.
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
h.advanceRunStep(r.Context(), callerRun, prevIssue, issue)
|
|
|
|
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
|
resp := issueToResponse(issue, prefix)
|
|
slog.Info("issue updated", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", workspaceID)...)
|
|
|
|
h.fillStatusCategory(r.Context(), issue.WorkspaceID, &resp)
|
|
assigneeChanged := (req.AssigneeType != nil || req.AssigneeID != nil || handoff != nil) &&
|
|
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
|
|
statusChanged := (req.Status != nil || params.Status.Valid) && prevIssue.Status != issue.Status
|
|
priorityChanged := req.Priority != nil && prevIssue.Priority != issue.Priority
|
|
// project_changed gates the client's per-project issue-list refetch the way
|
|
// status/assignee flags gate theirs. Without it the client must diff
|
|
// project_id against its own cache, which breaks once an optimistic local
|
|
// move has overwritten the cached value (MUL-3669 / #4548).
|
|
projectChanged := req.ProjectID != nil && uuidToString(prevIssue.ProjectID) != uuidToString(issue.ProjectID)
|
|
descriptionChanged := req.Description != nil && textToPtr(prevIssue.Description) != resp.Description
|
|
titleChanged := req.Title != nil && prevIssue.Title != issue.Title
|
|
prevStartDate := dateToPtr(prevIssue.StartDate)
|
|
startDateChanged := prevStartDate != resp.StartDate && (prevStartDate == nil) != (resp.StartDate == nil) ||
|
|
(prevStartDate != nil && resp.StartDate != nil && *prevStartDate != *resp.StartDate)
|
|
prevDueDate := dateToPtr(prevIssue.DueDate)
|
|
dueDateChanged := prevDueDate != resp.DueDate && (prevDueDate == nil) != (resp.DueDate == nil) ||
|
|
(prevDueDate != nil && resp.DueDate != nil && *prevDueDate != *resp.DueDate)
|
|
|
|
// A workflow handoff (MUL-7420) is decided before the event goes out so
|
|
// the timeline can record it as one entry: the status change, who the
|
|
// issue went to, whether their run started, and the brief it reads. The
|
|
// run gets that brief from its claim; its handoff note stays the
|
|
// caller's own.
|
|
handoffNote := req.HandoffNote
|
|
trigger, willRun := h.IssueService.WillEnqueueRun(r.Context(),
|
|
service.IssueTriggerInput{
|
|
Issue: issue,
|
|
PrevStatus: prevIssue.Status,
|
|
AssigneeChanged: assigneeChanged || handoff != nil,
|
|
StatusChanged: statusChanged,
|
|
EnteredHandoffStep: handoff != nil,
|
|
},
|
|
h.issueTriggerWriteProbe(r, actorType, actorID, issue),
|
|
)
|
|
willRun = willRun && !req.SuppressRun
|
|
|
|
updatedPayload := map[string]any{
|
|
"issue": resp,
|
|
"assignee_changed": assigneeChanged,
|
|
"status_changed": statusChanged,
|
|
"priority_changed": priorityChanged,
|
|
"project_changed": projectChanged,
|
|
"start_date_changed": startDateChanged,
|
|
"due_date_changed": dueDateChanged,
|
|
"description_changed": descriptionChanged,
|
|
"title_changed": titleChanged,
|
|
"prev_title": prevIssue.Title,
|
|
"prev_assignee_type": textToPtr(prevIssue.AssigneeType),
|
|
"prev_assignee_id": uuidToPtr(prevIssue.AssigneeID),
|
|
"prev_status": prevIssue.Status,
|
|
"prev_priority": prevIssue.Priority,
|
|
"prev_start_date": prevStartDate,
|
|
"prev_due_date": prevDueDate,
|
|
"prev_description": textToPtr(prevIssue.Description),
|
|
"creator_type": prevIssue.CreatorType,
|
|
"creator_id": uuidToString(prevIssue.CreatorID),
|
|
// Both ends of a mark change ride here: the activity log records it
|
|
// and clients refresh the two issues' relations from it.
|
|
"duplicate_of_issue_id": liveDuplicateMark(issue.Status, issue.DuplicateOfIssueID),
|
|
"prev_duplicate_of_issue_id": liveDuplicateMark(prevIssue.Status, prevIssue.DuplicateOfIssueID),
|
|
}
|
|
if handoff != nil {
|
|
updatedPayload["workflow_handoff"] = workflowHandoffPayload(issue, handoff, h.workflowHandoffBrief(r.Context(), issue, handoff), willRun)
|
|
}
|
|
h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, updatedPayload)
|
|
if attachmentsChanged {
|
|
// The full owner snapshot must be admitted before an auxiliary event at
|
|
// the same revision. Otherwise clients advance only the revision here and
|
|
// reject issue:updated as non-increasing, stranding the old issue fields.
|
|
h.publish(protocol.EventIssueAttachmentsChanged, workspaceID, actorType, actorID, map[string]any{
|
|
"issue_id": uuidToString(issue.ID),
|
|
"issue_revision": issue.Revision,
|
|
})
|
|
}
|
|
|
|
// Reconcile the task queue. Whether this write starts an agent run — and
|
|
// for whom (agent assignee or squad leader) — is decided by the single
|
|
// WillEnqueueRun predicate, shared verbatim with the preview endpoint so
|
|
// the two never drift (MUL-3375).
|
|
//
|
|
// A reassignment intentionally does NOT cancel existing tasks on the issue
|
|
// (#4963 / MUL-4113). The previous "cancel every active task on the issue"
|
|
// was too coarse: it silently dropped unrelated in-flight work (a
|
|
// mention-triggered run for another agent, a squad task) with no requeue,
|
|
// and it self-cancelled a run that reassigned the issue from inside itself.
|
|
// Ownership handoff no longer implies interruption; the new assignee's run,
|
|
// if any, is enqueued by WillEnqueueRun below and runs alongside whatever
|
|
// was already in flight. No status change — not even → cancelled — cancels
|
|
// active tasks: a user clicking "cancel" on an issue has no expectation that
|
|
// it stops in-flight agent runs, so that implicit coupling is gone
|
|
// (MUL-4465). Deleting an issue still cancels its tasks (see DeleteIssue),
|
|
// because the tasks' owning issue ceases to exist.
|
|
//
|
|
// A workflow handoff counts as a fresh assignment even when the step's
|
|
// handler already owns the issue: entering the step is the handler's cue
|
|
// to work on it, with the step's brief as the handoff note. (MUL-7420)
|
|
if handoff != nil && req.StopPreviousAssigneeRuns {
|
|
h.stopPreviousAssigneeRuns(r.Context(), prevIssue, issue, actorType, actorID)
|
|
}
|
|
if willRun {
|
|
h.dispatchIssueRun(r.Context(), issue, trigger, actorType, actorID, handoffNote)
|
|
}
|
|
|
|
// Sub-issue rules on the parent (the child_done system rule and sub-issue
|
|
// conditions): the write recorded the change; process it now. A failure
|
|
// is retried by the scheduler sweep.
|
|
if statusChanged || prevIssue.ParentIssueID != issue.ParentIssueID || prevIssue.Stage != issue.Stage {
|
|
h.processChildEvents(r.Context(), issue.ParentIssueID, prevIssue.ParentIssueID)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// validateAssigneePair verifies the (assignee_type, assignee_id) pair refers
|
|
// to an existing entity in the workspace. For agent and squad assignees it
|
|
// also rejects archived targets and runs the INVOKE gate — canInvokeAgent, not
|
|
// the softer canAccessPrivateAgent view gate: assigning an issue produces a
|
|
// run, so it must clear the same predicate as chat / @-mention (MUL-3963).
|
|
// That means owner-only for a private agent, with NO workspace-admin bypass
|
|
// and NO unconditional agent-to-agent bypass — an agent caller (X-Agent-ID) is
|
|
// judged by the top-of-chain human originator like everywhere else.
|
|
// An autopilot run needs no special case here: since MUL-6951 a scheduled run
|
|
// carries its trigger owner's originator, so it is judged by exactly the same
|
|
// predicate as that human acting directly.
|
|
//
|
|
// Returns (statusCode, errorMessage). statusCode == 0 means the pair is valid;
|
|
// callers should treat any non-zero status as a rejection and surface it back
|
|
// to the client.
|
|
func (h *Handler) validateAssigneePair(ctx context.Context, r *http.Request, workspaceID string, assigneeType pgtype.Text, assigneeID pgtype.UUID) (int, string) {
|
|
// Both unset → unassigned issue, valid.
|
|
if !assigneeType.Valid && !assigneeID.Valid {
|
|
return 0, ""
|
|
}
|
|
// Exactly one of type/id provided → callers must always pair them.
|
|
if assigneeType.Valid != assigneeID.Valid {
|
|
return http.StatusBadRequest, "assignee_type and assignee_id must be provided together"
|
|
}
|
|
wsUUID, err := util.ParseUUID(workspaceID)
|
|
if err != nil {
|
|
return http.StatusBadRequest, "invalid workspace_id"
|
|
}
|
|
switch assigneeType.String {
|
|
case "member":
|
|
if _, err := h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: assigneeID,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
return http.StatusBadRequest, "assignee_id does not refer to a member of this workspace"
|
|
}
|
|
return 0, ""
|
|
case "agent":
|
|
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
|
|
ID: assigneeID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
return http.StatusBadRequest, "assignee_id does not refer to an agent of this workspace"
|
|
}
|
|
if agent.ArchivedAt.Valid {
|
|
return http.StatusBadRequest, "cannot assign to archived agent"
|
|
}
|
|
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
|
effectiveInvoker := h.invokeOriginatorFromRequest(r, actorType, actorID)
|
|
if !h.canInvokeAgent(ctx, agent, actorType, actorID, effectiveInvoker, workspaceID) {
|
|
// Names the missing permission, not the target's configuration: the
|
|
// old "private agent" wording both disclosed the agent's permission
|
|
// mode and was simply wrong for a `public_to` agent scoped to
|
|
// specific people. This is NOT full enumeration-safety — the
|
|
// not-in-workspace branch above still answers 400 where this
|
|
// answers 403, so existence remains observable; the guarantee here
|
|
// is only that the reason no longer names the target's permission
|
|
// mode (MUL-6380 / GH #7180).
|
|
return http.StatusForbidden, "you do not have permission to assign work to this agent"
|
|
}
|
|
return 0, ""
|
|
case "squad":
|
|
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
|
ID: assigneeID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
return http.StatusBadRequest, "assignee_id does not refer to a squad in this workspace"
|
|
}
|
|
if squad.ArchivedAt.Valid {
|
|
return http.StatusBadRequest, "cannot assign to an archived squad"
|
|
}
|
|
leader, err := h.Queries.GetAgent(ctx, squad.LeaderID)
|
|
if err != nil || leader.ArchivedAt.Valid {
|
|
return http.StatusBadRequest, "squad leader is archived; cannot assign to this squad"
|
|
}
|
|
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
|
effectiveInvoker := h.invokeOriginatorFromRequest(r, actorType, actorID)
|
|
if !h.canInvokeAgent(ctx, leader, actorType, actorID, effectiveInvoker, workspaceID) {
|
|
// Same wording rule as the agent branch above; "this squad"
|
|
// avoids disclosing the leader agent's permission mode.
|
|
return http.StatusForbidden, "you do not have permission to assign work to this squad"
|
|
}
|
|
return 0, ""
|
|
default:
|
|
return http.StatusBadRequest, "assignee_type must be 'member', 'agent', or 'squad'"
|
|
}
|
|
}
|
|
|
|
// shouldEnqueueAgentTask returns true when an issue creation or assignment
|
|
// should trigger the assigned agent. Backlog issues are skipped — backlog
|
|
// acts as a parking lot where issues can be pre-assigned without immediately
|
|
// triggering execution. Moving out of backlog is handled separately in
|
|
// UpdateIssue.
|
|
func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bool {
|
|
// Only the fixed backlog key parks work; custom unstarted statuses do not.
|
|
// An issue in Triage is not parked but refused: it produces no run from any
|
|
// entry point until it is accepted (MUL-7189 §2.3).
|
|
if issue.TriageState.Valid || issuestatus.Effective(ctx, h.Queries, issue.WorkspaceID, issue.Status) == "backlog" {
|
|
return false
|
|
}
|
|
return h.isAgentAssigneeReady(ctx, issue)
|
|
}
|
|
|
|
// shouldEnqueueAssigneeFallback returns true when comment routing can fall back
|
|
// to the issue's assigned agent. Fires for any status — comments are
|
|
// conversational and can happen at any stage, including after completion
|
|
// (e.g. follow-up questions on a done issue).
|
|
//
|
|
// Mirrors the private-agent gate that resolveMentionedAgentCommentTriggers applies on the
|
|
// @mention path: once an owner/admin assigns a private agent to an issue, the
|
|
// agent's UUID is "welded" onto the issue and remains visible to every member
|
|
// who can view it. Without this check any of those members could dispatch a new
|
|
// task to the private agent simply by commenting (#3300).
|
|
func (h *Handler) shouldEnqueueAssigneeFallback(ctx context.Context, issue db.Issue, actorType, actorID string, opts commentTriggerComputeOptions) bool {
|
|
_, hasPending, ok := h.assigneeFallbackAgent(ctx, issue, actorType, actorID, opts)
|
|
return ok && !hasPending
|
|
}
|
|
|
|
func (h *Handler) assigneeFallbackAgent(ctx context.Context, issue db.Issue, actorType, actorID string, opts commentTriggerComputeOptions) (db.Agent, bool, bool) {
|
|
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "agent" || !issue.AssigneeID.Valid {
|
|
return db.Agent{}, false, false
|
|
}
|
|
agent, err := h.Queries.GetAgent(ctx, issue.AssigneeID)
|
|
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
|
return db.Agent{}, false, false
|
|
}
|
|
if !h.canInvokeAgent(ctx, agent, actorType, actorID, opts.OriginatorUserID, uuidToString(issue.WorkspaceID)) {
|
|
return db.Agent{}, false, false
|
|
}
|
|
// Coalescing queue: pending is still a valid route target, but callers
|
|
// that actually enqueue tasks use this flag to avoid piling on duplicates.
|
|
hasPending, err := h.hasPendingTaskForIssueAndAgent(ctx, issue.ID, issue.AssigneeID, opts)
|
|
if err != nil {
|
|
return db.Agent{}, false, false
|
|
}
|
|
return agent, hasPending, true
|
|
}
|
|
|
|
// isAgentRunningOnIssue reports whether the calling agent's current task
|
|
// (identified by X-Task-ID) is running for the exact issue being promoted.
|
|
// That is the only true self-loop on backlog→active: the agent flipping
|
|
// the same issue its own task is executing for would immediately re-enqueue
|
|
// itself, complete the run, flip again, and so on.
|
|
//
|
|
// Same-agent cross-issue handoff (Agent A finishing a task on issue I1 then
|
|
// promoting issue I2 — even when I2 is also assigned to A) is NOT a loop
|
|
// and must fire; that is the documented serial sub-task chain. Member
|
|
// actors never match.
|
|
//
|
|
// X-Task-ID is guaranteed to be present and consistent when actorType is
|
|
// "agent": resolveActor demotes the actor to "member" otherwise (handler.go
|
|
// resolveActor). We still recheck defensively — a future caller could pass
|
|
// agent identity through a different path.
|
|
func (h *Handler) isAgentRunningOnIssue(r *http.Request, actorType string, issue db.Issue) bool {
|
|
if actorType != "agent" {
|
|
return false
|
|
}
|
|
taskIDStr := r.Header.Get("X-Task-ID")
|
|
if taskIDStr == "" {
|
|
return false
|
|
}
|
|
taskUUID, err := util.ParseUUID(taskIDStr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if !task.IssueID.Valid {
|
|
return false
|
|
}
|
|
return uuidToString(task.IssueID) == uuidToString(issue.ID)
|
|
}
|
|
|
|
// isAgentAssigneeReady checks if an issue is assigned to an active agent
|
|
// with a valid runtime.
|
|
func (h *Handler) isAgentAssigneeReady(ctx context.Context, issue db.Issue) bool {
|
|
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "agent" || !issue.AssigneeID.Valid {
|
|
return false
|
|
}
|
|
|
|
agent, err := h.Queries.GetAgent(ctx, issue.AssigneeID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// The shared verdict, not a local re-check (service.AgentReadiness). Only a
|
|
// BLOCKED verdict stops the enqueue: an offline machine still queues,
|
|
// because that work runs when the machine comes back.
|
|
verdict, err := service.AgentReadiness(ctx, h.runtimeLookup(obsmetrics.RuntimeLookupSourceIssue), agent)
|
|
if err != nil || !verdict.Blocked() {
|
|
return err == nil
|
|
}
|
|
// Assignment has no response the assigner reads for this outcome, so a
|
|
// refusal that needs human repair leaves the explanation on the issue
|
|
// (MUL-6164). An unbound agent keeps its silent skip: the agent list
|
|
// already shows it has no runtime, and nothing about it is new here.
|
|
if service.RuntimeBlockedNeedsNotice(verdict.Reason) {
|
|
h.noteRuntimeUnusable(ctx, issue, agent, verdict)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
|
|
r = h.withWakeupActor(r)
|
|
id := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
|
// Fail any linked autopilot runs before delete (ON DELETE SET NULL clears issue_id).
|
|
_ = h.AutopilotService.FailAutopilotRunsByIssue(r.Context(), issue.ID)
|
|
|
|
deleteResult, err := h.deleteIssueAndCollectAttachmentURLs(r.Context(), issue, nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete issue")
|
|
return
|
|
}
|
|
|
|
h.deleteS3Objects(r.Context(), deleteResult.AttachmentURLs)
|
|
userID := requestUserID(r)
|
|
actorType, actorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
|
|
// Always emit the resolved UUID — frontend caches key by UUID, so an
|
|
// identifier-style payload ("MUL-123") would leave stale entries on
|
|
// other clients after an identifier-path delete.
|
|
resolvedID := uuidToString(issue.ID)
|
|
h.publish(protocol.EventIssueDeleted, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"issue_id": resolvedID})
|
|
h.publishIssueSnapshots(r.Context(), deleteResult.DetachedChildren, actorType, actorID)
|
|
h.publishClearedDuplicates(r.Context(), deleteResult.ClearedDuplicates, actorType, actorID)
|
|
slog.Info("issue deleted", append(logger.RequestAttrs(r), "issue_id", resolvedID, "workspace_id", uuidToString(issue.WorkspaceID))...)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// deleteIssueAndCollectAttachmentURLs serializes issue deletion with channel
|
|
// media binding. The delete-side FOR UPDATE conflicts with the binder's
|
|
// FOR KEY SHARE, and URL collection happens only after that lock is held:
|
|
// bind-first means the new URL is collected; delete-first means the bind rolls
|
|
// back without consuming its durable object intent.
|
|
type issueDeleteResult struct {
|
|
AttachmentURLs []string
|
|
DetachedChildren []db.Issue
|
|
// ClearedDuplicates lost their duplicate mark because their original was
|
|
// deleted. They stay cancelled.
|
|
ClearedDuplicates []clearedDuplicate
|
|
}
|
|
|
|
// clearedDuplicate pairs a duplicate whose mark a delete cleared with the
|
|
// original that delete removed, so the broadcast can still name it.
|
|
type clearedDuplicate struct {
|
|
Issue db.Issue
|
|
Original db.Issue
|
|
}
|
|
|
|
func (h *Handler) deleteIssueAndCollectAttachmentURLs(ctx context.Context, issue db.Issue, excludedIssueIDs []pgtype.UUID) (issueDeleteResult, error) {
|
|
return h.deleteIssuesAndCollectAttachmentURLs(ctx, []db.Issue{issue}, excludedIssueIDs)
|
|
}
|
|
|
|
func (h *Handler) deleteIssuesAndCollectAttachmentURLs(ctx context.Context, issues []db.Issue, excludedIssueIDs []pgtype.UUID) (issueDeleteResult, error) {
|
|
sort.Slice(issues, func(i, j int) bool {
|
|
return uuidToString(issues[i].ID) < uuidToString(issues[j].ID)
|
|
})
|
|
tx, err := h.beginWakeupWrite(ctx)
|
|
if err != nil {
|
|
return issueDeleteResult{}, fmt.Errorf("begin issue delete: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
result := issueDeleteResult{}
|
|
for _, issue := range issues {
|
|
if _, err := qtx.LockIssueForDelete(ctx, db.LockIssueForDeleteParams{
|
|
ID: issue.ID, WorkspaceID: issue.WorkspaceID,
|
|
}); err != nil {
|
|
return issueDeleteResult{}, fmt.Errorf("lock issue for delete: %w", err)
|
|
}
|
|
detached, err := qtx.DetachDirectChildIssues(ctx, db.DetachDirectChildIssuesParams{
|
|
WorkspaceID: issue.WorkspaceID, ParentIssueID: issue.ID, ExcludedIssueIds: excludedIssueIDs,
|
|
})
|
|
if err != nil {
|
|
return issueDeleteResult{}, fmt.Errorf("detach child issues: %w", err)
|
|
}
|
|
result.DetachedChildren = append(result.DetachedChildren, detached...)
|
|
cleared, err := qtx.ClearIssueDuplicatesOf(ctx, db.ClearIssueDuplicatesOfParams{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.ID, ExcludedIssueIds: excludedIssueIDs,
|
|
})
|
|
if err != nil {
|
|
return issueDeleteResult{}, fmt.Errorf("clear duplicate marks: %w", err)
|
|
}
|
|
for _, duplicate := range cleared {
|
|
result.ClearedDuplicates = append(result.ClearedDuplicates, clearedDuplicate{Issue: duplicate, Original: issue})
|
|
}
|
|
attachmentURLs, err := qtx.ListAttachmentURLsByIssueOrComments(ctx, issue.ID)
|
|
if err != nil {
|
|
return issueDeleteResult{}, fmt.Errorf("list issue attachment URLs: %w", err)
|
|
}
|
|
result.AttachmentURLs = append(result.AttachmentURLs, attachmentURLs...)
|
|
if sourceContext, contextErr := qtx.GetIssueSourceContextByIssue(ctx, db.GetIssueSourceContextByIssueParams{WorkspaceID: issue.WorkspaceID, IssueID: issue.ID}); contextErr == nil {
|
|
contextAttachments, listErr := qtx.ListAttachmentsBySourceContext(ctx, db.ListAttachmentsBySourceContextParams{
|
|
WorkspaceID: issue.WorkspaceID, SourceContextID: sourceContext.ID,
|
|
})
|
|
if listErr != nil {
|
|
return issueDeleteResult{}, fmt.Errorf("list source context attachments: %w", listErr)
|
|
}
|
|
// The storage interface's legacy bulk delete has no result channel. Keep
|
|
// durable object intents before removing DB ownership so the periodic
|
|
// reconciler retries any ambiguous or failed best-effort delete.
|
|
if h.Storage != nil {
|
|
for _, clone := range contextAttachments {
|
|
storageKey := h.Storage.KeyFromURL(clone.Url)
|
|
if storageKey == "" {
|
|
continue
|
|
}
|
|
if _, intentErr := qtx.RecordSourceContextDeletionObjectIntent(ctx, db.RecordSourceContextDeletionObjectIntentParams{
|
|
StorageKey: storageKey, WorkspaceID: issue.WorkspaceID, SourceContextID: sourceContext.ID,
|
|
AttachmentID: clone.ID, ObjectUrl: clone.Url,
|
|
}); intentErr != nil {
|
|
return issueDeleteResult{}, fmt.Errorf("record source context delete intent: %w", intentErr)
|
|
}
|
|
}
|
|
}
|
|
clones, cloneErr := qtx.DeleteAttachmentsBySourceContext(ctx, db.DeleteAttachmentsBySourceContextParams{WorkspaceID: issue.WorkspaceID, SourceContextID: sourceContext.ID})
|
|
if cloneErr != nil {
|
|
return issueDeleteResult{}, fmt.Errorf("delete source context attachments: %w", cloneErr)
|
|
}
|
|
for _, clone := range clones {
|
|
result.AttachmentURLs = append(result.AttachmentURLs, clone.Url)
|
|
}
|
|
if _, contextErr = qtx.DeleteIssueSourceContextByIssue(ctx, db.DeleteIssueSourceContextByIssueParams{WorkspaceID: issue.WorkspaceID, IssueID: issue.ID}); contextErr != nil {
|
|
return issueDeleteResult{}, fmt.Errorf("delete issue source context: %w", contextErr)
|
|
}
|
|
} else if !errors.Is(contextErr, pgx.ErrNoRows) {
|
|
return issueDeleteResult{}, fmt.Errorf("load issue source context for delete: %w", contextErr)
|
|
}
|
|
if err := qtx.DeleteIssue(ctx, db.DeleteIssueParams{ID: issue.ID, WorkspaceID: issue.WorkspaceID}); err != nil {
|
|
return issueDeleteResult{}, fmt.Errorf("delete issue: %w", err)
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return issueDeleteResult{}, fmt.Errorf("commit issue delete: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// publishIssueSnapshots broadcasts issue:updated for issues a delete rewrote:
|
|
// detached children and duplicates whose original was deleted.
|
|
func (h *Handler) publishIssueSnapshots(ctx context.Context, issues []db.Issue, actorType, actorID string) {
|
|
for _, issue := range issues {
|
|
response := issueToResponse(issue, h.getIssuePrefix(ctx, issue.WorkspaceID))
|
|
h.fillStatusCategory(ctx, issue.WorkspaceID, &response)
|
|
h.publish(protocol.EventIssueUpdated, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"issue": response})
|
|
}
|
|
}
|
|
|
|
// publishClearedDuplicates broadcasts issue:updated for duplicates whose
|
|
// original a delete removed. The pointer went with the original, so the
|
|
// payload names both ends the way UpdateIssue does, plus the identifier the
|
|
// activity log can no longer look up (MUL-7349).
|
|
func (h *Handler) publishClearedDuplicates(ctx context.Context, cleared []clearedDuplicate, actorType, actorID string) {
|
|
for _, c := range cleared {
|
|
prefix := h.getIssuePrefix(ctx, c.Issue.WorkspaceID)
|
|
response := issueToResponse(c.Issue, prefix)
|
|
h.fillStatusCategory(ctx, c.Issue.WorkspaceID, &response)
|
|
h.publish(protocol.EventIssueUpdated, uuidToString(c.Issue.WorkspaceID), actorType, actorID, map[string]any{
|
|
"issue": response,
|
|
"duplicate_of_issue_id": liveDuplicateMark(c.Issue.Status, c.Issue.DuplicateOfIssueID),
|
|
"prev_duplicate_of_issue_id": liveDuplicateMark(c.Issue.Status, c.Original.ID),
|
|
"prev_duplicate_of_identifier": prefix + "-" + strconv.Itoa(int(c.Original.Number)),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Batch operations
|
|
|
|
type BatchUpdateIssuesRequest struct {
|
|
IssueIDs []string `json:"issue_ids"`
|
|
Updates UpdateIssueRequest `json:"updates"`
|
|
}
|
|
|
|
func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
|
r = h.withWakeupActor(r)
|
|
bodyBytes, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "failed to read request body")
|
|
return
|
|
}
|
|
|
|
var req BatchUpdateIssuesRequest
|
|
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if len(req.IssueIDs) == 0 {
|
|
writeError(w, http.StatusBadRequest, "issue_ids is required")
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Detect which fields in "updates" were explicitly set (including null).
|
|
var rawTop map[string]json.RawMessage
|
|
json.Unmarshal(bodyBytes, &rawTop)
|
|
var rawUpdates map[string]json.RawMessage
|
|
if raw, exists := rawTop["updates"]; exists {
|
|
json.Unmarshal(raw, &rawUpdates)
|
|
}
|
|
if _, ok := rawUpdates["duplicate_of_issue_id"]; ok {
|
|
writeError(w, http.StatusBadRequest, "duplicate_of_issue_id is not supported in batch updates")
|
|
return
|
|
}
|
|
|
|
// Short-circuit when no mutation field is present in `updates`. Without
|
|
// this, the loop below runs N no-op UPDATEs (every if-guard skips, every
|
|
// COALESCE preserves the existing value) and reports `{"updated": N}` —
|
|
// the response cheerfully claims success while nothing changed. Most
|
|
// real-world cases that hit this path are caller mistakes (status placed
|
|
// at the top level, "update" misspelled as singular). Telling the truth
|
|
// here — `{"updated": 0}` — keeps the wire shape stable while making the
|
|
// count match reality. See multica-ai/multica#1660.
|
|
hasMutation := req.Updates.Title != nil ||
|
|
req.Updates.Description != nil ||
|
|
req.Updates.Status != nil ||
|
|
req.Updates.Priority != nil ||
|
|
req.Updates.Position != nil
|
|
if !hasMutation {
|
|
for _, k := range []string{"assignee_type", "assignee_id", "start_date", "due_date", "parent_issue_id", "project_id", "stage"} {
|
|
if _, ok := rawUpdates[k]; ok {
|
|
hasMutation = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !hasMutation {
|
|
writeJSON(w, http.StatusOK, map[string]any{"updated": 0})
|
|
return
|
|
}
|
|
if req.Updates.Priority != nil {
|
|
if !validateIssueEnum(w, "priority", *req.Updates.Priority, validIssuePriorities) {
|
|
return
|
|
}
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
// Status is validated against this workspace's catalog, so it has to wait
|
|
// for wsUUID above. One check for the whole batch — every issue in it
|
|
// shares the workspace — and a rejection rather than a silent skip, so a
|
|
// bad status cannot report `{"updated": N}`. (MUL-6243)
|
|
batchStatusKey := ""
|
|
if req.Updates.Status != nil {
|
|
batchStatusKey, _, ok = h.resolveIssueStatusKeyKind(w, r, wsUUID, *req.Updates.Status)
|
|
if !ok {
|
|
return
|
|
}
|
|
}
|
|
if !h.validateBatchTriageLocks(w, r, wsUUID, req.IssueIDs, rawUpdates) {
|
|
return
|
|
}
|
|
// The batch shares one project_id, so it is checked once here rather than
|
|
// per issue, and rejected instead of skipped like the per-item guards in
|
|
// the loop: a foreign project invalidates the whole request.
|
|
batchProjectID := pgtype.UUID{Valid: false}
|
|
if _, ok := rawUpdates["project_id"]; ok && req.Updates.ProjectID != nil {
|
|
projectUUID, ok := parseUUIDOrBadRequest(w, *req.Updates.ProjectID, "project_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
|
|
ID: projectUUID,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
if !isNotFound(err) {
|
|
slog.Error("batch update issues: validate project scope",
|
|
append(logger.RequestAttrs(r), "project_id", uuidToString(projectUUID), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to validate project")
|
|
return
|
|
}
|
|
writeError(w, http.StatusBadRequest, "project not found in this workspace")
|
|
return
|
|
}
|
|
batchProjectID = projectUUID
|
|
}
|
|
|
|
updated := 0
|
|
// One Resolver for the whole batch — a per-issue filler would query the
|
|
// catalog once per custom-status row. (MUL-6243)
|
|
fillBatch := h.newStatusCategoryFiller(r.Context(), wsUUID)
|
|
// Parents whose sub-issues changed this batch. Their rules are evaluated
|
|
// once after the loop against the final state (MUL-4155), not per child
|
|
// against a mid-batch snapshot.
|
|
var changedParents []pgtype.UUID
|
|
for _, issueID := range req.IssueIDs {
|
|
issueUUID, err := util.ParseUUID(issueID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
prevIssue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: issueUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
params := db.UpdateIssueParams{
|
|
SourceTaskID: h.wakeupSourceTaskID(r),
|
|
ID: prevIssue.ID,
|
|
AssigneeType: prevIssue.AssigneeType,
|
|
AssigneeID: prevIssue.AssigneeID,
|
|
StartDate: prevIssue.StartDate,
|
|
DueDate: prevIssue.DueDate,
|
|
ParentIssueID: prevIssue.ParentIssueID,
|
|
ProjectID: prevIssue.ProjectID,
|
|
Stage: prevIssue.Stage,
|
|
}
|
|
|
|
if req.Updates.Title != nil {
|
|
params.Title = pgtype.Text{String: *req.Updates.Title, Valid: true}
|
|
}
|
|
if req.Updates.Description != nil {
|
|
params.Description = pgtype.Text{String: *req.Updates.Description, Valid: true}
|
|
}
|
|
if req.Updates.Status != nil {
|
|
params.Status = pgtype.Text{String: batchStatusKey, Valid: true}
|
|
}
|
|
if req.Updates.Priority != nil {
|
|
params.Priority = pgtype.Text{String: *req.Updates.Priority, Valid: true}
|
|
}
|
|
if req.Updates.Position != nil {
|
|
params.Position = pgtype.Float8{Float64: *req.Updates.Position, Valid: true}
|
|
}
|
|
if _, ok := rawUpdates["assignee_type"]; ok {
|
|
if req.Updates.AssigneeType != nil {
|
|
params.AssigneeType = pgtype.Text{String: *req.Updates.AssigneeType, Valid: true}
|
|
} else {
|
|
params.AssigneeType = pgtype.Text{Valid: false}
|
|
}
|
|
}
|
|
if _, ok := rawUpdates["assignee_id"]; ok {
|
|
if req.Updates.AssigneeID != nil {
|
|
assigneeUUID, err := util.ParseUUID(*req.Updates.AssigneeID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
params.AssigneeID = assigneeUUID
|
|
} else {
|
|
params.AssigneeID = pgtype.UUID{Valid: false}
|
|
}
|
|
}
|
|
if _, ok := rawUpdates["start_date"]; ok {
|
|
if req.Updates.StartDate != nil && *req.Updates.StartDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.Updates.StartDate)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
params.StartDate = d
|
|
} else {
|
|
params.StartDate = pgtype.Date{Valid: false}
|
|
}
|
|
}
|
|
if _, ok := rawUpdates["due_date"]; ok {
|
|
if req.Updates.DueDate != nil && *req.Updates.DueDate != "" {
|
|
d, err := util.ParseCalendarDate(*req.Updates.DueDate)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
params.DueDate = d
|
|
} else {
|
|
params.DueDate = pgtype.Date{Valid: false}
|
|
}
|
|
}
|
|
|
|
if _, ok := rawUpdates["parent_issue_id"]; ok {
|
|
if req.Updates.ParentIssueID != nil {
|
|
newParentID, err := util.ParseUUID(*req.Updates.ParentIssueID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// Cannot set self as parent.
|
|
if newParentID == prevIssue.ID {
|
|
continue
|
|
}
|
|
// Validate parent exists in the same workspace.
|
|
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: newParentID,
|
|
WorkspaceID: prevIssue.WorkspaceID,
|
|
}); err != nil {
|
|
continue
|
|
}
|
|
// Cycle detection: walk up from the new parent to ensure we don't reach this issue.
|
|
cycleDetected := false
|
|
cursor := newParentID
|
|
for depth := 0; depth < 10; depth++ {
|
|
ancestor, err := h.Queries.GetIssue(r.Context(), cursor)
|
|
if err != nil || !ancestor.ParentIssueID.Valid {
|
|
break
|
|
}
|
|
if ancestor.ParentIssueID == prevIssue.ID {
|
|
cycleDetected = true
|
|
break
|
|
}
|
|
cursor = ancestor.ParentIssueID
|
|
}
|
|
if cycleDetected {
|
|
continue
|
|
}
|
|
params.ParentIssueID = newParentID
|
|
} else {
|
|
params.ParentIssueID = pgtype.UUID{Valid: false}
|
|
}
|
|
}
|
|
if _, ok := rawUpdates["project_id"]; ok {
|
|
// Resolved before the loop; an explicit null stays invalid and clears.
|
|
params.ProjectID = batchProjectID
|
|
}
|
|
if _, ok := rawUpdates["stage"]; ok {
|
|
if req.Updates.Stage != nil {
|
|
if *req.Updates.Stage < 1 {
|
|
continue
|
|
}
|
|
params.Stage = pgtype.Int4{Int32: *req.Updates.Stage, Valid: true}
|
|
} else {
|
|
params.Stage = pgtype.Int4{Valid: false} // explicit null = unstage
|
|
}
|
|
}
|
|
|
|
// Validate the resulting assignee pair when this batch update touches
|
|
// either assignee field. Skip the issue silently on failure.
|
|
//
|
|
// Scoped PER ISSUE (prevIssue is this iteration's row), so one bound
|
|
// issue in the batch can never lend its authority to the others: an
|
|
// unbound entry simply fails the check and is skipped. This IS a real
|
|
// agent-reachable authorization point — a task token authenticates as its
|
|
// bound workspace member, so requireUserID above is satisfied and
|
|
// resolveActor still classifies the caller as an agent (MUL-6691).
|
|
_, batchTouchedType := rawUpdates["assignee_type"]
|
|
_, batchTouchedID := rawUpdates["assignee_id"]
|
|
// Project workflow (MUL-7420), per issue: an issue whose workflow does
|
|
// not list the batch status is skipped like the other per-item guards;
|
|
// entering a handoff step reassigns this issue to the step's handler.
|
|
callerType, _ := h.resolveActor(r, userID, workspaceID)
|
|
callerRun := h.agentRunOnIssue(r, callerType, prevIssue)
|
|
handoff, err := h.applyIssueWorkflow(r.Context(), prevIssue, ¶ms, req.Updates.Status != nil, batchTouchedType || batchTouchedID, false, callerRun)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
issueStatusGuard := batchStatusKey
|
|
if params.Status.Valid {
|
|
issueStatusGuard = params.Status.String
|
|
}
|
|
if batchTouchedType || batchTouchedID || handoff != nil {
|
|
if status, _ := h.validateAssigneePair(r.Context(), r, workspaceID, params.AssigneeType, params.AssigneeID); status != 0 {
|
|
continue
|
|
}
|
|
}
|
|
|
|
writeCtx := withWakeupHandedTo(r.Context(), h.handedToAgent(r.Context(), prevIssue, params, handoff != nil))
|
|
var issue db.Issue
|
|
if req.Updates.Description != nil {
|
|
// One batch-level base cannot describe multiple issue documents.
|
|
// Preserve every marked channel-media block conservatively, matching
|
|
// legacy single-update clients that omit description_base.
|
|
var lockedPrev db.Issue
|
|
issue, lockedPrev, _, err = h.updateIssueAtomically(
|
|
writeCtx, prevIssue.WorkspaceID, params, rawUpdates, nil, nil, nil, issueStatusGuard,
|
|
)
|
|
if err == nil {
|
|
prevIssue = lockedPrev
|
|
}
|
|
} else {
|
|
issue, err = h.updateIssueWithStatusGuard(writeCtx, wsUUID, issueStatusGuard, params)
|
|
}
|
|
if err != nil {
|
|
// The archive race is a property of the batch's shared target
|
|
// status, not of one issue, so every remaining item would fail the
|
|
// same way. Abort with 409 instead of reporting a partial update.
|
|
if writeIssueStatusRaceError(w, err) {
|
|
return
|
|
}
|
|
slog.Warn("batch update issue failed", "issue_id", issueID, "error", err)
|
|
continue
|
|
}
|
|
|
|
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
|
resp := issueToResponse(issue, prefix)
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
h.advanceRunStep(r.Context(), callerRun, prevIssue, issue)
|
|
|
|
fillBatch(&resp)
|
|
assigneeChanged := (req.Updates.AssigneeType != nil || req.Updates.AssigneeID != nil || handoff != nil) &&
|
|
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
|
|
statusChanged := (req.Updates.Status != nil || params.Status.Valid) && prevIssue.Status != issue.Status
|
|
priorityChanged := req.Updates.Priority != nil && prevIssue.Priority != issue.Priority
|
|
projectChanged := req.Updates.ProjectID != nil && uuidToString(prevIssue.ProjectID) != uuidToString(issue.ProjectID)
|
|
|
|
batchHandoffNote := req.Updates.HandoffNote
|
|
trigger, willRun := h.IssueService.WillEnqueueRun(r.Context(),
|
|
service.IssueTriggerInput{
|
|
Issue: issue,
|
|
PrevStatus: prevIssue.Status,
|
|
AssigneeChanged: assigneeChanged || handoff != nil,
|
|
StatusChanged: statusChanged,
|
|
EnteredHandoffStep: handoff != nil,
|
|
},
|
|
h.issueTriggerWriteProbe(r, actorType, actorID, issue),
|
|
)
|
|
willRun = willRun && !req.Updates.SuppressRun
|
|
batchPayload := map[string]any{
|
|
"issue": resp,
|
|
"assignee_changed": assigneeChanged,
|
|
"status_changed": statusChanged,
|
|
"priority_changed": priorityChanged,
|
|
"project_changed": projectChanged,
|
|
"prev_status": prevIssue.Status,
|
|
"duplicate_of_issue_id": liveDuplicateMark(issue.Status, issue.DuplicateOfIssueID),
|
|
"prev_duplicate_of_issue_id": liveDuplicateMark(prevIssue.Status, prevIssue.DuplicateOfIssueID),
|
|
}
|
|
if handoff != nil {
|
|
batchPayload["workflow_handoff"] = workflowHandoffPayload(issue, handoff, h.workflowHandoffBrief(r.Context(), issue, handoff), willRun)
|
|
}
|
|
h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, batchPayload)
|
|
|
|
// Reassignment does not cancel existing tasks (#4963 / MUL-4113) —
|
|
// mirrors UpdateIssue. See that handler for the rationale.
|
|
//
|
|
// Same single predicate as UpdateIssue — batch must not grow its own
|
|
// copy of the enqueue rule (the historical source of four-entry-point
|
|
// drift, MUL-3375). suppress_run applies batch-wide.
|
|
if handoff != nil && req.Updates.StopPreviousAssigneeRuns {
|
|
h.stopPreviousAssigneeRuns(r.Context(), prevIssue, issue, actorType, actorID)
|
|
}
|
|
if willRun {
|
|
h.dispatchIssueRun(r.Context(), issue, trigger, actorType, actorID, batchHandoffNote)
|
|
}
|
|
|
|
// No status change — not even → cancelled — cancels active tasks here,
|
|
// mirroring UpdateIssue (MUL-4465). See that handler for the rationale.
|
|
|
|
if statusChanged || prevIssue.ParentIssueID != issue.ParentIssueID || prevIssue.Stage != issue.Stage {
|
|
changedParents = append(changedParents, issue.ParentIssueID, prevIssue.ParentIssueID)
|
|
}
|
|
|
|
updated++
|
|
}
|
|
|
|
// Each write recorded its sub-issue change; processing claims them per
|
|
// parent, so each parent is evaluated once against the batch's final
|
|
// state, independent of issue_ids order (MUL-4155). A failure is retried
|
|
// by the sweep.
|
|
h.processChildEvents(r.Context(), changedParents...)
|
|
|
|
slog.Info("batch update issues", append(logger.RequestAttrs(r), "count", updated)...)
|
|
writeJSON(w, http.StatusOK, map[string]any{"updated": updated})
|
|
}
|
|
|
|
type BatchDeleteIssuesRequest struct {
|
|
IssueIDs []string `json:"issue_ids"`
|
|
}
|
|
|
|
func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) {
|
|
var req BatchDeleteIssuesRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if len(req.IssueIDs) == 0 {
|
|
writeError(w, http.StatusBadRequest, "issue_ids is required")
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
issues := make([]db.Issue, 0, len(req.IssueIDs))
|
|
excludedIDs := make([]pgtype.UUID, 0, len(req.IssueIDs))
|
|
seenIssueIDs := make(map[pgtype.UUID]struct{}, len(req.IssueIDs))
|
|
for _, issueID := range req.IssueIDs {
|
|
issueUUID, err := util.ParseUUID(issueID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if _, duplicate := seenIssueIDs[issueUUID]; duplicate {
|
|
continue
|
|
}
|
|
issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: issueUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
seenIssueIDs[issueUUID] = struct{}{}
|
|
issues = append(issues, issue)
|
|
excludedIDs = append(excludedIDs, issue.ID)
|
|
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
|
_ = h.AutopilotService.FailAutopilotRunsByIssue(r.Context(), issue.ID)
|
|
}
|
|
deleteResult, err := h.deleteIssuesAndCollectAttachmentURLs(r.Context(), issues, excludedIDs)
|
|
if err != nil {
|
|
slog.Warn("batch delete issues failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to delete issues")
|
|
return
|
|
}
|
|
h.deleteS3Objects(r.Context(), deleteResult.AttachmentURLs)
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
for _, issue := range issues {
|
|
h.publish(protocol.EventIssueDeleted, workspaceID, actorType, actorID, map[string]any{"issue_id": uuidToString(issue.ID)})
|
|
}
|
|
h.publishIssueSnapshots(r.Context(), deleteResult.DetachedChildren, actorType, actorID)
|
|
h.publishClearedDuplicates(r.Context(), deleteResult.ClearedDuplicates, actorType, actorID)
|
|
deleted := len(issues)
|
|
|
|
slog.Info("batch delete issues", append(logger.RequestAttrs(r), "count", deleted)...)
|
|
writeJSON(w, http.StatusOK, map[string]any{"deleted": deleted})
|
|
}
|