Files
multica/server/internal/handler/issue.go
T
5ed09342af MUL-7680: issue wakeup v2 — conditions, runaway protection, check-ins and visibility (#8807)
* feat(wakeups): add rule deadlines and expose the child-done system rule (MUL-7680)

Wakeups can now end: an absolute deadline (expires_at) or a relative wait
(expires_in_seconds) that restarts when the rule is re-enabled. When the
deadline passes first, the scheduler ends the rule; event rules with
on_timeout=wake run the target once with a wakeup.timeout fact. A timed-out
rule keeps disabled_at NULL so its timeout run stays claimable.

The implicit "wake the parent's assignee when a stage of sub-issues
finishes" behavior is now described by GET /api/issues/{id}/system-wakeups
and can be turned off or given a supplementary instruction per issue via
PUT /api/issues/{id}/system-wakeups/child_done. Rule reads fail open to the
previous behavior.

List responses add the creator (member or agent) and the new expiry fields.
The CLI gains --expires-in, --expires-at and --on-timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): let members create wakeups and show the child-done system rule (MUL-7680)

The issue sidebar's Wakeups section is always available on open issues and
gains a New wakeup popover. Members pick a condition (a time, a recurring
check with an end date, someone's reply, an agent's run ending, or raw
events), the agent to wake, the instruction, how often it fires, how long
to wait and what happens on timeout. It posts the same configuration agents
create with the CLI.

The parent's child-done wake appears as a System rule with its stage,
remaining sub-issues and target, a per-issue toggle and a supplementary
instruction. Rows show when a rule ends, timed-out rules read as such, and
details name who created the rule. Rule titles wrap to two lines instead of
truncating.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* docs(wakeups): document deadlines, member-created rules and the system rule (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(wakeups): platform conditions, runaway protection and check-ins (MUL-7680)

Conditions: a wakeup can now wait for a fact the platform checks itself
(a status, assignee, label or property value; sub-issues or one stage
finishing; a linked PR's checks finishing or merging; another issue's
status). Related events only prompt an early evaluation; the rule wakes
its agent when the predicate becomes true, and a repeating rule fires
again only after it turned false or its facts changed. Finished PR checks
from before registration are ignored.

Runaway protection: repeating event rules stop after max_fires runs
(default 20), a rule pauses when its causal chain passes through it a
third time without a person in between, or when it starts 12 runs in an
hour. The reason is stored and shown.

Also: silent check-ins for every/cron checks (the run then posts no
fallback comment), wake now, delete, a per-rule run history, paused-rule
lists, timeline entries for created/triggered/timed-out/paused/check-in,
and source, paused scope, 7-day runs and child-done system rows in the
workspace list. The CLI gains --until-* conditions, --max-fires and the
trigger/delete/checkin/runs commands; the brief and wakeup prompt state
the check-in exception.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): record child-done transitions in the status write (MUL-7680)

The child-done system rule ran after the status write committed and gave
up on any error, so a crash, deploy or transient failure lost the
parent's wake. A trigger now records each child's move into a closed
status in the writing transaction, for every writer. The request that
wrote it processes the rows right away and a scheduler sweep retries
anything left over; claims make the two paths process a transition
once.

The rule also follows a workspace default (settings key
system_wakeup_child_done) until an issue sets its own, the per-issue
update accepts partial bodies, and each trigger adds a timeline entry
that names its system comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): wakeup conditions, history, timeline and workspace list (MUL-7680)

Issue sidebar: members can create the platform-checked conditions (a field
reaching a value, sub-issues or a stage finishing, a linked PR's CI or merge,
another issue's status) and cap how often a repeating wait fires. Rule
details show the fire count, why the platform paused a rule, the latest runs
with silent-check notes, and offer wake now and delete.

The issue header says what the issue is waiting for ("Emacs 在等 Jiayuan 回复
+2") and opens the Wakeups section. Board and list cards use the same short
sentence, or flag a paused rule. Wakeup runs read as "由唤醒触发 · <condition>"
in the execution log, transcript and on the comments they post.

The timeline shows created, triggered, timed-out, paused and check-in entries
with the rule they came from; consecutive check-ins merge, and the child-done
entry stands in for its system comment, which it can reveal.

Automation → Issue wakeups adds a source column and filter, 7-day runs, a
paused scope with a banner, the child-done system rule on each waiting parent,
and "New wakeup" with an issue picker. Settings → Issue statuses sets the
system rule's workspace default. Copy in all five languages.

The server records the watched issue's identifier in other-issue conditions
and the rule's creator in timeline snapshots, and returns board summaries with
their condition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* docs(wakeups): document conditions, runaway protection and check-ins (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): list system rules without a revision and localize statuses (MUL-7680)

System rule rows returned revision 0, which clients reject as a rule
revision; they now return null, and a list row with an invalid revision no
longer fails the whole page. Built-in statuses in conditions and the status
picker read in the viewer's language. Adds a browser test for a condition
created from the form, the header summary, the scheduler waking the agent
and the workspace list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(views): say why a rule paused without repeating "paused" (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(i18n): use the product's task terms in wakeup copy (MUL-7680)

ja, ko and fr wakeup strings said イシュー / 이슈 / ticket; the product term
for an issue in those languages is タスク / 태스크 / tâche (and sub-tasks
accordingly), per the conventions page. French strings are rewritten for
the feminine noun.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(views): pin the shortcut platform in the wakeup form test (MUL-7680)

The send shortcut is primary+Enter, which is Ctrl on Linux CI runners, so
pressing Cmd+Enter only submitted on macOS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(views): simplify the workspace wakeup list

Put the filters on one row: scope as a segmented control on the left,
source/trigger/agent and a search-as-you-type box on the right. "New
wakeup" moves to the page header, which drops the separate Search button.

Rows are single-line: the identifier sits before the title, frequency and
timezone move into the trigger tooltip, and selection, prompt edit and
the last run's transcript show on row hover or focus. The issue column
takes the remaining width and truncates, so the table fits the page.

Also fix creating from the list: the issue picker closes itself right
after reporting the selection, and that close cancelled the flow before
the form could open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(server): run the child_done rule as a system issue wakeup

The parent-assignee wake on closed sub-issue stages was its own code path:
a system comment, a mention-style run, an override table and endpoints.
It is now an ordinary issue_wakeup row with system_rule set, sharing the
condition, receipts, runaway protection, timeline entries and run model of
people's rules.

- One children_done evaluator: a stage wakes the assignee when it closes
  while a later stage waits; the wrap-up waits for every sub-issue,
  unstaged ones included. People's sub-issue conditions read the same set.
- issue_child_event records closing, reopening, re-parenting and restaging
  for every writer; the request processes it right after commit and the
  sweep retries. User sub-issue rules become immediate too.
- The target is resolved when it fires: an agent run, a squad leader run,
  an inbox notification for a member, or only the timeline entry.
- A parked (backlog) parent catches up when it leaves backlog; a waiting
  run of the same agent is joined instead of queuing a second one; the
  hourly limit pauses a runaway rule. No system comment is posted.
- The run gets the issue's instruction, else the workspace's, else the
  built-in one, plus each stage's counts and the next stage.
- Workspace defaults move to GET/PUT /api/system-wakeups (owners and
  admins) and apply to every rule nobody customized; turning a rule on
  records what already holds so old facts do not fire.
- Existing open parents get their rule from a one-time backfill.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): workspace wakeup settings and sub-issue notifications

- Settings → Wakeups holds the sub-issue rule's workspace default and
  default instruction; the toggle leaves Issue statuses.
- The issue's system rule shows who it reaches (a member is notified),
  a platform pause, and the instruction it will use.
- Timeline entries say whether the assignee was woken, notified or joined
  a waiting run. The folding of the old system comment is gone.
- Inbox renders the new children_done notification on web, desktop and
  mobile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): stop wakeups from repeating their agent's runs

Runs are serialized per issue and agent, so a wakeup never ran beside
another run of its agent, but it queued behind one and repeated it. Each
rule now checks two things before it starts a run:

- Acknowledged: every input came from the agent itself. Its own comments
  and issue changes never wake it, and a condition its own unfinished run
  on the issue satisfied does not wake it when the platform or the agent
  set the rule up. This fixes #8849: a coordinator closing its own stage is
  not woken again while that run is going. A person's condition rule still
  runs afterwards, since the running agent lacks its instruction.
- Merged: when a run of the agent is already waiting to start on the issue
  (assignment, comment, another rule), the rule's instruction and facts
  join that run (context.wakeup_joined, sent to the daemon as
  wakeup_joined and rendered for every prompt kind) instead of queuing
  another. Turning the rule off or replacing it withdraws what it joined.

Sub-issue changes now record the run that made them, and hints and
condition.met inputs carry those runs so a satisfied condition knows
whether the agent caused it. A rule that names the agent itself as the
actor keeps waking it on its own changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): show merged and acknowledged wakeups, hint assignee comment rules

- Timeline entries say when a wakeup joined the agent's waiting run or was
  the agent's own doing, for people's rules and the sub-issue rule.
- The create form tells a member that a reply or comment rule for the
  issue's agent assignee joins the run a comment already starts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): hand waiting wakeups to a run when it is claimed

A rule that fired while its agent had a run waiting used to write its
instruction into that run right away and consume its inputs. That let a
member's rule run under another member's identity, kept deleted rules'
instructions in the run, lost the inputs when the run was cancelled or
claimed by an older daemon, and skipped the fire cap and loop check.

Now the rule only waits for a run that runs as the same person its own
run would, keeping its inputs. When a daemon advertising
joined-wakeups-v1 claims that run, JoinWaitingWakeups rechecks each rule
(on, same agent and person, creator still allowed, not the agent's own
input, no loop), consumes its inputs, counts the fire toward max_fires,
adds its chain and records the merge. A re-claim drops entries of rules
turned off or changed since. Anything else leaves the rule its inputs to
start its own run.

A child_done rule created by the backfill or by processing a change now
treats sub-issues with unprocessed close events as still open, so the
backfill no longer swallows a close waiting for the sweep.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): consume joined wakeup inputs only once the run starts

A claim used to consume the inputs of the rules that joined the run. If
that claim did not go through, the run went back to the queue carrying
inputs the rules no longer had: turning the sub-issue rule off on the
issue or for the workspace (which only changes enabled) did not remove
them from the next claim, and cancelling the run lost a once rule that
had already ended.

A claim now reserves the inputs for the run (receipt task_id, still
unprocessed). The rule's next dispatch settles them: a run that started
consumes them as a merged firing (once ends, max_fires counts and can
pause, timeline entry); a run that ended without starting gives them
back; one that has not started keeps them. Turning a rule off or
changing it discards its pending inputs, reserved ones included, and a
later claim of the same run rechecks every entry and drops the ones
without reserved inputs or no longer allowed to reach the run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-27 21:08:54 +08:00

4910 lines
180 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/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 is the 7 BUILT-IN status keys. Since MUL-6243 it is no
// longer the set of writable statuses — write paths validate against the
// workspace's catalog via validateIssueStatusKey — and it survives only for the
// issue-table grouping/filtering paths, which key their group descriptors and
// compound cells off a fixed status list.
//
// KNOWN LIMITATION: a custom status is therefore not yet selectable as an
// issue-table group or filter value. That is a self-contained follow-up (the
// table's group descriptors and compound cell keys need to become catalog
// driven); it is scoped out here so this change cannot alter the table view for
// workspaces that have no custom statuses.
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 patterns like "MUL-123" or "ABC-45".
var identifierNumberRe = regexp.MustCompile(`(?i)^[a-z]+-(\d+)$`)
// parseQueryNumber extracts an issue number from the query if it looks like
// an identifier (e.g. "MUL-123") 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 {
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 {
// 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
}
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
}
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(&params); 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 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: "+err.Error())
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"`
// 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(&params, 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"]
if touchedType || touchedID {
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
}
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(
r.Context(), prevIssue.WorkspaceID, params, rawFields, req.TitleBase, req.DescriptionBase, attachmentIDs, statusKeyForGuard,
)
if lockedPrev.ID.Valid {
prevIssue = lockedPrev
}
} else {
issue, err = h.updateIssueWithStatusGuard(r.Context(), 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: "+err.Error())
return
}
// Determine actor identity: agent (via X-Agent-ID header) or member.
actorType, actorID := h.resolveActor(r, userID, workspaceID)
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) &&
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
statusChanged := req.Status != nil && 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)
h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, 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 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.
if trigger, ok := h.IssueService.WillEnqueueRun(r.Context(),
service.IssueTriggerInput{
Issue: issue,
PrevStatus: prevIssue.Status,
AssigneeChanged: assigneeChanged,
StatusChanged: statusChanged,
},
h.issueTriggerWriteProbe(r, actorType, actorID, issue),
); ok && !req.SuppressRun {
h.dispatchIssueRun(r.Context(), issue, trigger, actorType, actorID, req.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"]
if batchTouchedType || batchTouchedID {
if status, _ := h.validateAssigneePair(r.Context(), r, workspaceID, params.AssigneeType, params.AssigneeID); status != 0 {
continue
}
}
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(
r.Context(), prevIssue.WorkspaceID, params, rawUpdates, nil, nil, nil, batchStatusKey,
)
if err == nil {
prevIssue = lockedPrev
}
} else {
issue, err = h.updateIssueWithStatusGuard(r.Context(), wsUUID, batchStatusKey, 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)
fillBatch(&resp)
assigneeChanged := (req.Updates.AssigneeType != nil || req.Updates.AssigneeID != nil) &&
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
statusChanged := req.Updates.Status != nil && prevIssue.Status != issue.Status
priorityChanged := req.Updates.Priority != nil && prevIssue.Priority != issue.Priority
projectChanged := req.Updates.ProjectID != nil && uuidToString(prevIssue.ProjectID) != uuidToString(issue.ProjectID)
h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, map[string]any{
"issue": resp,
"assignee_changed": assigneeChanged,
"status_changed": statusChanged,
"priority_changed": priorityChanged,
"project_changed": projectChanged,
"duplicate_of_issue_id": liveDuplicateMark(issue.Status, issue.DuplicateOfIssueID),
"prev_duplicate_of_issue_id": liveDuplicateMark(prevIssue.Status, prevIssue.DuplicateOfIssueID),
})
// 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 trigger, ok := h.IssueService.WillEnqueueRun(r.Context(),
service.IssueTriggerInput{
Issue: issue,
PrevStatus: prevIssue.Status,
AssigneeChanged: assigneeChanged,
StatusChanged: statusChanged,
},
h.issueTriggerWriteProbe(r, actorType, actorID, issue),
); ok && !req.Updates.SuppressRun {
h.dispatchIssueRun(r.Context(), issue, trigger, actorType, actorID, req.Updates.HandoffNote)
}
// 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})
}