mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip is the control plane for companies that use AI agents. > - Apps give humans and agents controlled access to external services. > - The existing app detail flow split permissions, tests, setup, and activity across separate pages. > - The split made access rules harder to understand and made reconnect work hard to find. > - New write actions also defaulted to Ask first, which did not match the intended connection policy. > - This pull request combines permission control and action testing, removes the setup page, and moves connection activity into Audit. > - The benefit is one clear place to configure, test, reconnect, and review each app. ## Linked Issues or Issue Description **What existing behavior does this improve?** The installed app Permissions, Test, Setup, and Activity views. **Subsystem affected** Cross-cutting. This change updates the React UI, shared app defaults, server permission behavior, tests, smoke scripts, and connection documentation. **Current behavior** App access and action testing use separate pages. The app detail view also links to a setup page after installation. Connection activity uses a separate tab. New write actions default to Ask first. **Proposed behavior** Permissions uses the connection access language from the initial flow. It includes searchable Read and Write sections, a three-state permission control, and a Test dialog for each action. Reconnect appears below a Needs attention header on Permissions and Review. Old Setup and Test links redirect to Permissions. Old Activity links redirect to the filtered company Audit feed. New write actions default to Allowed. **Reason and benefit** A person can understand and test app access without moving between several pages. Reconnect work stays visible where the person reviews the connection. Audit events use one consistent feed and filter model. New connections have the intended default policy. **Breaking changes** The Setup, Test, and app Activity tabs are removed. Existing deep links redirect to their replacement pages. Existing saved action permissions do not change. Only defaults for new write actions change. **Additional context** This builds on the managed app connection work in #12728. A search found no duplicate open pull request or issue. ## What Changed - Combined action testing with Permissions. - Added searchable Read and Write action groups. - Added Off, Ask first, and Allowed controls with tooltips. - Added an action Test dialog with agent selection, arguments, and formatted results. - Removed the installed-app Setup and Activity tabs. - Added reconnect guidance to Permissions and Review when a connection needs attention. - Routed connection activity into the company Audit feed and preserved the Apps & tools filter in streamlined Audit. - Moved connection removal to the Connectors-page management menu. - Made new write actions default to Allowed across connection creation paths. - Updated regression tests, browser suites, smoke scripts, and connection documentation. ## Verification - `pnpm check:token-gates` - `pnpm exec vitest run packages/shared/src/app-definitions.test.ts server/src/__tests__/generic-mcp-connection.test.ts server/src/__tests__/tool-access-service.test.ts ui/src/components/AppConnectionSidebar.test.tsx ui/src/pages/apps/AppDetail.test.tsx ui/src/pages/apps/AppNotConnected.test.tsx ui/src/pages/apps/AppsConnect.test.tsx ui/src/pages/apps/Browse.test.tsx ui/src/pages/apps/Connections.test.tsx ui/src/pages/apps/composio-services.test.ts ui/src/pages/audit/AuditFeed.test.tsx ui/src/pages/tools/PasteConfigTab.test.tsx` (517 tests passed) - `pnpm exec vitest run ui/src/pages/apps/app-detail/TestPanel.test.tsx ui/src/pages/audit/AuditHub.test.tsx ui/src/pages/audit/AuditFeed.test.tsx ui/src/pages/apps/AppDetail.test.tsx ui/src/pages/apps/Browse.test.tsx` (96 tests passed) - Targeted Playwright verification for connection removal, rename on Permissions, inline action testing, and Smoke Lab Audit evidence (5 flows passed) - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` completed with 5,755 passing tests and 20 unrelated macOS harness failures. The failures use `/tmp` versus `/private/tmp`, invalid ports above 65535, and workspace fixtures outside this change. ## Risks - Low migration risk. This change has no database migration. - Old app-detail URLs depend on redirect compatibility. - New connections grant write actions by default. Finalization remains configure-authorized and audited, Ask first and Off remain available per action, and existing connections keep their saved policy. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected - check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, exact model ID `gpt-5`. The client does not expose the context-window size. The model used reasoning, repository tools, code execution, and browser verification. ## 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 - [x] 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: Paperclip <noreply@paperclip.ing>
533 lines
23 KiB
TypeScript
533 lines
23 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_skipped": "skipped 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 organization",
|
|
"company.updated": "updated organization",
|
|
"company.archived": "archived",
|
|
"company.reactivated": "reactivated",
|
|
"company.budget_updated": "updated budget for",
|
|
"audit.exported": "exported the agent audit log for",
|
|
"tool_app.connected": "connected",
|
|
"tool_app.oauth_connected": "connected credentials for",
|
|
"tool_app.oauth_failed": "failed to connect credentials for",
|
|
"tool_app.oauth_access_finalized": "finished credential access for",
|
|
"tool_app.finished": "finished setup for",
|
|
"tool_app.reconnected": "reconnected",
|
|
"tool_connection.created": "created",
|
|
"tool_connection.updated": "updated",
|
|
"tool_connection.archived": "removed",
|
|
"tool_connection.catalog_refresh": "refreshed actions for",
|
|
"tool_connection.installs_synced": "changed agent installs for",
|
|
"tool_connection.install_access_extended": "extended agent access for",
|
|
"tool_connection.grant_audience_replaced": "changed human access for",
|
|
"tool_connection.grant_added": "added credentials to",
|
|
"tool_connection.grant_revoked": "revoked credentials from",
|
|
"tool_connection.grant_delegated": "delegated credentials for",
|
|
"tool_connection.grant_delegation_revoked": "revoked credential delegation 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_skipped": "skipped 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.startsWith("tool_gateway.")) {
|
|
const rawTool = typeof details?.tool === "string"
|
|
? details.tool
|
|
: typeof details?.upstreamToolName === "string"
|
|
? details.upstreamToolName
|
|
: "an app action";
|
|
const tool = rawTool.replace(/[._-]+/g, " ");
|
|
const isTest = details?.source === "test";
|
|
if (action === "tool_gateway.call_completed") return `${isTest ? "tested" : "used"} ${tool} on`;
|
|
if (action === "tool_gateway.call_allowed") return `${isTest ? "started a test of" : "was allowed to use"} ${tool} on`;
|
|
if (action === "tool_gateway.call_denied") return `was blocked from using ${tool} on`;
|
|
if (action === "tool_gateway.approval_requested") return `asked to use ${tool} on`;
|
|
if (action === "tool_gateway.session_created") return "opened an app session for";
|
|
if (action === "tool_gateway.session_rejected") return "was blocked from opening an app session for";
|
|
if (action === "tool_gateway.discovery") return "discovered app actions for";
|
|
}
|
|
|
|
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, " ");
|
|
}
|