mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
* refactor(timeline): drop server-side comment + timeline pagination (MUL-1929) The cursor-paginated /timeline and /comments endpoints were sized for a problem the data shape doesn't have: prod p99 is ~30 comments per issue and the all-time max is ~1.1k. Time-based pagination also splits reply threads across page boundaries (orphan replies), which the frontend was papering over with an "orphan rescue" that promoted disconnected replies to top-level — confusing UX with no real benefit. Replace both endpoints with a single full-issue fetch, capped server-side at 2000 rows as a defensive safety net (never hit in practice). Server - /api/issues/:id/timeline now returns a flat ASC TimelineEntry[] (matches the legacy desktop contract — older Multica.app builds keep working because the wrapped TimelineResponse + cursors are gone, and the raw array shape was always what they consumed). - /api/issues/:id/comments drops limit/offset; only ?since is honoured for the CLI agent-polling flow. - Drop ListCommentsBefore/After/Latest, ListActivitiesBefore/After/Latest and the timelineCursor encoding. - Replace with ListCommentsForIssue / ListCommentsSinceForIssue / ListActivitiesForIssue (capped by argument). CLI - multica issue comment list drops --limit / --offset and the X-Total-Count reporting; --since is preserved for incremental polling. Frontend - Replace useInfiniteQuery with useQuery in useIssueTimeline; drop fetchOlder/Newer, jumpToLatest, isAtLatest, newEntriesBelowCount. - Remove timeline-cache helpers (mapAllEntries / filterAllEntries / prependToLatestPage) and the TimelinePage / TimelinePageParam types. - WS event handlers update the single flat-array cache directly. - Drop the orphan-reply rescue in issue-detail — every reply's parent is now guaranteed to be in the same array. - Strip the "show older / show newer / jump to latest" buttons and their i18n strings. Co-authored-by: multica-agent <github@multica.ai> * fix(timeline): address review feedback on pagination removal Three issues caught in PR #2322 review: 1. /timeline broke for stale clients between #2128 and this PR. They send ?limit/?before/?after/?around and parse with the wrapped TimelinePageSchema; the new flat-array response was failing schema validation and falling back to an empty timeline. Restore the wrapped shape on those query params (DESC entries, null cursors, has_more_*=false), keeping the flat ASC array for bare requests. Around-mode now also fills target_index from the merged slice so legacy clients can still scroll-to-anchor without a follow-up. 2. The agent prompts in runtime_config.go and prompt.go still told agents that `multica issue comment list` accepts --limit/--offset and to use `--limit 30` on truncated output. With those flags removed in this PR, new agent runs would hit "unknown flag" or skip context. Update the prompt copy to "returns all comments, capped at 2000; --since for incremental polling". 3. useCreateComment's onSuccess was a bare append to the timeline cache with no id-dedupe, so a fast comment:created WS event firing before onSuccess produced a transient duplicate. Restore the id guard the old prependToLatestPage helper used to provide. Adds two new boundary tests: - TestListTimeline_LegacyWrappedShape_OnPaginationParams - TestListTimeline_LegacyWrappedShape_AroundFillsTargetIndex Co-authored-by: multica-agent <github@multica.ai> * test(handler): fix timeline test assertions for handler-package isolation The TestListTimeline_* assertions assumed CreateIssue would seed an "issue_created" activity_log row, but the activity listener that publishes those rows is registered in cmd/server/main.go — handler-package tests don't wire it up. CI saw 5 entries (3 comments + 2 activities) where the test expected ≥6. Drop the auto-activity assumption: assert exactly 5 entries in TestListTimeline_MergesCommentsAndActivities, and tighten TestListTimeline_EmptyIssue to assert a fully-empty timeline. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
138 lines
5.0 KiB
TypeScript
138 lines
5.0 KiB
TypeScript
import { z } from "zod";
|
|
import type { ListIssuesResponse, TimelineEntry } from "../types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schemas for the highest-risk API endpoints — those whose responses drive
|
|
// the issue detail page (timeline, comments, subscribers) and the issues
|
|
// list. These are the surfaces that white-screened in #2143 / #2147 / #2192.
|
|
//
|
|
// These schemas are intentionally LENIENT:
|
|
// - String enums are stored as `z.string()` rather than `z.enum([...])`.
|
|
// A new server-side enum value should render as a generic fallback in
|
|
// the UI, never crash a `safeParse`.
|
|
// - Optional fields are unioned with `null` and given fallbacks where
|
|
// existing UI code already coerces them.
|
|
// - Arrays default to `[]` so a missing `reactions` / `attachments` /
|
|
// `entries` field doesn't take the page down.
|
|
// - Every object schema ends with `.loose()` so unknown server-side
|
|
// fields pass through unchanged. zod 4's `.object()` defaults to STRIP,
|
|
// which would silently delete fields the schema didn't explicitly list
|
|
// — fine while the TS type doesn't claim them, but the moment a future
|
|
// PR adds a TS field without updating the schema, the cast `as T` lies
|
|
// and the field shows up as `undefined` at runtime. `.loose()` removes
|
|
// that synchronisation hazard.
|
|
//
|
|
// These schemas are deliberately not typed as `z.ZodType<TimelineEntry>` /
|
|
// `z.ZodType<Issue>` etc. — the strict TS types narrow string fields to
|
|
// literal unions, which would defeat the leniency above. `parseWithFallback`
|
|
// returns the parsed value cast to the caller-supplied `T`, so the strict
|
|
// type still flows out at the call site; the schema only guards shape.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ReactionSchema = z.object({
|
|
id: z.string(),
|
|
comment_id: z.string(),
|
|
actor_type: z.string(),
|
|
actor_id: z.string(),
|
|
emoji: z.string(),
|
|
created_at: z.string(),
|
|
});
|
|
|
|
const AttachmentSchema = z.object({
|
|
id: z.string(),
|
|
}).loose();
|
|
|
|
// All object schemas use `.loose()` so unknown server-side fields pass
|
|
// through unchanged. zod 4's `.object()` defaults to STRIP, which would
|
|
// silently drop new fields and surface as a "field neither showed up in
|
|
// the UI" mystery the next time the TS type adopted them but the schema
|
|
// wasn't updated in lock-step. `.loose()` removes that synchronisation
|
|
// hazard — the schema validates the shape it knows about and leaves the
|
|
// rest alone.
|
|
const TimelineEntrySchema = z.object({
|
|
type: z.string(),
|
|
id: z.string(),
|
|
actor_type: z.string(),
|
|
actor_id: z.string(),
|
|
created_at: z.string(),
|
|
action: z.string().optional(),
|
|
details: z.record(z.string(), z.unknown()).optional(),
|
|
content: z.string().optional(),
|
|
parent_id: z.string().nullable().optional(),
|
|
updated_at: z.string().optional(),
|
|
comment_type: z.string().optional(),
|
|
reactions: z.array(ReactionSchema).optional(),
|
|
attachments: z.array(AttachmentSchema).optional(),
|
|
coalesced_count: z.number().optional(),
|
|
}).loose();
|
|
|
|
// /timeline returns a flat array of TimelineEntry, oldest first. The
|
|
// previously cursor-paginated wrapper was removed (#1929) — at observed data
|
|
// sizes (p99 ~30 entries per issue) paged delivery only created bugs.
|
|
export const TimelineEntriesSchema = z.array(TimelineEntrySchema);
|
|
|
|
export const EMPTY_TIMELINE_ENTRIES: TimelineEntry[] = [];
|
|
|
|
export const CommentSchema = z.object({
|
|
id: z.string(),
|
|
issue_id: z.string(),
|
|
author_type: z.string(),
|
|
author_id: z.string(),
|
|
content: z.string(),
|
|
type: z.string(),
|
|
parent_id: z.string().nullable(),
|
|
reactions: z.array(ReactionSchema).default([]),
|
|
attachments: z.array(AttachmentSchema).default([]),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const CommentsListSchema = z.array(CommentSchema);
|
|
|
|
const IssueSchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
number: z.number(),
|
|
identifier: z.string(),
|
|
title: z.string(),
|
|
description: z.string().nullable(),
|
|
status: z.string(),
|
|
priority: z.string(),
|
|
assignee_type: z.string().nullable(),
|
|
assignee_id: z.string().nullable(),
|
|
creator_type: z.string(),
|
|
creator_id: z.string(),
|
|
parent_issue_id: z.string().nullable(),
|
|
project_id: z.string().nullable(),
|
|
position: z.number(),
|
|
due_date: z.string().nullable(),
|
|
reactions: z.array(z.unknown()).optional(),
|
|
labels: z.array(z.unknown()).optional(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const ListIssuesResponseSchema = z.object({
|
|
issues: z.array(IssueSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
|
|
issues: [],
|
|
total: 0,
|
|
};
|
|
|
|
const SubscriberSchema = z.object({
|
|
issue_id: z.string(),
|
|
user_type: z.string(),
|
|
user_id: z.string(),
|
|
reason: z.string(),
|
|
created_at: z.string(),
|
|
}).loose();
|
|
|
|
export const SubscribersListSchema = z.array(SubscriberSchema);
|
|
|
|
export const ChildIssuesResponseSchema = z.object({
|
|
issues: z.array(IssueSchema).default([]),
|
|
}).loose();
|