Files
multica/server/internal/handler/issue_thread_summary.go
Claude 5ca20d1123 feat(projects): return the newest reply excerpt in thread summaries
Before: GET /api/issues/thread-summaries told a client how many replies an issue had and when the last one landed, but not what it said, so a compact list had to open the thread to show it.

After: each summary row carries last_reply_excerpt, the newest counted (non-deleted, non-system) comment flattened to one line of at most 80 runes with mentions collapsed to @Label; null when the issue has no counted comment.

How: ListIssueThreadSummaries joins a lateral lookup of the newest counted comment (body capped at 2000 characters in SQL) onto the existing rollup, so the batch is still one query; the handler flattens it with commentExcerpt. A DB-backed handler test covers newest-wins, deleted and system exclusion, mention flattening and truncation (not run here, no PostgreSQL).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017deksZDSzZBA4QSqZZoBip
2026-10-21 07:44:08 +00:00

463 lines
16 KiB
Go

package handler
import (
"context"
"net/http"
"regexp"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Cap on the number of issues summarised in one request. The project feed
// renders one page of issues at a time, so this is a guard against ANY()
// across a whole workspace rather than a page-size contract.
const issueThreadSummariesLimit = 100
// Participants shown per issue; the rest are implied by reply_count.
const issueThreadSummaryParticipantLimit = 3
// Agent questions listed per issue (newest first) and the excerpt length, in
// runes, of each.
const (
issueAwaitingMeLimit = 3
issueAwaitingMeExcerptRunes = 140
)
// Runes kept of the newest comment for last_reply_excerpt.
const issueLastReplyExcerptRunes = 80
// Linked PRs listed per issue on the output strip, newest first.
const issueThreadSummaryPullRequestLimit = 3
// Values of ThreadPullRequestResponse.ChecksState.
const (
threadChecksPassed = "passed"
threadChecksFailed = "failed"
threadChecksPending = "pending"
threadChecksNone = "none"
)
type ThreadParticipantResponse struct {
Type string `json:"type"`
ID string `json:"id"`
}
type IssueThreadSummaryResponse struct {
IssueID string `json:"issue_id"`
ReplyCount int64 `json:"reply_count"`
LastReplyAt *string `json:"last_reply_at"`
// LastReplyExcerpt is the newest counted comment flattened to one line
// (mentions collapsed to "@Label"); null when the issue has none.
LastReplyExcerpt *string `json:"last_reply_excerpt"`
OpenThreadCount int64 `json:"open_thread_count"`
Participants []ThreadParticipantResponse `json:"participants"`
// LastReadAt is the caller's own read cursor on the issue; null when the
// caller never opened it.
LastReadAt *string `json:"last_read_at"`
// AwaitingMe lists unresolved threads whose latest comment is an agent
// mentioning the caller, newest first, at most issueAwaitingMeLimit. Never
// null.
AwaitingMe []AwaitingMeResponse `json:"awaiting_me"`
// LatestProgress is the newest agent progress report (a progress_update
// comment holding a Markdown task list); null when the issue has none.
LatestProgress *LatestProgressResponse `json:"latest_progress"`
// PullRequests are the newest linked PRs (GitHub and self-hosted VCS),
// newest first, at most issueThreadSummaryPullRequestLimit. Never null.
PullRequests []ThreadPullRequestResponse `json:"pull_requests"`
// DeliverableCount is the number of distinct files uploaded in live
// comments; uploads of the same name and type are versions of one file.
DeliverableCount int64 `json:"deliverable_count"`
// LastDeliverableAt is the newest upload among those files, or null.
LastDeliverableAt *string `json:"last_deliverable_at"`
}
type LatestProgressResponse struct {
CommentID string `json:"comment_id"`
// Content is the full comment body; the client decides how many lines to
// show.
Content string `json:"content"`
CreatedAt string `json:"created_at"`
}
// ThreadPullRequestResponse is the slice of a linked PR the feed's output
// strip renders. ChecksState is one of passed, failed, pending or none; none
// covers both "no checks" and "no current CI snapshot", which the strip shows
// the same way.
type ThreadPullRequestResponse struct {
ID string `json:"id"`
Number int32 `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
State string `json:"state"`
Additions int32 `json:"additions"`
Deletions int32 `json:"deletions"`
ChecksState string `json:"checks_state"`
// createdAt orders the merged GitHub and VCS lists; never serialized.
createdAt time.Time
}
type AwaitingMeResponse struct {
CommentID string `json:"comment_id"`
ActorID string `json:"actor_id"`
Excerpt string `json:"excerpt"`
CreatedAt string `json:"created_at"`
}
var whitespaceRunRe = regexp.MustCompile(`\s+`)
// commentExcerpt flattens comment Markdown into a one-line preview: mention
// links collapse to their label ("@Name"; issue links keep the bare identifier),
// whitespace runs to a single space, and the result is cut to maxRunes runes
// with an ellipsis.
func commentExcerpt(content string, maxRunes int) string {
flat := util.MentionRe.ReplaceAllStringFunc(content, func(m string) string {
sub := util.MentionRe.FindStringSubmatch(m)
if sub[2] == "issue" {
return sub[1]
}
return "@" + sub[1]
})
flat = strings.TrimSpace(whitespaceRunRe.ReplaceAllString(flat, " "))
runes := []rune(flat)
if len(runes) <= maxRunes {
return flat
}
return strings.TrimSpace(string(runes[:maxRunes])) + "…"
}
// ListIssueThreadSummaries returns comment rollups for a batch of issues so a
// feed can render reply counts and avatars without one comment list per card.
//
// An issue gets a row when it has at least one counted comment (not deleted,
// not platform-authored), a linked PR, or a deliverable; callers treat a
// missing issue as zero.
// Workspace scope is enforced in the query, so an id from another workspace
// yields no row rather than an error.
//
// awaiting_me carries the caller's open questions from agents: unresolved
// threads whose latest comment is agent-authored and mentions the caller.
//
// latest_progress carries the newest agent progress report per issue, read
// from progress_update comments whose body is a Markdown task list.
//
// pull_requests and deliverable_count/last_deliverable_at feed the output
// strip: what the issue has produced, without opening it.
//
// Each row also carries the caller's read cursor (last_read_at, null when never
// opened) so the feed can tell unread cards from read ones; other users'
// cursors are never exposed.
func (h *Handler) ListIssueThreadSummaries(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user_id")
if !ok {
return
}
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
}
empty := map[string]any{"summaries": []IssueThreadSummaryResponse{}}
raw := r.URL.Query().Get("ids")
if raw == "" {
writeJSON(w, http.StatusOK, empty)
return
}
parts := strings.Split(raw, ",")
if len(parts) > issueThreadSummariesLimit {
writeError(w, http.StatusBadRequest, "too many ids")
return
}
issueIDs := make([]pgtype.UUID, 0, len(parts))
for _, s := range parts {
s = strings.TrimSpace(s)
if s == "" {
continue
}
id, ok := parseUUIDOrBadRequest(w, s, "ids")
if !ok {
return
}
issueIDs = append(issueIDs, id)
}
if len(issueIDs) == 0 {
writeJSON(w, http.StatusOK, empty)
return
}
rows, err := h.Queries.ListIssueThreadSummaries(r.Context(), db.ListIssueThreadSummariesParams{
WorkspaceID: wsUUID,
IssueIds: issueIDs,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list thread summaries")
return
}
participantRows, err := h.Queries.ListIssueThreadParticipants(r.Context(), db.ListIssueThreadParticipantsParams{
WorkspaceID: wsUUID,
IssueIds: issueIDs,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list thread participants")
return
}
cursorRows, err := h.Queries.ListIssueReadCursors(r.Context(), db.ListIssueReadCursorsParams{
UserID: userUUID,
WorkspaceID: wsUUID,
IssueIds: issueIDs,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list read cursors")
return
}
lastRead := make(map[string]*string, len(cursorRows))
for _, c := range cursorRows {
lastRead[uuidToString(c.IssueID)] = timestampToNanoPtr(c.LastReadAt)
}
questionRows, err := h.Queries.ListAgentQuestionsForIssues(r.Context(), db.ListAgentQuestionsForIssuesParams{
WorkspaceID: wsUUID,
IssueIds: issueIDs,
CallerID: userUUID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agent questions")
return
}
// Rows arrive newest first per issue; keep the first few.
awaiting := make(map[string][]AwaitingMeResponse)
for _, q := range questionRows {
issueID := uuidToString(q.IssueID)
if len(awaiting[issueID]) >= issueAwaitingMeLimit {
continue
}
awaiting[issueID] = append(awaiting[issueID], AwaitingMeResponse{
CommentID: uuidToString(q.CommentID),
ActorID: uuidToString(q.AuthorID),
Excerpt: commentExcerpt(q.Content, issueAwaitingMeExcerptRunes),
CreatedAt: q.CreatedAt.Time.Format(time.RFC3339Nano),
})
}
pullRequests, err := h.listThreadPullRequests(r.Context(), wsUUID, issueIDs)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list pull requests")
return
}
deliverableRows, err := h.Queries.ListIssueDeliverableSummaries(r.Context(), db.ListIssueDeliverableSummariesParams{
WorkspaceID: wsUUID,
IssueIds: issueIDs,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list deliverables")
return
}
deliverables := make(map[string]db.ListIssueDeliverableSummariesRow, len(deliverableRows))
for _, d := range deliverableRows {
deliverables[uuidToString(d.IssueID)] = d
}
progressRows, err := h.Queries.ListLatestProgressCommentsForIssues(r.Context(), db.ListLatestProgressCommentsForIssuesParams{
WorkspaceID: wsUUID,
IssueIds: issueIDs,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list progress reports")
return
}
progress := make(map[string]*LatestProgressResponse, len(progressRows))
for _, p := range progressRows {
progress[uuidToString(p.IssueID)] = &LatestProgressResponse{
CommentID: uuidToString(p.CommentID),
Content: p.Content,
CreatedAt: p.CreatedAt.Time.Format(time.RFC3339Nano),
}
}
// Rows arrive ordered by issue then most recent activity, so the first
// few per issue are the participants to show.
participants := make(map[string][]ThreadParticipantResponse, len(rows))
for _, p := range participantRows {
issueID := uuidToString(p.IssueID)
if len(participants[issueID]) >= issueThreadSummaryParticipantLimit {
continue
}
participants[issueID] = append(participants[issueID], ThreadParticipantResponse{
Type: p.AuthorType,
ID: uuidToString(p.AuthorID),
})
}
build := func(issueID string, row *db.ListIssueThreadSummariesRow) IssueThreadSummaryResponse {
list := participants[issueID]
if list == nil {
list = []ThreadParticipantResponse{}
}
awaitingList := awaiting[issueID]
if awaitingList == nil {
awaitingList = []AwaitingMeResponse{}
}
prs := pullRequests[issueID]
if prs == nil {
prs = []ThreadPullRequestResponse{}
}
summary := IssueThreadSummaryResponse{
IssueID: issueID,
Participants: list,
LastReadAt: lastRead[issueID],
AwaitingMe: awaitingList,
PullRequests: prs,
LatestProgress: progress[issueID],
}
if row != nil {
summary.ReplyCount = row.ReplyCount
summary.LastReplyAt = timestampToNanoPtr(row.LastReplyAt)
if excerpt := commentExcerpt(row.LastReplyContent, issueLastReplyExcerptRunes); excerpt != "" {
summary.LastReplyExcerpt = &excerpt
}
summary.OpenThreadCount = row.OpenThreadCount
}
if d, ok := deliverables[issueID]; ok {
summary.DeliverableCount = d.DeliverableCount
summary.LastDeliverableAt = timestampToNanoPtr(d.LastDeliverableAt)
}
return summary
}
summaries := make([]IssueThreadSummaryResponse, 0, len(rows))
seen := make(map[string]bool, len(rows))
for i := range rows {
issueID := uuidToString(rows[i].IssueID)
seen[issueID] = true
summaries = append(summaries, build(issueID, &rows[i]))
}
// Issues with output but no counted comment (a PR linked before anyone
// replied) still need a row; they follow the commented ones in request
// order.
for _, id := range issueIDs {
issueID := uuidToString(id)
if seen[issueID] {
continue
}
if _, hasFiles := deliverables[issueID]; !hasFiles && len(pullRequests[issueID]) == 0 {
continue
}
seen[issueID] = true
summaries = append(summaries, build(issueID, nil))
}
writeJSON(w, http.StatusOK, map[string]any{"summaries": summaries})
}
// listThreadPullRequests returns each issue's newest linked PRs across GitHub
// and the self-hosted providers, newest first, capped per issue. Each source
// is queried once for the whole batch and already capped per issue, so the
// merge sees at most the cap per issue per source.
func (h *Handler) listThreadPullRequests(ctx context.Context, wsUUID pgtype.UUID, issueIDs []pgtype.UUID) (map[string][]ThreadPullRequestResponse, error) {
ghRows, err := h.Queries.ListIssueThreadPullRequests(ctx, db.ListIssueThreadPullRequestsParams{
WorkspaceID: wsUUID,
IssueIds: issueIDs,
PrLimit: issueThreadSummaryPullRequestLimit,
})
if err != nil {
return nil, err
}
vcsRows, err := h.Queries.ListIssueThreadVCSPullRequests(ctx, db.ListIssueThreadVCSPullRequestsParams{
WorkspaceID: wsUUID,
IssueIds: issueIDs,
PrLimit: issueThreadSummaryPullRequestLimit,
})
if err != nil {
return nil, err
}
snapshotsEnabled := h.PRRefresh.Enabled()
out := make(map[string][]ThreadPullRequestResponse)
for _, p := range ghRows {
available := currentGitHubSnapshotAvailable(snapshotsEnabled, p.HeadSha, p.SnapshotHeadSha, p.SnapshotFetchedAt)
issueID := uuidToString(p.IssueID)
out[issueID] = append(out[issueID], ThreadPullRequestResponse{
ID: uuidToString(p.ID),
Number: p.PrNumber,
Title: p.Title,
URL: p.HtmlUrl,
State: p.State,
Additions: p.Additions,
Deletions: p.Deletions,
ChecksState: githubChecksState(available, p.ChecksRollupState, p.ChecksFailed, p.ChecksRunning, p.ChecksPassed),
createdAt: p.PrCreatedAt.Time,
})
}
for _, p := range vcsRows {
issueID := uuidToString(p.IssueID)
out[issueID] = append(out[issueID], ThreadPullRequestResponse{
ID: uuidToString(p.ID),
Number: p.PrNumber,
Title: p.Title,
URL: p.HtmlUrl,
State: p.State,
Additions: p.Additions,
Deletions: p.Deletions,
ChecksState: checksStateFromConclusion(aggregateChecksConclusion(p.ChecksFailed, p.ChecksPassed, p.ChecksPending, p.ChecksTotal)),
createdAt: p.PrCreatedAt.Time,
})
}
for issueID, list := range out {
sort.SliceStable(list, func(i, j int) bool {
if !list[i].createdAt.Equal(list[j].createdAt) {
return list[i].createdAt.After(list[j].createdAt)
}
return list[i].ID > list[j].ID
})
if len(list) > issueThreadSummaryPullRequestLimit {
list = list[:issueThreadSummaryPullRequestLimit]
}
out[issueID] = list
}
return out, nil
}
// githubChecksState folds a GitHub PR's CI snapshot into the strip's enum the
// way the PR list does (deriveChecksStatus in
// packages/core/github/pull-request-status.ts): nothing is claimed without a
// current snapshot, a failed run or a failing rollup wins, then pending, then
// passed.
func githubChecksState(snapshotAvailable bool, rollup pgtype.Text, failed, running, passed int64) string {
if !snapshotAvailable {
return threadChecksNone
}
if failed > 0 {
return threadChecksFailed
}
return checksStateFromConclusion(rollupToConclusion(rollup, failed, running, passed))
}
// checksStateFromConclusion maps the coarse failed/pending/passed conclusion
// shared by the PR list responses onto the strip's enum; nil is "none".
func checksStateFromConclusion(conclusion *string) string {
if conclusion == nil {
return threadChecksNone
}
switch *conclusion {
case "failed":
return threadChecksFailed
case "pending":
return threadChecksPending
case "passed":
return threadChecksPassed
}
return threadChecksNone
}