mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents move their work into review, and a reviewer must then give a verdict on it > - By default anyone with write access can give that verdict, including the agent that did the work > - The server can constrain that default per issue with a `reviewPolicy` column, but no screen showed the value > - A reviewer could therefore press Approve on a review that the server refuses, and get a 403 > - This pull request shows the policy as a badge on the two surfaces where a person gives a verdict > - It also makes an agent verdict read as a verdict in the activity timeline > - The benefit is that a reviewer sees who can approve before they try ## Linked Issues or Issue Description No public GitHub issue exists for this change. The description below follows `.github/ISSUE_TEMPLATE/enhancement.yml`. **What existing behavior does this improve?** The issue review flow. A reviewer cannot see the approval constraint on an issue before they give a verdict. **Subsystem affected** Web UI (`ui/`), with one supporting change in the server attention service. **Current behavior** The server stores an optional approval constraint for each issue in a `reviewPolicy` column. The column has three meaningful states: the default (`NULL` or `anyone`), `not_creator`, and `human_only`. The server enforces the constraint when it receives a verdict. No screen shows the value. Two problems follow: 1. A reviewer presses Approve on a review that the server refuses. The server answers 403, and the reason is not visible on the card. 2. An agent that accepts or rejects a review renders in the activity timeline as the raw action id, for example "issue thread interaction accepted". A person who reads the timeline cannot tell that a verdict was given. **Proposed behavior** Show the constraint as a read-only badge on the two surfaces where a person gives a verdict. Show no pixels for the default state, because the default is what every issue already does. Make an agent verdict read as a verdict in the timeline. Only agents set the column today, so this change adds no control to set it. **Reason and benefit** A reviewer sees the constraint before they act. This prevents the 403, and it removes the need to explain the 403 afterwards. The timeline also becomes complete, because it now shows agent verdicts and human verdicts in the same way. **Breaking changes** None. The change adds a badge and changes copy. It adds no column, no endpoint, and no request. **Additional context** The server-side column and the verdict enforcement landed earlier in #10931. This pull request is the user interface for that column. The default state stays unchanged on screen, so the badge appears on a small number of issues. ## What Changed - **A read-only "Approvals" row** in the issue Execution properties. The row renders *only* for a constrained policy: "Anyone else" (`not_creator`) or "Human only" (`human_only`). A `NULL` or `anyone` column adds no row, so the panel is untouched on the overwhelming majority of issues. - **The same badge on the stalled-review card** in `/decisions`, above the three review verbs. A reviewer now sees the constraint before they press Approve. The condition is the same, so the default card is unchanged. - **Agent verdicts read as verdicts in the activity timeline.** An agent that accepted or rejected a review request previously rendered the raw action id ("issue thread interaction accepted"). It now reads "approved the request". A stalled-review decision names the verb that the actor chose. - **A cleared policy reports as "anyone", not "none",** in the field-change receipt. The `reviewPolicy` column is nullable by default, so an absent value is a real setting rather than a missing one. - **All copy comes from `ui/src/lib/review-policy.ts`.** Its badge lookup returns `null` for the default. This makes "no pixels for the default" one enforced decision instead of a condition repeated at each call site. It also keeps the badge, the activity line, and the receipt reading alike. - **The server attention service carries the policy** on the review attention subject, so the stalled-review card can read it. ## Verification Automated tests: - `ui/src/lib/review-policy.test.ts` — the default returns no badge, however the column spells it (`null`, `undefined`, `"anyone"`). An unrecognised policy from the wire shows nothing rather than leaking an enum value. - `ui/src/components/AttentionQueueRow.test.tsx` — no badge on the default card, and the verbs still render. Suppression of the badge must not suppress the card. - `ui/src/components/IssueProperties.test.tsx` — no Approvals row on the default. The constrained row contains no `button`, so nothing there can PATCH. - `server/src/__tests__/attention-service.test.ts` — the review attention subject carries the policy, and subjects built from narrower selects do not claim one. Run them with: ```sh pnpm vitest run ui/src/lib/review-policy.test.ts \ ui/src/components/AttentionQueueRow.test.tsx \ ui/src/components/IssueProperties.test.tsx \ server/src/__tests__/attention-service.test.ts ``` Manual steps: 1. Open an issue that has no `reviewPolicy`. Confirm that the Execution properties panel shows no Approvals row. 2. Set the column to `not_creator`. Reload the issue. Confirm that the Approvals row reads "Anyone else", and that the row has no control. 3. Move that issue into review. Open `/decisions`. Confirm that the stalled review card shows the same badge above the review verbs. 4. Let an agent approve the review. Confirm that the activity timeline reads "approved the request" and not "issue thread interaction accepted". Screenshots were captured at 1440x900 and 390x844, in light mode and dark mode, with the three policy states side by side. The leftmost column in each capture is the default. It carries no badge and no extra row. ## Risks Low risk. - The change is additive on screen. Every new surface is behind a constrained policy, so the default path renders exactly as before. - The badge is read-only. It has no control and sends no request, and a test asserts that the row contains no `button`. - An unknown policy value from the wire renders nothing. It does not render the raw enum. - No migration, no schema change, and no endpoint change. ## Model Used Claude Opus 5 (Anthropic), model id `claude-opus-5[1m]`, 1M context window, with extended thinking and tool use enabled. Used through Claude Code for the implementation, the tests, and this description. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
497 lines
21 KiB
TypeScript
497 lines
21 KiB
TypeScript
import type { Agent } from "@paperclipai/shared";
|
|
import type { CompanyUserProfile } from "./company-members";
|
|
import { formatReviewPolicyValue } from "./review-policy";
|
|
|
|
type ActivityDetails = Record<string, unknown> | 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<string, Agent>;
|
|
userProfileMap?: Map<string, CompanyUserProfile>;
|
|
currentUserId?: string | null;
|
|
}
|
|
|
|
const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
|
"issue.created": "created",
|
|
"issue.updated": "updated",
|
|
"issue.checked_out": "checked out",
|
|
"issue.released": "released",
|
|
"issue.comment_added": "commented on",
|
|
"issue.comment_cancelled": "cancelled a queued comment on",
|
|
"issue.comment_deleted": "deleted a comment 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_locked": "locked document on",
|
|
"issue.document_unlocked": "unlocked document on",
|
|
"issue.document_deleted": "deleted document from",
|
|
"issue.monitor_scheduled": "scheduled monitor on",
|
|
"issue.monitor_triggered": "triggered monitor for",
|
|
"issue.monitor_cleared": "cleared monitor on",
|
|
"issue.monitor_skipped": "skipped monitor for",
|
|
"issue.monitor_exhausted": "exhausted monitor on",
|
|
"issue.monitor_recovery_wake_queued": "queued monitor recovery for",
|
|
"issue.monitor_recovery_issue_created": "created monitor recovery for",
|
|
"issue.monitor_escalated_to_board": "escalated monitor for",
|
|
"issue.commented": "commented on",
|
|
"issue.deleted": "deleted",
|
|
"issue.successful_run_handoff_required": "flagged missing next step on",
|
|
"issue.successful_run_handoff_resolved": "recorded next step chosen on",
|
|
"issue.successful_run_handoff_escalated": "escalated missing next step on",
|
|
"issue.accepted_plan_decomposition_updated": "updated accepted-plan decomposition on",
|
|
"issue.recovery_action_opened": "opened a recovery action on",
|
|
"issue.recovery_action_resolved": "resolved the recovery action on",
|
|
"issue.recovery_action_escalated": "escalated the recovery action on",
|
|
"agent.created": "created",
|
|
"agent.updated": "updated",
|
|
"agent.paused": "paused",
|
|
"agent.resumed": "resumed",
|
|
"agent.error_cleared": "cleared error on",
|
|
"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",
|
|
"heartbeat.output_stale_source_resolved": "system-folded stale run on",
|
|
"heartbeat.output_stale_recovery_recursion_refused": "refused recovery-on-recovery for",
|
|
"approval.created": "requested approval",
|
|
"approval.approved": "approved",
|
|
"approval.rejected": "rejected",
|
|
// Interaction outcomes (PAP-16506). An agent may now resolve one — including a
|
|
// review of its own work — so these must read as outcomes in the feed instead
|
|
// of falling through to the raw "issue thread interaction accepted" action id.
|
|
// `details.interactionKind` sharpens the wording; see INTERACTION_OUTCOME_LABELS.
|
|
"issue.thread_interaction_created": "asked for a decision on",
|
|
"issue.thread_interaction_accepted": "accepted the request on",
|
|
"issue.thread_interaction_rejected": "rejected the request on",
|
|
"issue.thread_interaction_answered": "answered the request on",
|
|
"issue.thread_interaction_withdrawn": "withdrew the request on",
|
|
"issue.thread_interaction_cancelled": "cancelled the request on",
|
|
"issue.thread_interaction_expired": "expired the request on",
|
|
"issue.thread_interaction_item_verdicts_submitted": "submitted verdicts on",
|
|
"issue.stalled_review_decided": "recorded a review verdict on",
|
|
"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.reactivated": "reactivated",
|
|
"company.budget_updated": "updated budget for",
|
|
"audit.exported": "exported the agent audit log for",
|
|
};
|
|
|
|
const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
|
"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.comment_cancelled": "cancelled a queued comment",
|
|
"issue.comment_deleted": "deleted 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_locked": "locked a document",
|
|
"issue.document_unlocked": "unlocked a document",
|
|
"issue.document_deleted": "deleted a document",
|
|
"issue.monitor_scheduled": "scheduled a monitor",
|
|
"issue.monitor_triggered": "triggered a monitor",
|
|
"issue.monitor_cleared": "cleared a monitor",
|
|
"issue.monitor_skipped": "skipped a monitor",
|
|
"issue.monitor_exhausted": "exhausted a monitor",
|
|
"issue.monitor_recovery_wake_queued": "queued a monitor recovery wake",
|
|
"issue.monitor_recovery_issue_created": "created a monitor recovery issue",
|
|
"issue.monitor_escalated_to_board": "escalated a monitor to the board",
|
|
"issue.deleted": "deleted the issue",
|
|
"issue.successful_run_handoff_required": "Run finished without a clear next step",
|
|
"issue.successful_run_handoff_resolved": "Next step chosen",
|
|
"issue.successful_run_handoff_escalated": "Run finished without a next step - recovery escalated",
|
|
"issue.cross_issue_influence_cap_rejected": "hit the per-run cross-task write cap",
|
|
"issue.cross_issue_influence_observed": "made a cross-task write",
|
|
"issue.attribution_spoof_rejected": "tried to choose its own responsible user",
|
|
"issue.recovery_action_opened": "Opened a source-scoped recovery action",
|
|
"issue.recovery_action_resolved": "Resolved the recovery action",
|
|
"issue.recovery_action_escalated": "Escalated the recovery action",
|
|
"issue.accepted_plan_decomposition_updated": "updated the accepted-plan decomposition",
|
|
"agent.created": "created an agent",
|
|
"agent.updated": "updated the agent",
|
|
"agent.paused": "paused the agent",
|
|
"agent.resumed": "resumed the agent",
|
|
"agent.error_cleared": "cleared the agent error",
|
|
"agent.terminated": "terminated the agent",
|
|
"heartbeat.invoked": "invoked a heartbeat",
|
|
"heartbeat.cancelled": "cancelled a heartbeat",
|
|
"heartbeat.output_stale_source_resolved": "System folded a stale run",
|
|
"heartbeat.output_stale_recovery_recursion_refused": "Refused recovery-on-recovery escalation",
|
|
"approval.created": "requested approval",
|
|
"approval.approved": "approved",
|
|
"approval.rejected": "rejected",
|
|
"issue.thread_interaction_created": "asked for a decision",
|
|
"issue.thread_interaction_accepted": "accepted the request",
|
|
"issue.thread_interaction_rejected": "rejected the request",
|
|
"issue.thread_interaction_answered": "answered the request",
|
|
"issue.thread_interaction_withdrawn": "withdrew the request",
|
|
"issue.thread_interaction_cancelled": "cancelled the request",
|
|
"issue.thread_interaction_expired": "expired the request",
|
|
"issue.thread_interaction_item_verdicts_submitted": "submitted verdicts on the request",
|
|
"issue.stalled_review_decided": "recorded a review verdict",
|
|
};
|
|
|
|
/**
|
|
* `issue.stalled_review_decided` carries the verb the actor chose, so the line
|
|
* names the verdict ("approved the review") rather than the generic action.
|
|
* Mirrors `StalledReviewDecisionAction` in shared.
|
|
*/
|
|
const STALLED_REVIEW_DECISION_LABELS: Record<string, string> = {
|
|
approve: "approved the review",
|
|
request_changes: "requested changes on the review",
|
|
send_back: "sent the review back to work",
|
|
};
|
|
|
|
/**
|
|
* `issue.thread_interaction_accepted` / `_rejected` fire for *every* interaction
|
|
* kind, not only for a review. A task suggestion or a question is accepted, not
|
|
* approved, so the kind on the event picks the verb. Kinds absent from a map
|
|
* keep the neutral "accepted the request" wording from the tables above, which
|
|
* is also the fallback for an event that carries no kind.
|
|
*/
|
|
const INTERACTION_ACCEPTED_LABELS: Record<string, string> = {
|
|
request_confirmation: "approved the request",
|
|
request_checkbox_confirmation: "approved the request",
|
|
suggest_tasks: "accepted the task suggestions",
|
|
ask_user_questions: "accepted the answers",
|
|
};
|
|
|
|
const INTERACTION_REJECTED_LABELS: Record<string, string> = {
|
|
request_confirmation: "rejected the request",
|
|
request_checkbox_confirmation: "rejected the request",
|
|
suggest_tasks: "declined the task suggestions",
|
|
ask_user_questions: "declined the questions",
|
|
};
|
|
|
|
/**
|
|
* Kind-aware wording for an interaction outcome, or `null` when the tables
|
|
* above already say it well enough.
|
|
*/
|
|
function formatInteractionOutcomeLabel(action: string, details: ActivityDetails): string | null {
|
|
const table = action === "issue.thread_interaction_accepted"
|
|
? INTERACTION_ACCEPTED_LABELS
|
|
: action === "issue.thread_interaction_rejected"
|
|
? INTERACTION_REJECTED_LABELS
|
|
: null;
|
|
if (!table) return null;
|
|
const kind = typeof details?.interactionKind === "string" ? details.interactionKind : null;
|
|
return kind ? table[kind] ?? null : null;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
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, options: ActivityFormatOptions = {}): string {
|
|
if (!userId || userId === "local-board") return "Board";
|
|
if (options.currentUserId && userId === options.currentUserId) return "You";
|
|
const profile = options.userProfileMap?.get(userId);
|
|
if (profile) return profile.label;
|
|
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);
|
|
}
|
|
|
|
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 "task";
|
|
}
|
|
|
|
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 readNumber(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
return null;
|
|
}
|
|
|
|
function readStringArrayLength(value: unknown): number {
|
|
if (!Array.isArray(value)) return 0;
|
|
return value.filter((entry) => typeof entry === "string" && entry.length > 0).length;
|
|
}
|
|
|
|
function formatAcceptedPlanDecompositionDetail(details: ActivityDetails): string | null {
|
|
if (!details) return null;
|
|
const status = typeof details.status === "string" ? details.status : null;
|
|
const requested = readNumber(details.requestedChildCount);
|
|
const totalChildren = readStringArrayLength(details.childIssueIds);
|
|
const newlyCreated = readStringArrayLength(details.newlyCreatedChildIssueIds);
|
|
const reused = Math.max(0, totalChildren - newlyCreated);
|
|
const parts: string[] = [];
|
|
if (newlyCreated > 0) parts.push(`created ${newlyCreated} new`);
|
|
if (reused > 0) parts.push(`reused ${reused} existing`);
|
|
if (parts.length === 0 && requested !== null) parts.push(`${requested} requested`);
|
|
const summary = parts.length > 0 ? parts.join(", ") : null;
|
|
if (status === "completed" && summary) return `decomposition completed (${summary})`;
|
|
if (status === "completed") return "decomposition completed";
|
|
if (status === "in_flight" && summary) return `decomposition in flight (${summary})`;
|
|
return summary;
|
|
}
|
|
|
|
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 formatAssigneeName(details: ActivityDetails, options: ActivityFormatOptions): string | null {
|
|
if (!details) return null;
|
|
const agentId = details.assigneeAgentId;
|
|
const userId = details.assigneeUserId;
|
|
if (typeof agentId === "string" && agentId) {
|
|
return options.agentMap?.get(agentId)?.name ?? "agent";
|
|
}
|
|
if (typeof userId === "string" && userId) {
|
|
return formatUserLabel(userId, options);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatIssueUpdatedAction(details: ActivityDetails, options: ActivityFormatOptions = {}): 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) {
|
|
const assigneeName = formatAssigneeName(details, options);
|
|
parts.push(assigneeName ? `made ${assigneeName} responsible for the task` : "cleared the responsible");
|
|
}
|
|
if (details.reviewPolicy !== undefined) {
|
|
// `null` is the default ("anyone can approve"), so it must not read as
|
|
// "changed the review policy to none" (PAP-16506).
|
|
parts.push(`changed who can approve to ${formatReviewPolicyValue(details.reviewPolicy)}`);
|
|
}
|
|
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<string, unknown> | null,
|
|
options: ActivityFormatOptions = {},
|
|
): string {
|
|
if (action === "issue.updated") {
|
|
const issueUpdatedVerb = formatIssueUpdatedVerb(details);
|
|
if (issueUpdatedVerb) return issueUpdatedVerb;
|
|
}
|
|
|
|
if (action === "issue.stalled_review_decided") {
|
|
const decision = typeof details?.action === "string" ? details.action : null;
|
|
const label = decision ? STALLED_REVIEW_DECISION_LABELS[decision] : null;
|
|
if (label) return `${label} on`;
|
|
}
|
|
|
|
const outcomeLabel = formatInteractionOutcomeLabel(action, details);
|
|
if (outcomeLabel) return `${outcomeLabel} on`;
|
|
|
|
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<string, unknown> | null,
|
|
options: ActivityFormatOptions = {},
|
|
): string {
|
|
if (action === "issue.updated") {
|
|
const issueUpdatedAction = formatIssueUpdatedAction(details, options);
|
|
if (issueUpdatedAction) return issueUpdatedAction;
|
|
}
|
|
|
|
const structuredChange = formatStructuredIssueChange({
|
|
action,
|
|
details,
|
|
options,
|
|
forIssueDetail: true,
|
|
});
|
|
if (structuredChange) return structuredChange;
|
|
|
|
if (action === "issue.accepted_plan_decomposition_updated") {
|
|
const detail = formatAcceptedPlanDecompositionDetail(details);
|
|
if (detail) return detail;
|
|
}
|
|
|
|
if (action === "issue.stalled_review_decided") {
|
|
const decision = typeof details?.action === "string" ? details.action : null;
|
|
const label = decision ? STALLED_REVIEW_DECISION_LABELS[decision] : null;
|
|
if (label) return label;
|
|
}
|
|
|
|
const outcomeLabel = formatInteractionOutcomeLabel(action, details);
|
|
if (outcomeLabel) return outcomeLabel;
|
|
|
|
if (action.startsWith("issue.monitor_") && details) {
|
|
const serviceName = typeof details.serviceName === "string" && details.serviceName.trim()
|
|
? details.serviceName.trim()
|
|
: null;
|
|
const base = ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
|
|
return serviceName ? `${base} for ${serviceName}` : base;
|
|
}
|
|
|
|
if (
|
|
(
|
|
action === "issue.document_created" ||
|
|
action === "issue.document_updated" ||
|
|
action === "issue.document_locked" ||
|
|
action === "issue.document_unlocked" ||
|
|
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, " ");
|
|
}
|