mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
## 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>
129 lines
4 KiB
TypeScript
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);
|
|
}
|