mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* feat(issues): add revision-aware concurrency guards * fix(concurrency): decouple text edits from aggregate revisions
398 lines
15 KiB
Go
398 lines
15 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
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"`
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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).
|
|
const 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.
|
|
const timelineProbeLimit = 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)
|
|
}
|
|
|
|
if wantWrapped {
|
|
entries := h.mergeTimeline(r, comments, activities, false)
|
|
if entries == nil {
|
|
entries = []TimelineEntry{}
|
|
}
|
|
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
|
|
}
|
|
|
|
entries := h.mergeTimeline(r, comments, activities, true)
|
|
if entries == nil {
|
|
entries = []TimelineEntry{}
|
|
}
|
|
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)
|
|
|
|
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),
|
|
}
|
|
}
|
|
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),
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|