nexus/ui/src/lib/issue-timeline-events.ts
dotta bd6d07d0b4 fix(ui): polish issue detail timelines and attachments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 11:51:40 -05:00

105 lines
3.2 KiB
TypeScript

import type { ActivityEvent } from "@paperclipai/shared";
export interface IssueTimelineAssignee {
agentId: string | null;
userId: string | null;
}
export interface IssueTimelineEvent {
id: string;
createdAt: Date | string;
actorType: ActivityEvent["actorType"];
actorId: string;
statusChange?: {
from: string | null;
to: string | null;
};
assigneeChange?: {
from: IssueTimelineAssignee;
to: IssueTimelineAssignee;
};
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function hasOwn(record: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(record, key);
}
function nullableString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function toTimestamp(value: Date | string) {
return new Date(value).getTime();
}
function sameAssignee(left: IssueTimelineAssignee, right: IssueTimelineAssignee) {
return left.agentId === right.agentId && left.userId === right.userId;
}
function sortTimelineEvents<T extends { createdAt: Date | string; id: string }>(events: T[]) {
return [...events].sort((a, b) => {
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
if (createdAtDiff !== 0) return createdAtDiff;
return a.id.localeCompare(b.id);
});
}
export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | undefined): IssueTimelineEvent[] {
const events: IssueTimelineEvent[] = [];
for (const event of activity ?? []) {
if (event.action !== "issue.updated") continue;
const details = asRecord(event.details);
if (!details) continue;
const previous = asRecord(details._previous);
const timelineEvent: IssueTimelineEvent = {
id: event.id,
createdAt: event.createdAt,
actorType: event.actorType,
actorId: event.actorId,
};
if (hasOwn(details, "status")) {
const from = nullableString(previous?.status) ?? nullableString(details.reopenedFrom);
const to = nullableString(details.status);
if (from !== to) {
timelineEvent.statusChange = { from, to };
}
}
if (hasOwn(details, "assigneeAgentId") || hasOwn(details, "assigneeUserId")) {
const previousAssignee: IssueTimelineAssignee = {
agentId: nullableString(previous?.assigneeAgentId),
userId: nullableString(previous?.assigneeUserId),
};
const nextAssignee: IssueTimelineAssignee = {
agentId: hasOwn(details, "assigneeAgentId")
? nullableString(details.assigneeAgentId)
: previousAssignee.agentId,
userId: hasOwn(details, "assigneeUserId")
? nullableString(details.assigneeUserId)
: previousAssignee.userId,
};
if (!sameAssignee(previousAssignee, nextAssignee)) {
timelineEvent.assigneeChange = {
from: previousAssignee,
to: nextAssignee,
};
}
}
if (timelineEvent.statusChange || timelineEvent.assigneeChange) {
events.push(timelineEvent);
}
}
return sortTimelineEvents(events);
}