diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts new file mode 100644 index 00000000..d4e99a8e --- /dev/null +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -0,0 +1,244 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; +import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + assertCheckoutOwner: vi.fn(), + update: vi.fn(), + addComment: vi.fn(), + findMentionedAgents: vi.fn(), + getRelationSummaries: vi.fn(), + listWakeableBlockedDependents: vi.fn(), + getWakeableParentAfterChildCompletion: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(async () => false), + hasPermission: vi.fn(async () => false), + }), + agentService: () => ({ + getById: vi.fn(async () => null), + }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), + }), + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue() { + return { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + status: "todo", + assigneeAgentId: "22222222-2222-4222-8222-222222222222", + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-580", + title: "Activity event issue", + executionPolicy: null, + executionState: null, + }; +} + +describe("issue activity event routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); + mockIssueService.findMentionedAgents.mockResolvedValue([]); + mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); + mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); + mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + }); + + it("logs blocker activity with added and removed issue summaries", async () => { + const issue = makeIssue(); + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.getRelationSummaries + .mockResolvedValueOnce({ + blockedBy: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + identifier: "PAP-10", + title: "Old blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + blocks: [], + }) + .mockResolvedValueOnce({ + blockedBy: [ + { + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + identifier: "PAP-11", + title: "New blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + blocks: [], + }); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + updatedAt: new Date(), + })); + + const res = await request(createApp()) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] }); + + expect(res.status).toBe(200); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.blockers_updated", + details: expect.objectContaining({ + addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"], + removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], + addedBlockedByIssues: [ + { + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + identifier: "PAP-11", + title: "New blocker", + }, + ], + removedBlockedByIssues: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + identifier: "PAP-10", + title: "Old blocker", + }, + ], + }), + }), + ); + }); + + it("logs explicit reviewer and approver activity when execution policy participants change", async () => { + const existingPolicy = normalizeIssueExecutionPolicy({ + stages: [ + { + id: "11111111-1111-4111-8111-111111111111", + type: "review", + participants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555" }], + }, + { + id: "22222222-2222-4222-8222-222222222222", + type: "approval", + participants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa" }], + }, + ], + })!; + const nextPolicy = normalizeIssueExecutionPolicy({ + stages: [ + { + id: "11111111-1111-4111-8111-111111111111", + type: "review", + participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" }], + }, + { + id: "22222222-2222-4222-8222-222222222222", + type: "approval", + participants: [{ type: "user", userId: "local-board" }], + }, + ], + })!; + const issue = { + ...makeIssue(), + executionPolicy: existingPolicy, + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + executionPolicy: patch.executionPolicy, + updatedAt: new Date(), + })); + + const res = await request(createApp()) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ executionPolicy: nextPolicy }); + + expect(res.status).toBe(200); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.reviewers_updated", + details: expect.objectContaining({ + participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }], + addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }], + removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }], + }), + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.approvers_updated", + details: expect.objectContaining({ + participants: [{ type: "user", agentId: null, userId: "local-board" }], + addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }], + removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }], + }), + }), + ); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f72b19cd..561c1331 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -68,6 +68,16 @@ const updateIssueRouteSchema = updateIssueSchema.extend({ }); type ParsedExecutionState = NonNullable>; +type NormalizedExecutionPolicy = NonNullable>; +type ActivityIssueRelationSummary = { + id: string; + identifier: string | null; + title: string; +}; +type ActivityExecutionParticipant = Pick< + NormalizedExecutionPolicy["stages"][number]["participants"][number], + "type" | "agentId" | "userId" +>; type ExecutionStageWakeContext = { wakeRole: "reviewer" | "approver" | "executor"; stageId: string | null; @@ -102,6 +112,59 @@ function buildExecutionStageWakeContext(input: { }; } +function summarizeIssueRelationForActivity(relation: { + id: string; + identifier: string | null; + title: string; +}): ActivityIssueRelationSummary { + return { + id: relation.id, + identifier: relation.identifier, + title: relation.title, + }; +} + +function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string { + return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`; +} + +function summarizeExecutionParticipants( + policy: NormalizedExecutionPolicy | null, + stageType: NormalizedExecutionPolicy["stages"][number]["type"], +): ActivityExecutionParticipant[] { + const stage = policy?.stages.find((candidate) => candidate.type === stageType); + return ( + stage?.participants.map((participant) => ({ + type: participant.type, + agentId: participant.agentId ?? null, + userId: participant.userId ?? null, + })) ?? [] + ); +} + +function diffExecutionParticipants( + previousPolicy: NormalizedExecutionPolicy | null, + nextPolicy: NormalizedExecutionPolicy | null, + stageType: NormalizedExecutionPolicy["stages"][number]["type"], +) { + const previousParticipants = summarizeExecutionParticipants(previousPolicy, stageType); + const nextParticipants = summarizeExecutionParticipants(nextPolicy, stageType); + const previousByKey = new Map(previousParticipants.map((participant) => [ + activityExecutionParticipantKey(participant), + participant, + ])); + const nextByKey = new Map(nextParticipants.map((participant) => [ + activityExecutionParticipantKey(participant), + participant, + ])); + + return { + participants: nextParticipants, + addedParticipants: nextParticipants.filter((participant) => !previousByKey.has(activityExecutionParticipantKey(participant))), + removedParticipants: previousParticipants.filter((participant) => !nextByKey.has(activityExecutionParticipantKey(participant))), + }; +} + function buildExecutionStageWakeup(input: { issueId: string; previousState: ParsedExecutionState | null; @@ -1202,9 +1265,10 @@ export function issueRoutes( } const actor = getActorInfo(req); + const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); const issue = await svc.create(companyId, { ...req.body, - executionPolicy: normalizeIssueExecutionPolicy(req.body.executionPolicy), + executionPolicy, createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, }); @@ -1309,13 +1373,15 @@ export function issueRoutes( if (req.body.executionPolicy !== undefined) { updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); } + const previousExecutionPolicy = normalizeIssueExecutionPolicy(existing.executionPolicy ?? null); + const nextExecutionPolicy = + updateFields.executionPolicy !== undefined + ? (updateFields.executionPolicy as NormalizedExecutionPolicy | null) + : previousExecutionPolicy; const transition = applyIssueExecutionPolicyTransition({ issue: existing, - policy: - updateFields.executionPolicy !== undefined - ? (updateFields.executionPolicy as NonNullable | null) - : normalizeIssueExecutionPolicy(existing.executionPolicy ?? null), + policy: nextExecutionPolicy, requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined, requestedAssigneePatch: { assigneeAgentId: @@ -1430,8 +1496,9 @@ export function issueRoutes( return; } let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown } = issue; + let updatedRelations: Awaited> | null = null; if (issue && Array.isArray(req.body.blockedByIssueIds)) { - const updatedRelations = await svc.getRelationSummaries(issue.id); + updatedRelations = await svc.getRelationSummaries(issue.id); issueResponse = { ...issue, blockedBy: updatedRelations.blockedBy, @@ -1488,6 +1555,8 @@ export function issueRoutes( const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]); const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate)); const removedBlockedByIssueIds = [...previousBlockedByIds].filter((candidate) => !nextBlockedByIds.has(candidate)); + const nextBlockedByRelations = updatedRelations?.blockedBy ?? []; + const previousBlockedByRelations = existingRelations?.blockedBy ?? []; if (addedBlockedByIssueIds.length > 0 || removedBlockedByIssueIds.length > 0) { await logActivity(db, { companyId: issue.companyId, @@ -1503,11 +1572,58 @@ export function issueRoutes( blockedByIssueIds: req.body.blockedByIssueIds, addedBlockedByIssueIds, removedBlockedByIssueIds, + blockedByIssues: nextBlockedByRelations.map(summarizeIssueRelationForActivity), + addedBlockedByIssues: nextBlockedByRelations + .filter((relation) => addedBlockedByIssueIds.includes(relation.id)) + .map(summarizeIssueRelationForActivity), + removedBlockedByIssues: previousBlockedByRelations + .filter((relation) => removedBlockedByIssueIds.includes(relation.id)) + .map(summarizeIssueRelationForActivity), }, }); } } + const reviewerChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "review"); + if (reviewerChanges.addedParticipants.length > 0 || reviewerChanges.removedParticipants.length > 0) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.reviewers_updated", + entityType: "issue", + entityId: issue.id, + details: { + identifier: issue.identifier, + participants: reviewerChanges.participants, + addedParticipants: reviewerChanges.addedParticipants, + removedParticipants: reviewerChanges.removedParticipants, + }, + }); + } + + const approverChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "approval"); + if (approverChanges.addedParticipants.length > 0 || approverChanges.removedParticipants.length > 0) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.approvers_updated", + entityType: "issue", + entityId: issue.id, + details: { + identifier: issue.identifier, + participants: approverChanges.participants, + addedParticipants: approverChanges.addedParticipants, + removedParticipants: approverChanges.removedParticipants, + }, + }); + } + if (issue.status === "done" && existing.status !== "done") { const tc = getTelemetryClient(); if (tc && actor.agentId) { diff --git a/ui/src/components/ActivityRow.tsx b/ui/src/components/ActivityRow.tsx index ebfe23c5..dbe88785 100644 --- a/ui/src/components/ActivityRow.tsx +++ b/ui/src/components/ActivityRow.tsx @@ -2,72 +2,9 @@ import { Link } from "@/lib/router"; import { Identity } from "./Identity"; import { timeAgo } from "../lib/timeAgo"; import { cn } from "../lib/utils"; +import { formatActivityVerb } from "../lib/activity-format"; import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared"; -const ACTION_VERBS: Record = { - "issue.created": "created", - "issue.updated": "updated", - "issue.checked_out": "checked out", - "issue.released": "released", - "issue.comment_added": "commented on", - "issue.attachment_added": "attached file to", - "issue.attachment_removed": "removed attachment from", - "issue.document_created": "created document for", - "issue.document_updated": "updated document on", - "issue.document_deleted": "deleted document from", - "issue.commented": "commented on", - "issue.deleted": "deleted", - "agent.created": "created", - "agent.updated": "updated", - "agent.paused": "paused", - "agent.resumed": "resumed", - "agent.terminated": "terminated", - "agent.key_created": "created API key for", - "agent.budget_updated": "updated budget for", - "agent.runtime_session_reset": "reset session for", - "heartbeat.invoked": "invoked heartbeat for", - "heartbeat.cancelled": "cancelled heartbeat for", - "approval.created": "requested approval", - "approval.approved": "approved", - "approval.rejected": "rejected", - "project.created": "created", - "project.updated": "updated", - "project.deleted": "deleted", - "goal.created": "created", - "goal.updated": "updated", - "goal.deleted": "deleted", - "cost.reported": "reported cost for", - "cost.recorded": "recorded cost for", - "company.created": "created company", - "company.updated": "updated company", - "company.archived": "archived", - "company.budget_updated": "updated budget for", -}; - -function humanizeValue(value: unknown): string { - if (typeof value !== "string") return String(value ?? "none"); - return value.replace(/_/g, " "); -} - -function formatVerb(action: string, details?: Record | null): string { - if (action === "issue.updated" && details) { - const previous = (details._previous ?? {}) as Record; - if (details.status !== undefined) { - const from = previous.status; - return from - ? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on` - : `changed status to ${humanizeValue(details.status)} on`; - } - if (details.priority !== undefined) { - const from = previous.priority; - return from - ? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on` - : `changed priority to ${humanizeValue(details.priority)} on`; - } - } - return ACTION_VERBS[action] ?? action.replace(/[._]/g, " "); -} - function entityLink(entityType: string, entityId: string, name?: string | null): string | null { switch (entityType) { case "issue": return `/issues/${name ?? entityId}`; @@ -88,7 +25,7 @@ interface ActivityRowProps { } export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) { - const verb = formatVerb(event.action, event.details); + const verb = formatActivityVerb(event.action, event.details, { agentMap }); const isHeartbeatEvent = event.entityType === "heartbeat_run"; const heartbeatAgentId = isHeartbeatEvent diff --git a/ui/src/lib/activity-format.test.ts b/ui/src/lib/activity-format.test.ts new file mode 100644 index 00000000..25f2d37a --- /dev/null +++ b/ui/src/lib/activity-format.test.ts @@ -0,0 +1,60 @@ +import type { Agent } from "@paperclipai/shared"; +import { describe, expect, it } from "vitest"; +import { formatActivityVerb, formatIssueActivityAction } from "./activity-format"; + +describe("activity formatting", () => { + const agentMap = new Map([ + ["agent-reviewer", { id: "agent-reviewer", name: "Reviewer Bot" } as Agent], + ["agent-approver", { id: "agent-approver", name: "Approver Bot" } as Agent], + ]); + + it("formats blocker activity using linked issue identifiers", () => { + const details = { + addedBlockedByIssues: [ + { id: "issue-2", identifier: "PAP-22", title: "Blocked task" }, + ], + removedBlockedByIssues: [], + }; + + expect(formatActivityVerb("issue.blockers_updated", details)).toBe("added blocker PAP-22 to"); + expect(formatIssueActivityAction("issue.blockers_updated", details)).toBe("added blocker PAP-22"); + }); + + it("formats reviewer activity using agent names", () => { + const details = { + addedParticipants: [ + { type: "agent", agentId: "agent-reviewer", userId: null }, + ], + removedParticipants: [], + }; + + expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot to"); + expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot"); + }); + + it("formats approver removals using user-aware labels", () => { + const details = { + addedParticipants: [], + removedParticipants: [ + { type: "user", agentId: null, userId: "local-board" }, + ], + }; + + expect(formatActivityVerb("issue.approvers_updated", details)).toBe("removed approver Board from"); + expect(formatIssueActivityAction("issue.approvers_updated", details)).toBe("removed approver Board"); + }); + + it("falls back to updated wording when reviewers are both added and removed", () => { + const details = { + addedParticipants: [ + { type: "agent", agentId: "agent-reviewer", userId: null }, + ], + removedParticipants: [ + { type: "agent", agentId: "agent-approver", userId: null }, + ], + }; + + expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers on"); + expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers"); + }); +}); diff --git a/ui/src/lib/activity-format.ts b/ui/src/lib/activity-format.ts new file mode 100644 index 00000000..bfebb113 --- /dev/null +++ b/ui/src/lib/activity-format.ts @@ -0,0 +1,289 @@ +import type { Agent } from "@paperclipai/shared"; + +type ActivityDetails = Record | null | undefined; + +type ActivityParticipant = { + type: "agent" | "user"; + agentId?: string | null; + userId?: string | null; +}; + +type ActivityIssueReference = { + id?: string | null; + identifier?: string | null; + title?: string | null; +}; + +interface ActivityFormatOptions { + agentMap?: Map; + currentUserId?: string | null; +} + +const ACTIVITY_ROW_VERBS: Record = { + "issue.created": "created", + "issue.updated": "updated", + "issue.checked_out": "checked out", + "issue.released": "released", + "issue.comment_added": "commented on", + "issue.attachment_added": "attached file to", + "issue.attachment_removed": "removed attachment from", + "issue.document_created": "created document for", + "issue.document_updated": "updated document on", + "issue.document_deleted": "deleted document from", + "issue.commented": "commented on", + "issue.deleted": "deleted", + "agent.created": "created", + "agent.updated": "updated", + "agent.paused": "paused", + "agent.resumed": "resumed", + "agent.terminated": "terminated", + "agent.key_created": "created API key for", + "agent.budget_updated": "updated budget for", + "agent.runtime_session_reset": "reset session for", + "heartbeat.invoked": "invoked heartbeat for", + "heartbeat.cancelled": "cancelled heartbeat for", + "approval.created": "requested approval", + "approval.approved": "approved", + "approval.rejected": "rejected", + "project.created": "created", + "project.updated": "updated", + "project.deleted": "deleted", + "goal.created": "created", + "goal.updated": "updated", + "goal.deleted": "deleted", + "cost.reported": "reported cost for", + "cost.recorded": "recorded cost for", + "company.created": "created company", + "company.updated": "updated company", + "company.archived": "archived", + "company.budget_updated": "updated budget for", +}; + +const ISSUE_ACTIVITY_LABELS: Record = { + "issue.created": "created the issue", + "issue.updated": "updated the issue", + "issue.checked_out": "checked out the issue", + "issue.released": "released the issue", + "issue.comment_added": "added a comment", + "issue.feedback_vote_saved": "saved feedback on an AI output", + "issue.attachment_added": "added an attachment", + "issue.attachment_removed": "removed an attachment", + "issue.document_created": "created a document", + "issue.document_updated": "updated a document", + "issue.document_deleted": "deleted a document", + "issue.deleted": "deleted the issue", + "agent.created": "created an agent", + "agent.updated": "updated the agent", + "agent.paused": "paused the agent", + "agent.resumed": "resumed the agent", + "agent.terminated": "terminated the agent", + "heartbeat.invoked": "invoked a heartbeat", + "heartbeat.cancelled": "cancelled a heartbeat", + "approval.created": "requested approval", + "approval.approved": "approved", + "approval.rejected": "rejected", +}; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function humanizeValue(value: unknown): string { + if (typeof value !== "string") return String(value ?? "none"); + return value.replace(/_/g, " "); +} + +function isActivityParticipant(value: unknown): value is ActivityParticipant { + const record = asRecord(value); + if (!record) return false; + return record.type === "agent" || record.type === "user"; +} + +function isActivityIssueReference(value: unknown): value is ActivityIssueReference { + return asRecord(value) !== null; +} + +function readParticipants(details: ActivityDetails, key: string): ActivityParticipant[] { + const value = details?.[key]; + if (!Array.isArray(value)) return []; + return value.filter(isActivityParticipant); +} + +function readIssueReferences(details: ActivityDetails, key: string): ActivityIssueReference[] { + const value = details?.[key]; + if (!Array.isArray(value)) return []; + return value.filter(isActivityIssueReference); +} + +function formatUserLabel(userId: string | null | undefined, currentUserId?: string | null): string { + if (!userId || userId === "local-board") return "Board"; + if (currentUserId && userId === currentUserId) return "You"; + return `user ${userId.slice(0, 5)}`; +} + +function formatParticipantLabel(participant: ActivityParticipant, options: ActivityFormatOptions): string { + if (participant.type === "agent") { + const agentId = participant.agentId ?? ""; + return options.agentMap?.get(agentId)?.name ?? "agent"; + } + return formatUserLabel(participant.userId, options.currentUserId); +} + +function formatIssueReferenceLabel(reference: ActivityIssueReference): string { + if (reference.identifier) return reference.identifier; + if (reference.title) return reference.title; + if (reference.id) return reference.id.slice(0, 8); + return "issue"; +} + +function formatChangedEntityLabel( + singular: string, + plural: string, + labels: string[], +): string { + if (labels.length <= 0) return plural; + if (labels.length === 1) return `${singular} ${labels[0]}`; + return `${labels.length} ${plural}`; +} + +function formatIssueUpdatedVerb(details: ActivityDetails): string | null { + if (!details) return null; + const previous = asRecord(details._previous) ?? {}; + if (details.status !== undefined) { + const from = previous.status; + return from + ? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on` + : `changed status to ${humanizeValue(details.status)} on`; + } + if (details.priority !== undefined) { + const from = previous.priority; + return from + ? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on` + : `changed priority to ${humanizeValue(details.priority)} on`; + } + return null; +} + +function formatIssueUpdatedAction(details: ActivityDetails): string | null { + if (!details) return null; + const previous = asRecord(details._previous) ?? {}; + const parts: string[] = []; + + if (details.status !== undefined) { + const from = previous.status; + parts.push( + from + ? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}` + : `changed the status to ${humanizeValue(details.status)}`, + ); + } + if (details.priority !== undefined) { + const from = previous.priority; + parts.push( + from + ? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}` + : `changed the priority to ${humanizeValue(details.priority)}`, + ); + } + if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) { + parts.push(details.assigneeAgentId || details.assigneeUserId ? "assigned the issue" : "unassigned the issue"); + } + if (details.title !== undefined) parts.push("updated the title"); + if (details.description !== undefined) parts.push("updated the description"); + + return parts.length > 0 ? parts.join(", ") : null; +} + +function formatStructuredIssueChange(input: { + action: string; + details: ActivityDetails; + options: ActivityFormatOptions; + forIssueDetail: boolean; +}): string | null { + const details = input.details; + if (!details) return null; + + if (input.action === "issue.blockers_updated") { + const added = readIssueReferences(details, "addedBlockedByIssues").map(formatIssueReferenceLabel); + const removed = readIssueReferences(details, "removedBlockedByIssues").map(formatIssueReferenceLabel); + if (added.length > 0 && removed.length === 0) { + const changed = formatChangedEntityLabel("blocker", "blockers", added); + return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`; + } + if (removed.length > 0 && added.length === 0) { + const changed = formatChangedEntityLabel("blocker", "blockers", removed); + return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`; + } + return input.forIssueDetail ? "updated blockers" : "updated blockers on"; + } + + if (input.action === "issue.reviewers_updated" || input.action === "issue.approvers_updated") { + const added = readParticipants(details, "addedParticipants").map((participant) => formatParticipantLabel(participant, input.options)); + const removed = readParticipants(details, "removedParticipants").map((participant) => formatParticipantLabel(participant, input.options)); + const singular = input.action === "issue.reviewers_updated" ? "reviewer" : "approver"; + const plural = input.action === "issue.reviewers_updated" ? "reviewers" : "approvers"; + if (added.length > 0 && removed.length === 0) { + const changed = formatChangedEntityLabel(singular, plural, added); + return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`; + } + if (removed.length > 0 && added.length === 0) { + const changed = formatChangedEntityLabel(singular, plural, removed); + return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`; + } + return input.forIssueDetail ? `updated ${plural}` : `updated ${plural} on`; + } + + return null; +} + +export function formatActivityVerb( + action: string, + details?: Record | null, + options: ActivityFormatOptions = {}, +): string { + if (action === "issue.updated") { + const issueUpdatedVerb = formatIssueUpdatedVerb(details); + if (issueUpdatedVerb) return issueUpdatedVerb; + } + + const structuredChange = formatStructuredIssueChange({ + action, + details, + options, + forIssueDetail: false, + }); + if (structuredChange) return structuredChange; + + return ACTIVITY_ROW_VERBS[action] ?? action.replace(/[._]/g, " "); +} + +export function formatIssueActivityAction( + action: string, + details?: Record | null, + options: ActivityFormatOptions = {}, +): string { + if (action === "issue.updated") { + const issueUpdatedAction = formatIssueUpdatedAction(details); + if (issueUpdatedAction) return issueUpdatedAction; + } + + const structuredChange = formatStructuredIssueChange({ + action, + details, + options, + forIssueDetail: true, + }); + if (structuredChange) return structuredChange; + + if ( + (action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") && + details + ) { + const key = typeof details.key === "string" ? details.key : "document"; + const title = typeof details.title === "string" && details.title ? ` (${details.title})` : ""; + return `${ISSUE_ACTIVITY_LABELS[action] ?? action} ${key}${title}`; + } + + return ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " "); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 48451738..fcaa09cf 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -58,6 +58,7 @@ import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { formatIssueActivityAction } from "@/lib/activity-format"; import { Activity as ActivityIcon, Check, @@ -94,43 +95,8 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { queueTargetRunId?: string | null; }; -const ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS = 3000; -const IDLE_ISSUE_RUN_POLL_INTERVAL_MS = 30000; -const ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 5000; -const IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 30000; - -const ACTION_LABELS: Record = { - "issue.created": "created the issue", - "issue.updated": "updated the issue", - "issue.checked_out": "checked out the issue", - "issue.released": "released the issue", - "issue.comment_added": "added a comment", - "issue.feedback_vote_saved": "saved feedback on an AI output", - "issue.attachment_added": "added an attachment", - "issue.attachment_removed": "removed an attachment", - "issue.document_created": "created a document", - "issue.document_updated": "updated a document", - "issue.document_deleted": "deleted a document", - "issue.deleted": "deleted the issue", - "agent.created": "created an agent", - "agent.updated": "updated the agent", - "agent.paused": "paused the agent", - "agent.resumed": "resumed the agent", - "agent.terminated": "terminated the agent", - "heartbeat.invoked": "invoked a heartbeat", - "heartbeat.cancelled": "cancelled a heartbeat", - "approval.created": "requested approval", - "approval.approved": "approved", - "approval.rejected": "rejected", -}; - const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; -function humanizeValue(value: unknown): string { - if (typeof value !== "string") return String(value ?? "none"); - return value.replace(/_/g, " "); -} - function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; @@ -180,50 +146,6 @@ function titleizeFilename(input: string) { .join(" "); } -function formatAction(action: string, details?: Record | null): string { - if (action === "issue.updated" && details) { - const previous = (details._previous ?? {}) as Record; - const parts: string[] = []; - - if (details.status !== undefined) { - const from = previous.status; - parts.push( - from - ? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}` - : `changed the status to ${humanizeValue(details.status)}` - ); - } - if (details.priority !== undefined) { - const from = previous.priority; - parts.push( - from - ? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}` - : `changed the priority to ${humanizeValue(details.priority)}` - ); - } - if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) { - parts.push( - details.assigneeAgentId || details.assigneeUserId - ? "assigned the issue" - : "unassigned the issue", - ); - } - if (details.title !== undefined) parts.push("updated the title"); - if (details.description !== undefined) parts.push("updated the description"); - - if (parts.length > 0) return parts.join(", "); - } - if ( - (action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") && - details - ) { - const key = typeof details.key === "string" ? details.key : "document"; - const title = typeof details.title === "string" && details.title ? ` (${details.title})` : ""; - return `${ACTION_LABELS[action] ?? action} ${key}${title}`; - } - return ACTION_LABELS[action] ?? action.replace(/[._]/g, " "); -} - function mergeOptimisticFeedbackVote( previousVotes: FeedbackVote[] | undefined, nextVote: { @@ -1901,7 +1823,7 @@ export function IssueDetail() { {activity.slice(0, 20).map((evt) => (
- {formatAction(evt.action, evt.details)} + {formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })} {relativeTime(evt.createdAt)}
))}