paperclip/ui/src/lib/issue-timeline-events.ts
Dotta 68f69975a4
Harden control-plane safety and issue identifiers (#5292)
## Thinking Path

> - Paperclip relies on issue identifiers, execution policies, and agent
heartbeat rules to keep autonomous work auditable.
> - Safety checks need to reject ambiguous agent handoffs, and
identifier parsing needs to support Cloud tenant prefixes.
> - Agent instructions also need to make final-disposition rules
explicit so work does not stall in vague states.
> - This pull request isolates backend correctness and governance
hardening from the UI and recovery-system-notice branches.
> - The benefit is safer in-review transitions, better identifier
compatibility, and clearer agent operating contracts.

## What Changed

- Fixed run-aware confirmation ordering and interrupted-run state
cleanup.
- Added Cloud tenant identity bootstrap and alphanumeric issue
identifier support across shared parsing and server routes.
- Guarded agent-authored `in_review` updates unless a real review path
exists.
- Tightened heartbeat disposition instructions in adapter
utilities/default AGENTS/Paperclip skill.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run packages/shared/src/issue-references.test.ts
server/src/__tests__/issue-identifier-routes.test.ts
server/src/__tests__/issue-execution-policy-routes.test.ts
packages/adapter-utils/src/server-utils.test.ts` initially had the first
execution-policy test hit Vitest's 5s timeout under the parallel bundle
while the rest passed.
- `pnpm exec vitest run
server/src/__tests__/issue-execution-policy-routes.test.ts
--testTimeout=20000` passed with 10/10 tests.

- Follow-up: `pnpm run typecheck:build-gaps` passed.
- Follow-up: `pnpm --filter @paperclipai/ui typecheck` passed.
- Follow-up: `pnpm vitest run
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/company-portability.test.ts
server/src/__tests__/costs-service.test.ts` passed.
- Follow-up: `pnpm vitest run ui/src/context/LiveUpdatesProvider.test.ts
ui/src/lib/issue-chat-messages.test.ts
ui/src/lib/issue-reference.test.ts
ui/src/lib/issue-timeline-events.test.ts` passed.

## Risks

- Medium control-plane risk: in-review update validation changes agent
behavior. The error message is explicit and tests cover allowed review
paths.

## Model Used

- OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with
shell/git/GitHub CLI tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-06 07:49:47 -05:00

129 lines
4 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;
runId?: string | null;
statusChange?: {
from: string | null;
to: string | null;
};
assigneeChange?: {
from: IssueTimelineAssignee;
to: IssueTimelineAssignee;
};
commentId?: string | null;
followUpRequested?: boolean;
}
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 ?? []) {
const details = asRecord(event.details);
if (!details) continue;
if (event.action === "issue.comment_added") {
if (details.followUpRequested !== true && details.resumeIntent !== true) continue;
if (details.reopened === true) continue;
const commentId = nullableString(details.commentId);
events.push({
id: event.id,
createdAt: event.createdAt,
actorType: event.actorType,
actorId: event.actorId,
runId: event.runId ?? null,
commentId,
followUpRequested: true,
});
continue;
}
if (event.action !== "issue.updated") continue;
const previous = asRecord(details._previous);
const timelineEvent: IssueTimelineEvent = {
id: event.id,
createdAt: event.createdAt,
actorType: event.actorType,
actorId: event.actorId,
runId: event.runId ?? null,
};
if (details.followUpRequested === true || details.resumeIntent === true) {
timelineEvent.followUpRequested = true;
timelineEvent.commentId = nullableString(details.commentId);
}
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 || timelineEvent.followUpRequested) {
events.push(timelineEvent);
}
}
return sortTimelineEvents(events);
}