mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
Add issue review policy and comment retry
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
4b39b0cc14
commit
b3e0c31239
18 changed files with 1409 additions and 5 deletions
|
|
@ -12,6 +12,7 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
|
|
@ -166,6 +167,10 @@ export function IssueProperties({
|
|||
const [projectSearch, setProjectSearch] = useState("");
|
||||
const [blockedByOpen, setBlockedByOpen] = useState(false);
|
||||
const [blockedBySearch, setBlockedBySearch] = useState("");
|
||||
const [reviewersOpen, setReviewersOpen] = useState(false);
|
||||
const [reviewerSearch, setReviewerSearch] = useState("");
|
||||
const [approversOpen, setApproversOpen] = useState(false);
|
||||
const [approverSearch, setApproverSearch] = useState("");
|
||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||
const [labelSearch, setLabelSearch] = useState("");
|
||||
const [newLabelName, setNewLabelName] = useState("");
|
||||
|
|
@ -265,9 +270,59 @@ export function IssueProperties({
|
|||
const assignee = issue.assigneeAgentId
|
||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||
: null;
|
||||
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
|
||||
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
|
||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
|
||||
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||
const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => {
|
||||
onUpdate({
|
||||
executionPolicy: buildExecutionPolicy({
|
||||
existingPolicy: issue.executionPolicy ?? null,
|
||||
reviewerValues: nextReviewers,
|
||||
approverValues: nextApprovers,
|
||||
}),
|
||||
});
|
||||
};
|
||||
const toggleExecutionParticipant = (stageType: "review" | "approval", value: string) => {
|
||||
const currentValues = stageType === "review" ? reviewerValues : approverValues;
|
||||
const nextValues = currentValues.includes(value)
|
||||
? currentValues.filter((candidate) => candidate !== value)
|
||||
: [...currentValues, value];
|
||||
updateExecutionPolicy(
|
||||
stageType === "review" ? nextValues : reviewerValues,
|
||||
stageType === "approval" ? nextValues : approverValues,
|
||||
);
|
||||
};
|
||||
const executionParticipantLabel = (value: string) => {
|
||||
if (value.startsWith("agent:")) {
|
||||
return agentName(value.slice("agent:".length)) ?? value.slice("agent:".length, "agent:".length + 8);
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
return userLabel(value.slice("user:".length)) ?? "User";
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const reviewerTrigger = reviewerValues.length > 0
|
||||
? <span className="text-sm truncate">{reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
||||
: <span className="text-sm text-muted-foreground">None</span>;
|
||||
const approverTrigger = approverValues.length > 0
|
||||
? <span className="text-sm truncate">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
||||
: <span className="text-sm text-muted-foreground">None</span>;
|
||||
const currentExecutionLabel = (() => {
|
||||
if (!issue.executionState?.currentStageType) return null;
|
||||
const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval";
|
||||
const participant = issue.executionState.currentParticipant;
|
||||
const participantLabel = participant
|
||||
? (participant.type === "agent"
|
||||
? agentName(participant.agentId ?? null)
|
||||
: userLabel(participant.userId ?? null))
|
||||
: null;
|
||||
if (issue.executionState.status === "changes_requested") {
|
||||
return `${stageLabel} requested changes${participantLabel ? ` by ${participantLabel}` : ""}`;
|
||||
}
|
||||
return `${stageLabel} pending${participantLabel ? ` with ${participantLabel}` : ""}`;
|
||||
})();
|
||||
|
||||
const labelsTrigger = (issue.labels ?? []).length > 0 ? (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
|
|
@ -454,6 +509,80 @@ export function IssueProperties({
|
|||
</>
|
||||
);
|
||||
|
||||
const executionParticipantsContent = (
|
||||
stageType: "review" | "approval",
|
||||
values: string[],
|
||||
search: string,
|
||||
setSearch: (value: string) => void,
|
||||
onClear: () => void,
|
||||
) => (
|
||||
<>
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder={`Search ${stageType === "review" ? "reviewers" : "approvers"}...`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus={!inline}
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.length === 0 && "bg-accent",
|
||||
)}
|
||||
onClick={onClear}
|
||||
>
|
||||
No {stageType === "review" ? "reviewers" : "approvers"}
|
||||
</button>
|
||||
{currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(`user:${currentUserId}`) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggleExecutionParticipant(stageType, `user:${currentUserId}`)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
Assign to me
|
||||
</button>
|
||||
)}
|
||||
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(`user:${issue.createdByUserId}`) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggleExecutionParticipant(stageType, `user:${issue.createdByUserId}`)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{creatorUserLabel ? creatorUserLabel : "Requester"}
|
||||
</button>
|
||||
)}
|
||||
{sortedAgents
|
||||
.filter((agent) => {
|
||||
if (!search.trim()) return true;
|
||||
return agent.name.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
.map((agent) => {
|
||||
const encoded = `agent:${agent.id}`;
|
||||
return (
|
||||
<button
|
||||
key={`${stageType}:${agent.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(encoded) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggleExecutionParticipant(stageType, encoded)}
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||
{agent.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const projectTrigger = issue.projectId ? (
|
||||
<>
|
||||
<span
|
||||
|
|
@ -750,6 +879,48 @@ export function IssueProperties({
|
|||
</div>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Reviewers"
|
||||
open={reviewersOpen}
|
||||
onOpenChange={(open) => { setReviewersOpen(open); if (!open) setReviewerSearch(""); }}
|
||||
triggerContent={reviewerTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName="w-56"
|
||||
>
|
||||
{executionParticipantsContent(
|
||||
"review",
|
||||
reviewerValues,
|
||||
reviewerSearch,
|
||||
setReviewerSearch,
|
||||
() => updateExecutionPolicy([], approverValues),
|
||||
)}
|
||||
</PropertyPicker>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Approvers"
|
||||
open={approversOpen}
|
||||
onOpenChange={(open) => { setApproversOpen(open); if (!open) setApproverSearch(""); }}
|
||||
triggerContent={approverTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName="w-56"
|
||||
>
|
||||
{executionParticipantsContent(
|
||||
"approval",
|
||||
approverValues,
|
||||
approverSearch,
|
||||
setApproverSearch,
|
||||
() => updateExecutionPolicy(reviewerValues, []),
|
||||
)}
|
||||
</PropertyPicker>
|
||||
|
||||
{currentExecutionLabel && (
|
||||
<PropertyRow label="Execution">
|
||||
<span className="text-sm">{currentExecutionLabel}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{issue.parentId && (
|
||||
<PropertyRow label="Parent">
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { assetsApi } from "../api/assets";
|
|||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { buildExecutionPolicy } from "../lib/issue-execution-policy";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import {
|
||||
assigneeValueFromSelection,
|
||||
|
|
@ -66,6 +67,8 @@ interface IssueDraft {
|
|||
status: string;
|
||||
priority: string;
|
||||
assigneeValue: string;
|
||||
reviewerValue: string;
|
||||
approverValue: string;
|
||||
assigneeId?: string;
|
||||
projectId: string;
|
||||
projectWorkspaceId?: string;
|
||||
|
|
@ -281,6 +284,8 @@ export function NewIssueDialog() {
|
|||
const [status, setStatus] = useState("todo");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [assigneeValue, setAssigneeValue] = useState("");
|
||||
const [reviewerValue, setReviewerValue] = useState("");
|
||||
const [approverValue, setApproverValue] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
|
||||
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
||||
|
|
@ -484,6 +489,8 @@ export function NewIssueDialog() {
|
|||
status,
|
||||
priority,
|
||||
assigneeValue,
|
||||
reviewerValue,
|
||||
approverValue,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelOverride,
|
||||
|
|
@ -498,6 +505,8 @@ export function NewIssueDialog() {
|
|||
status,
|
||||
priority,
|
||||
assigneeValue,
|
||||
reviewerValue,
|
||||
approverValue,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelOverride,
|
||||
|
|
@ -547,6 +556,8 @@ export function NewIssueDialog() {
|
|||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
|
|
@ -565,6 +576,8 @@ export function NewIssueDialog() {
|
|||
? assigneeValueFromSelection(newIssueDefaults)
|
||||
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
|
||||
);
|
||||
setReviewerValue(draft.reviewerValue ?? "");
|
||||
setApproverValue(draft.approverValue ?? "");
|
||||
setProjectId(restoredProjectId);
|
||||
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
|
|
@ -584,6 +597,8 @@ export function NewIssueDialog() {
|
|||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
|
|
@ -626,6 +641,8 @@ export function NewIssueDialog() {
|
|||
setStatus("todo");
|
||||
setPriority("");
|
||||
setAssigneeValue("");
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeOptionsOpen(false);
|
||||
|
|
@ -647,6 +664,8 @@ export function NewIssueDialog() {
|
|||
if (companyId === effectiveCompanyId) return;
|
||||
setDialogCompanyId(companyId);
|
||||
setAssigneeValue("");
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeModelOverride("");
|
||||
|
|
@ -685,6 +704,10 @@ export function NewIssueDialog() {
|
|||
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
|
||||
? { mode: requestedExecutionWorkspaceMode }
|
||||
: null;
|
||||
const executionPolicy = buildExecutionPolicy({
|
||||
reviewerValues: reviewerValue ? [reviewerValue] : [],
|
||||
approverValues: approverValue ? [approverValue] : [],
|
||||
});
|
||||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
stagedFiles,
|
||||
|
|
@ -704,6 +727,7 @@ export function NewIssueDialog() {
|
|||
? { executionWorkspaceId: selectedExecutionWorkspaceId }
|
||||
: {}),
|
||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||
...(executionPolicy ? { executionPolicy } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1153,6 +1177,64 @@ export function NewIssueDialog() {
|
|||
);
|
||||
}}
|
||||
/>
|
||||
<InlineEntitySelector
|
||||
value={reviewerValue}
|
||||
options={assigneeOptions}
|
||||
placeholder="Reviewer"
|
||||
disablePortal
|
||||
noneLabel="No reviewer"
|
||||
searchPlaceholder="Search reviewers..."
|
||||
emptyMessage="No reviewers found."
|
||||
onChange={setReviewerValue}
|
||||
renderTriggerValue={(option) =>
|
||||
option ? (
|
||||
<span className="truncate">{`Reviewer: ${option.label}`}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Reviewer</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const reviewer = parseAssigneeValue(option.id).assigneeAgentId
|
||||
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
{reviewer ? <AgentIcon icon={reviewer.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<InlineEntitySelector
|
||||
value={approverValue}
|
||||
options={assigneeOptions}
|
||||
placeholder="Approver"
|
||||
disablePortal
|
||||
noneLabel="No approver"
|
||||
searchPlaceholder="Search approvers..."
|
||||
emptyMessage="No approvers found."
|
||||
onChange={setApproverValue}
|
||||
renderTriggerValue={(option) =>
|
||||
option ? (
|
||||
<span className="truncate">{`Approver: ${option.label}`}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Approver</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const approver = parseAssigneeValue(option.id).assigneeAgentId
|
||||
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
{approver ? <AgentIcon icon={approver.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
95
ui/src/lib/issue-execution-policy.ts
Normal file
95
ui/src/lib/issue-execution-policy.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { IssueExecutionPolicy, IssueExecutionStageParticipant, IssueExecutionStagePrincipal } from "@paperclipai/shared";
|
||||
import { parseAssigneeValue } from "./assignees";
|
||||
|
||||
type StageType = "review" | "approval";
|
||||
|
||||
function newId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `stage-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function principalKey(principal: IssueExecutionStagePrincipal | IssueExecutionStageParticipant) {
|
||||
return principal.type === "agent" ? `agent:${principal.agentId}` : `user:${principal.userId}`;
|
||||
}
|
||||
|
||||
export function principalFromSelectionValue(value: string): IssueExecutionStagePrincipal | null {
|
||||
const selection = parseAssigneeValue(value);
|
||||
if (selection.assigneeAgentId) {
|
||||
return { type: "agent", agentId: selection.assigneeAgentId, userId: null };
|
||||
}
|
||||
if (selection.assigneeUserId) {
|
||||
return { type: "user", userId: selection.assigneeUserId, agentId: null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function selectionValueFromPrincipal(principal: IssueExecutionStagePrincipal | IssueExecutionStageParticipant): string {
|
||||
return principal.type === "agent" ? `agent:${principal.agentId}` : `user:${principal.userId}`;
|
||||
}
|
||||
|
||||
export function stageParticipantValues(policy: IssueExecutionPolicy | null | undefined, stageType: StageType): string[] {
|
||||
const stage = policy?.stages.find((candidate) => candidate.type === stageType);
|
||||
return stage?.participants.map((participant) => selectionValueFromPrincipal(participant)) ?? [];
|
||||
}
|
||||
|
||||
function mergeParticipants(
|
||||
existing: IssueExecutionStageParticipant[] | undefined,
|
||||
values: string[],
|
||||
): IssueExecutionStageParticipant[] {
|
||||
const existingByKey = new Map((existing ?? []).map((participant) => [principalKey(participant), participant]));
|
||||
const participants: IssueExecutionStageParticipant[] = [];
|
||||
for (const value of values) {
|
||||
const principal = principalFromSelectionValue(value);
|
||||
if (!principal) continue;
|
||||
const key = principalKey(principal);
|
||||
const previous = existingByKey.get(key);
|
||||
participants.push({
|
||||
id: previous?.id ?? newId(),
|
||||
type: principal.type,
|
||||
agentId: principal.type === "agent" ? principal.agentId ?? null : null,
|
||||
userId: principal.type === "user" ? principal.userId ?? null : null,
|
||||
});
|
||||
}
|
||||
return participants;
|
||||
}
|
||||
|
||||
export function buildExecutionPolicy(input: {
|
||||
existingPolicy?: IssueExecutionPolicy | null;
|
||||
reviewerValues: string[];
|
||||
approverValues: string[];
|
||||
}): IssueExecutionPolicy | null {
|
||||
const mode = input.existingPolicy?.mode ?? "normal";
|
||||
const stages = [];
|
||||
|
||||
const existingReviewStage = input.existingPolicy?.stages.find((stage) => stage.type === "review");
|
||||
const reviewParticipants = mergeParticipants(existingReviewStage?.participants, input.reviewerValues);
|
||||
if (reviewParticipants.length > 0) {
|
||||
stages.push({
|
||||
id: existingReviewStage?.id ?? newId(),
|
||||
type: "review" as const,
|
||||
approvalsNeeded: 1,
|
||||
participants: reviewParticipants,
|
||||
});
|
||||
}
|
||||
|
||||
const existingApprovalStage = input.existingPolicy?.stages.find((stage) => stage.type === "approval");
|
||||
const approvalParticipants = mergeParticipants(existingApprovalStage?.participants, input.approverValues);
|
||||
if (approvalParticipants.length > 0) {
|
||||
stages.push({
|
||||
id: existingApprovalStage?.id ?? newId(),
|
||||
type: "approval" as const,
|
||||
approvalsNeeded: 1,
|
||||
participants: approvalParticipants,
|
||||
});
|
||||
}
|
||||
|
||||
if (stages.length === 0) return null;
|
||||
|
||||
return {
|
||||
mode,
|
||||
commentRequired: true,
|
||||
stages,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue