mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* feat(server): steer running turns from ordinary comments (MUL-7631) A comment can now name recipients whose running turn should receive it (`steer_agent_ids` on POST /issues/:id/comments). Each named recipient with a running, steer-capable turn gets the comment bound to that turn instead of a follow-up run; any other recipient keeps its normal queued / coalesced / deferred trigger, so losing a race never drops input. - One comment can steer several turns: receipts are keyed by (comment_id, task_id) and returned as `supplements` on comments and timeline entries. The single supplement_* fields mirror the first receipt for older clients. - Completion reconciliation skips a steered comment only for the agent it steered, so the same comment still reaches recipients that were addressed without steering. - Delivery claims match the (comment, run) pair. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): choose per recipient how a message reaches a running agent (MUL-7631) Steering moves from a separate "Add message" box on the run row into the normal reply and comment composers. Recipients keep the existing trigger rules; each recipient's current run on the issue decides what the message can do: - running (and steer-capable): add to the current run (default in that run's own thread), start after this run, stop and start over, or skip - queued: include when it starts, or skip - idle: start on send, or skip Receipts follow the message ("Waiting for Lambda to read it" → "Read by Lambda · after step N"), a failed delivery offers retry or sending it as a new run, and the run's step list marks where it read the message. A draft whose recipient finished meanwhile stays in place and says it will start a new run; a message with files is handled after the run. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(server): steer only the chosen turn, and make a retried steer idempotent (MUL-7631) - Steering names the exact running turns the author chose (`steer_task_ids`). A chosen turn that ended before the send arrives is never swapped for a later turn of the same agent; that recipient keeps its normal trigger. - A steering send carries `client_request_id`. A retry after a lost response returns the original comment (200) instead of creating a second comment and a second delivery; a concurrent twin binding the same request is treated as already steered. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): guard steering sends against stale previews and broad resends (MUL-7631) - "Stop and start over" never stops a run while the trigger preview is still catching up with an edited @mention: the stop is dropped unless the preview answers the submitted text. - Steering sends the chosen turns' task ids and one request id per draft text, reused on retries of the same text. - "Send as a new run" on a failed receipt reaches only that receipt's agent: every other current recipient is suppressed. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): keep the reply box placeholder neutral (MUL-7631) The empty reply box cannot know who a reply goes to: the author may @ someone else or only talk to a teammate. The recipient chip already says what Send will do once there is text. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * test(issues): give steering composer tests room for the preview debounce (MUL-7631) Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): choose the default reply to a running agent (MUL-7631) Preferences > Comments gains "When replying to a running agent": "Add to current run" (default) or "Start after this run". It only changes what a reply does without a per-message choice; the recipient chip still offers both, and replies that never steered by default (another thread, an idle or queued agent) are unaffected. Saved on this device next to the sticky comment bar. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(server): save one comment per send and honor "don't start" on replay (MUL-7631) Idempotency now lives on the comment, not the delivery receipt. A send's client_request_id is stored on the comment behind a partial unique index (migrations 550/551), so a retry, or a concurrent twin, returns the saved comment (200) whether the send steered a running turn or fell back to a normal trigger. Before, a twin could save a second comment, and a send whose turn had ended kept no receipt to find. The agents an author chose not to start are stored on the comment too, and completion replay skips them. "Send as a new run" for one missed recipient, and "Won't start this time", no longer wake the other agent once its current run completes. An edit re-records the choice. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): repeat the original steering request on retry (MUL-7631) Until a send is settled, retrying the same text sends its first steering request unchanged, even after the chosen turn ended. The server then returns the comment the first attempt saved instead of posting it again as new input. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(migrations): fail fast on locks in 549 and 550 (MUL-7631) Both take an ACCESS EXCLUSIVE lock on a table the product touches continuously: comment for every read and write, task_supplement for every daemon claim. Bound lock acquisition and execution, as 483 does for issue, so a long transaction makes the migration fail and retry on the next run instead of queueing all traffic behind it. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(server): steer at most one turn per comment during rollout (MUL-7631) Steering is live, and servers from before per-run receipts claim every receipt of a comment at once. While they still serve daemons during a rollout, a comment bound to two turns could have the second copy marked in flight and never delivered. Until every server claims per (comment, run), a comment steers the first chosen turn only; later recipients keep their normal trigger. A follow-up lifts the limit after this ships. Also covers a steer_task_ids entry that names another issue's turn. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(server): finish a saved send on retry; keep an edit's choices with its text (MUL-7631) A send's comment was saved before its agents were started or steered, and deduplication treated the saved comment as the whole send. A dropped connection cancelled the request between the two, and the retry only returned the comment, so no agent ever received it. - Attempts of one logical send now run one at a time under a transaction advisory lock, held on its own connection: a twin waits for the attempt in progress (up to 30s, then 409) instead of racing it, and a process that dies releases it. - After the comment is saved, dispatch runs on a non-cancelable context, and comment.client_request_dispatched_at (migration 552) records that it finished. A retry that finds a saved but undispatched comment finishes the dispatch with the comment's recorded choices. An edit's "don't start" choices were written by a separate statement after the text, so interleaved edits could leave an older edit's choice. They are now part of UpdateComment, under the same revision check as the text. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): offer one steered recipient per message during rollout (MUL-7631) The server steers at most one turn per comment until #8800, but the composer showed "Add to current run" for every running recipient and the server quietly queued the rest. Now only one recipient steers: the one the author picked, otherwise the first that would by default. The others show "Start after this run", and picking "Add to current run" on one of them moves the steer there. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(server): claim a send before dispatching it; drop the request lock (MUL-7631) The previous recovery marked a send dispatched only after reaching its agents, so a failure after dispatch made a retry reach them all again. It also held a pooled connection per send for an advisory lock while the send needed more from the same pool, which could wait on itself until timeout. A send's comment now holds a dispatch claim instead (comment.client_request_dispatched_at, one conditional update): only the attempt whose claim takes effect publishes the comment and reaches its agents. A failed claim writes nothing else and returns 500 for the client to retry; a retry that finds an unclaimed comment claims and dispatches it; a claimed one is only returned. Concurrent attempts no longer wait on each other, and post-save dispatch keeps a 30s bound of its own. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(server): let one comment steer several running turns (MUL-7631) Lifts the rollout limit from #8760. Every server now claims receipts per (comment, run), so a comment can be bound to each running turn its author chose, and each recipient receives it in its own run. Merge only after #8760 is deployed everywhere. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): steer every running recipient a message addresses (MUL-7631) The client side of lifting the rollout limit: each running recipient can take the message in its current turn again, instead of only one per message. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * refactor(issues): drop send-retry idempotency and two display extras (MUL-7631) A steering send now behaves like any other comment when its response is lost: a retry posts it again. The machinery that tried to make that retry return the saved comment grew with every edge it met, and ordinary comments never had it: - Remove comment.client_request_id, its unique index, and the dispatch claim (migrations 551/552, and 550 reduced to suppressed_agent_ids). - Remove the replay/resume path in CreateComment, the post-save non-cancelable dispatch context, and the client's sticky request id. Steering sends only `steer_task_ids`; each binding uses the comment id as its receipt request id. Two display extras go too, since the UI already shows the same thing: - The "run ended while you were writing" notice: the recipient chip already switches from "Add to current run" to its new action. - "after step N" on a read receipt: the run trace already places the message at the step where it was read. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): space agent names in Chinese copy; neutral recipients header (MUL-7631) - The steer badge joined names with Intl.ListFormat, which gives "Lambda和Orion" in Chinese. Latin names are now spaced from the Chinese joiner ("Lambda 和 Orion"), per the zh copy conventions; the enumeration comma stays unspaced. - The multi-recipient popover was still titled "Will start when sent" and its tooltip said "choose who runs", though each row now picks how that agent handles the message. Title it "Recipients" and reword the tooltip. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): translate the recipient source labels in Chinese (MUL-7631) "assignee" and "@mention" were left in English in zh-Hans; the recipient menu header shows them. Use 负责人 (per the zh conventions) and @提及. 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]>
477 lines
18 KiB
Go
477 lines
18 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// TimelineEntry represents a single entry in the issue timeline, which can be
|
|
// either an activity log record or a comment.
|
|
type TimelineEntry struct {
|
|
Type string `json:"type"` // "activity" or "comment"
|
|
ID string `json:"id"`
|
|
|
|
ActorType string `json:"actor_type"`
|
|
ActorID string `json:"actor_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
// Display-only identity is hydrated from the global user row for member
|
|
// actors. It remains available after the member leaves this workspace;
|
|
// actor_type + actor_id stay the durable attribution keys.
|
|
ActorName string `json:"actor_name,omitempty"`
|
|
ActorAvatarURL string `json:"actor_avatar_url,omitempty"`
|
|
|
|
// Activity-only fields
|
|
Action *string `json:"action,omitempty"`
|
|
Details json.RawMessage `json:"details,omitempty"`
|
|
|
|
// Comment-only fields
|
|
Content *string `json:"content,omitempty"`
|
|
ParentID *string `json:"parent_id,omitempty"`
|
|
UpdatedAt *string `json:"updated_at,omitempty"`
|
|
Revision int64 `json:"revision,omitempty"`
|
|
CommentType *string `json:"comment_type,omitempty"`
|
|
// Set only on comments produced by a quick action run. Unforgeable: there
|
|
// is no request field for it on the generic comment endpoint.
|
|
QuickActionID *string `json:"quick_action_id,omitempty"`
|
|
Reactions []ReactionResponse `json:"reactions,omitempty"`
|
|
Attachments []AttachmentResponse `json:"attachments,omitempty"`
|
|
ResolvedAt *string `json:"resolved_at,omitempty"`
|
|
ResolvedByType *string `json:"resolved_by_type,omitempty"`
|
|
ResolvedByID *string `json:"resolved_by_id,omitempty"`
|
|
SourceTaskID *string `json:"source_task_id,omitempty"`
|
|
// Set only on a tombstone: a comment deleted while it still had replies.
|
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
|
// Supplements lists every running turn this comment steered; the single
|
|
// supplement_* fields mirror the first receipt for older clients.
|
|
Supplements []CommentSupplementResponse `json:"supplements,omitempty"`
|
|
SupplementTaskID string `json:"supplement_task_id,omitempty"`
|
|
SupplementStatus string `json:"supplement_status,omitempty"`
|
|
SupplementFailureReason *string `json:"supplement_failure_reason,omitempty"`
|
|
SupplementDeliveredAt *string `json:"supplement_delivered_at,omitempty"`
|
|
}
|
|
|
|
// timelineHardCap bounds the per-issue timeline payload. Sized as a defensive
|
|
// safety net, not a UX page window: see commentHardCap in comment.go for the
|
|
// data-shape rationale (#1929). A variable only so the cap tests can shrink it,
|
|
// like commentHardCap; nothing outside tests assigns it.
|
|
var timelineHardCap = 2000
|
|
|
|
// timelineProbeLimit reads one row past the cap so "we hit the cap" can be
|
|
// distinguished from "the issue happens to have exactly timelineHardCap rows".
|
|
// Without the probe row an issue sitting exactly on the boundary would report a
|
|
// complete timeline as truncated and pay a needless ancestor-backfill query.
|
|
func timelineProbeLimit() int32 { return int32(timelineHardCap) + 1 }
|
|
|
|
// Truncation is signalled with a response header rather than a body field
|
|
// because the unpaginated response is a bare JSON array (TimelineEntriesSchema =
|
|
// z.array(TimelineEntrySchema) in packages/core/api/schemas.ts) with nowhere to
|
|
// put a flag. The header is additive: existing clients keep validating.
|
|
//
|
|
// The value names which kinds were truncated ("activity", "comment", or
|
|
// "activity,comment") because the two caps are independent — in practice it is
|
|
// almost always activity alone.
|
|
//
|
|
// There is deliberately no companion "window from" header. An earlier revision
|
|
// emitted one, but TimestampToString is second-precision RFC3339 while the real
|
|
// ordering key is (created_at, id) at full precision, so it could not be used to
|
|
// resume a read without skipping or repeating rows inside a shared second. A
|
|
// resumable cursor needs to be opaque and carry both halves; that is worth
|
|
// designing when there is a consumer, not shipping as a lossy approximation.
|
|
//
|
|
// Exported so the CORS layer can reference the same identifier
|
|
// (corsExposedHeaders in server/cmd/server/router.go). A custom response header
|
|
// is invisible to browser JS unless explicitly exposed, so a rename here that
|
|
// did not reach the CORS list would silently switch the signal back off.
|
|
const HeaderTimelineTruncated = "X-Timeline-Truncated"
|
|
|
|
// truncatedKinds renders the X-Timeline-Truncated value, "" when nothing was
|
|
// truncated.
|
|
func truncatedKinds(comments, activities bool) string {
|
|
switch {
|
|
case comments && activities:
|
|
return "activity,comment"
|
|
case comments:
|
|
return "comment"
|
|
case activities:
|
|
return "activity"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// takeNewest trims a timelineProbeLimit read down to timelineHardCap and reports
|
|
// whether the probe row proved older rows exist. rows must be ascending, so the
|
|
// newest timelineHardCap entries are the tail.
|
|
func takeNewest[T any](rows []T) ([]T, bool) {
|
|
if len(rows) <= timelineHardCap {
|
|
return rows, false
|
|
}
|
|
return rows[len(rows)-timelineHardCap:], true
|
|
}
|
|
|
|
// timelinePaginatedResponse mirrors the wrapper shape produced by the prior
|
|
// cursor-paginated ListTimeline (#2128). It is preserved as a backward-compat
|
|
// surface for installed Desktop builds and stale Web bundles between #2128 and
|
|
// #1929 that send `?limit=`/`?before=`/`?after=`/`?around=` and parse the
|
|
// response with the old TimelinePageSchema (entries + cursors). Cursors are
|
|
// always nil and `has_more_after` is always false: the new server returns the
|
|
// whole timeline in one shot. `has_more_before` is now truthful — it reports the
|
|
// hard-cap clamp — instead of being hardcoded false.
|
|
type timelinePaginatedResponse struct {
|
|
Entries []TimelineEntry `json:"entries"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
PrevCursor *string `json:"prev_cursor"`
|
|
HasMoreBefore bool `json:"has_more_before"`
|
|
HasMoreAfter bool `json:"has_more_after"`
|
|
TargetIndex *int `json:"target_index,omitempty"`
|
|
}
|
|
|
|
// ListTimeline returns the full issue timeline (comments + activities merged).
|
|
// Two response shapes coexist for boundary compatibility (#1929):
|
|
//
|
|
// - No pagination params → flat ASC `TimelineEntry[]`. Matches the legacy
|
|
// desktop contract (Multica.app ≤ v0.2.25) and the new client.
|
|
// - Any of `limit` / `before` / `after` / `around` present → wrapped object
|
|
// with DESC entries + null cursors + has_more_after=false. Matches what a
|
|
// stale v0.2.26+ build expects when it parses the response with
|
|
// TimelinePageSchema; cursor-walking is now a no-op so the client just
|
|
// sees a single full page.
|
|
//
|
|
// Both shapes carry the same set of entries — paging and ordering differ.
|
|
// Time-based pagination was removed because it split reply threads at page
|
|
// boundaries, and at observed data sizes (p99 ~30 comments per issue) the
|
|
// cursor machinery was pure overhead.
|
|
//
|
|
// When the hard cap fires each list is independently reduced to its newest
|
|
// entries and X-Timeline-Truncated names which kinds were affected. Comment
|
|
// threads cut by the window are completed afterwards within a bounded context
|
|
// budget; a thread that cannot be completed is omitted as one unit (MUL-5492).
|
|
func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
|
|
comments, err := h.Queries.ListCommentsForIssue(ctx, db.ListCommentsForIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
Limit: timelineProbeLimit(),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list comments")
|
|
return
|
|
}
|
|
activities, err := h.Queries.ListActivitiesForIssue(ctx, db.ListActivitiesForIssueParams{
|
|
IssueID: issue.ID,
|
|
Limit: timelineProbeLimit(),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list activities")
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
wantWrapped := q.Get("limit") != "" || q.Get("before") != "" ||
|
|
q.Get("after") != "" || q.Get("around") != ""
|
|
|
|
// Each list is capped independently and reports its own truncation. The two
|
|
// are deliberately NOT clamped to a shared floor.
|
|
//
|
|
// An earlier revision did clamp them, to guarantee the returned window was a
|
|
// contiguous correctly-interleaved slice with no region holding only one of
|
|
// the two kinds. That traded the wrong way round. Comments are human-paced
|
|
// (p99 ~30, max ever observed ~1.1k) so they essentially never reach the cap,
|
|
// while activity is machine-paced and reaches it routinely — so the shared
|
|
// floor was almost always the ACTIVITY floor cutting away comments that had
|
|
// been fetched successfully and would have rendered fine. It deleted real
|
|
// content to buy a cosmetic property, and on a busy issue with thirty
|
|
// comments it was pure loss.
|
|
//
|
|
// Not clamping costs only activity density in the older part of the range,
|
|
// which is metadata, not content — and it is reported rather than hidden.
|
|
// It also keeps the comment set from being cut at an arbitrary row, which is
|
|
// why completeCommentThreads repairs any affected thread below.
|
|
comments, commentsTruncated := takeNewest(comments)
|
|
activities, activitiesTruncated := takeNewest(activities)
|
|
|
|
// Timeline clients derive resolution bars, author lists, and folded counts
|
|
// from the comments in this response. A parent-only repair would therefore
|
|
// make a partial thread look complete. Restore all missing siblings and
|
|
// descendants for affected roots within the shared context budget; if the
|
|
// walk cannot prove a thread complete, omit that thread as one unit.
|
|
if commentsTruncated {
|
|
var err error
|
|
comments, err = h.completeCommentThreads(ctx, issue.ID, issue.WorkspaceID, comments)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to complete comment threads")
|
|
return
|
|
}
|
|
}
|
|
|
|
if kinds := truncatedKinds(commentsTruncated, activitiesTruncated); kinds != "" {
|
|
w.Header().Set(HeaderTimelineTruncated, kinds)
|
|
}
|
|
|
|
entries := h.mergeTimeline(r, comments, activities, !wantWrapped)
|
|
// The current-member directory deliberately excludes departed members, but
|
|
// timeline attribution must remain readable after they leave. Hydrate only
|
|
// the member ids already present in this authorised issue response; lookup
|
|
// failure is display-only and must not make the timeline unavailable.
|
|
h.hydrateTimelineMemberActors(ctx, entries)
|
|
if entries == nil {
|
|
entries = []TimelineEntry{}
|
|
}
|
|
|
|
if wantWrapped {
|
|
resp := timelinePaginatedResponse{
|
|
Entries: entries,
|
|
HasMoreBefore: commentsTruncated || activitiesTruncated,
|
|
}
|
|
// `around=<id>`: locate the anchor in the DESC slice so the legacy
|
|
// client can scroll-to-highlight without a follow-up request.
|
|
if anchor := q.Get("around"); anchor != "" {
|
|
for i, e := range entries {
|
|
if e.ID == anchor {
|
|
idx := i
|
|
resp.TargetIndex = &idx
|
|
break
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, entries)
|
|
}
|
|
|
|
// mergeTimeline merges comments and activities and returns them sorted by
|
|
// (created_at, id). When ascending=true, oldest first (the new flat-array
|
|
// contract); otherwise newest first (the wrapped legacy contract).
|
|
func (h *Handler) mergeTimeline(r *http.Request, comments []db.Comment, activities []db.ActivityLog, ascending bool) []TimelineEntry {
|
|
out := make([]TimelineEntry, 0, len(comments)+len(activities))
|
|
out = append(out, h.commentsToEntries(r, comments)...)
|
|
for _, a := range activities {
|
|
out = append(out, activityToEntry(a))
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].CreatedAt != out[j].CreatedAt {
|
|
if ascending {
|
|
return out[i].CreatedAt < out[j].CreatedAt
|
|
}
|
|
return out[i].CreatedAt > out[j].CreatedAt
|
|
}
|
|
if ascending {
|
|
return out[i].ID < out[j].ID
|
|
}
|
|
return out[i].ID > out[j].ID
|
|
})
|
|
return out
|
|
}
|
|
|
|
// commentsToEntries fetches reactions + attachments for the given comments in
|
|
// one batch each and returns enriched TimelineEntry slices preserving order.
|
|
func (h *Handler) commentsToEntries(r *http.Request, comments []db.Comment) []TimelineEntry {
|
|
if len(comments) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]pgtype.UUID, len(comments))
|
|
for i, c := range comments {
|
|
ids[i] = c.ID
|
|
}
|
|
reactions := h.groupReactions(r, ids)
|
|
attachments := h.groupAttachments(r, ids)
|
|
supplements := h.listCommentSupplements(r.Context(), comments[0].WorkspaceID, ids)
|
|
|
|
out := make([]TimelineEntry, len(comments))
|
|
for i, c := range comments {
|
|
content := c.Content
|
|
commentType := c.Type
|
|
updatedAt := timestampToString(c.UpdatedAt)
|
|
cid := uuidToString(c.ID)
|
|
out[i] = TimelineEntry{
|
|
Type: "comment",
|
|
ID: cid,
|
|
ActorType: c.AuthorType,
|
|
ActorID: uuidToString(c.AuthorID),
|
|
Content: &content,
|
|
CommentType: &commentType,
|
|
QuickActionID: uuidToPtr(c.QuickActionID),
|
|
ParentID: uuidToPtr(c.ParentID),
|
|
CreatedAt: timestampToString(c.CreatedAt),
|
|
UpdatedAt: &updatedAt,
|
|
Revision: c.Revision,
|
|
Reactions: reactions[cid],
|
|
Attachments: attachments[cid],
|
|
ResolvedAt: timestampToPtr(c.ResolvedAt),
|
|
ResolvedByType: textToPtr(c.ResolvedByType),
|
|
ResolvedByID: uuidToPtr(c.ResolvedByID),
|
|
SourceTaskID: uuidToPtr(c.SourceTaskID),
|
|
DeletedAt: timestampToPtr(c.DeletedAt),
|
|
}
|
|
if receipts := supplements[cid]; len(receipts) > 0 {
|
|
out[i].Supplements = receipts
|
|
out[i].SupplementTaskID = receipts[0].TaskID
|
|
out[i].SupplementStatus = receipts[0].Status
|
|
out[i].SupplementFailureReason = receipts[0].FailureReason
|
|
out[i].SupplementDeliveredAt = receipts[0].DeliveredAt
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func activityToEntry(a db.ActivityLog) TimelineEntry {
|
|
action := a.Action
|
|
actorType := ""
|
|
if a.ActorType.Valid {
|
|
actorType = a.ActorType.String
|
|
}
|
|
return TimelineEntry{
|
|
Type: "activity",
|
|
ID: uuidToString(a.ID),
|
|
ActorType: actorType,
|
|
ActorID: uuidToString(a.ActorID),
|
|
Action: &action,
|
|
Details: a.Details,
|
|
CreatedAt: timestampToString(a.CreatedAt),
|
|
}
|
|
}
|
|
|
|
// hydrateTimelineMemberActors adds display identity for member-authored rows
|
|
// without changing the active-member directory's semantics. The ids came from
|
|
// comments/activity rows on an issue loadIssueForUser already authorised, so
|
|
// this creates no arbitrary user lookup surface. The query is bounded by the
|
|
// timeline hard caps and runs once for the whole response, never once per row.
|
|
func (h *Handler) hydrateTimelineMemberActors(ctx context.Context, entries []TimelineEntry) {
|
|
seen := make(map[string]struct{})
|
|
ids := make([]pgtype.UUID, 0)
|
|
for i := range entries {
|
|
entry := &entries[i]
|
|
if entry.ActorType != "member" || entry.ActorID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[entry.ActorID]; ok {
|
|
continue
|
|
}
|
|
id, err := util.ParseUUID(entry.ActorID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
seen[entry.ActorID] = struct{}{}
|
|
ids = append(ids, id)
|
|
}
|
|
if len(ids) == 0 {
|
|
return
|
|
}
|
|
|
|
users, err := h.Queries.GetUsersByIDs(ctx, ids)
|
|
if err != nil {
|
|
return
|
|
}
|
|
byID := make(map[string]db.GetUsersByIDsRow, len(users))
|
|
for _, user := range users {
|
|
byID[uuidToString(user.ID)] = user
|
|
}
|
|
for i := range entries {
|
|
entry := &entries[i]
|
|
if entry.ActorType != "member" {
|
|
continue
|
|
}
|
|
user, ok := byID[entry.ActorID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
entry.ActorName = user.Name
|
|
if user.AvatarUrl.Valid {
|
|
entry.ActorAvatarURL = h.resolveAvatarURL(user.AvatarUrl.String)
|
|
}
|
|
}
|
|
}
|
|
|
|
// AssigneeFrequencyEntry represents how often a user assigns to a specific target.
|
|
type AssigneeFrequencyEntry struct {
|
|
AssigneeType string `json:"assignee_type"`
|
|
AssigneeID string `json:"assignee_id"`
|
|
Frequency int64 `json:"frequency"`
|
|
}
|
|
|
|
// GetAssigneeFrequency returns assignee usage frequency for the current user,
|
|
// combining data from assignee change activities and initial issue assignments.
|
|
func (h *Handler) GetAssigneeFrequency(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
// Aggregate frequency from both data sources.
|
|
freq := map[string]int64{} // key: "type:id"
|
|
|
|
// Source 1: assignee_changed activities by this user.
|
|
activityCounts, err := h.Queries.CountAssigneeChangesByActor(r.Context(), db.CountAssigneeChangesByActorParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
ActorID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
|
|
return
|
|
}
|
|
for _, row := range activityCounts {
|
|
aType, _ := row.AssigneeType.(string)
|
|
aID, _ := row.AssigneeID.(string)
|
|
if aType != "" && aID != "" {
|
|
freq[aType+":"+aID] += row.Frequency
|
|
}
|
|
}
|
|
|
|
// Source 2: issues created by this user with an assignee.
|
|
issueCounts, err := h.Queries.CountCreatedIssueAssignees(r.Context(), db.CountCreatedIssueAssigneesParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
CreatorID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
|
|
return
|
|
}
|
|
for _, row := range issueCounts {
|
|
if !row.AssigneeType.Valid || !row.AssigneeID.Valid {
|
|
continue
|
|
}
|
|
key := row.AssigneeType.String + ":" + uuidToString(row.AssigneeID)
|
|
freq[key] += row.Frequency
|
|
}
|
|
|
|
// Build sorted response.
|
|
result := make([]AssigneeFrequencyEntry, 0, len(freq))
|
|
for key, count := range freq {
|
|
// Split "type:id" — type is always "member" or "agent" (no colons).
|
|
var aType, aID string
|
|
for i := 0; i < len(key); i++ {
|
|
if key[i] == ':' {
|
|
aType = key[:i]
|
|
aID = key[i+1:]
|
|
break
|
|
}
|
|
}
|
|
result = append(result, AssigneeFrequencyEntry{
|
|
AssigneeType: aType,
|
|
AssigneeID: aID,
|
|
Frequency: count,
|
|
})
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].Frequency > result[j].Frequency
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|