forked from farhoodlabs/paperclip
Add issue review policy and comment retry
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -37,6 +37,9 @@ export const heartbeatRuns = pgTable(
|
||||
onDelete: "set null",
|
||||
}),
|
||||
processLossRetryCount: integer("process_loss_retry_count").notNull().default(0),
|
||||
issueCommentStatus: text("issue_comment_status").notNull().default("not_applicable"),
|
||||
issueCommentSatisfiedByCommentId: uuid("issue_comment_satisfied_by_comment_id"),
|
||||
issueCommentRetryQueuedAt: timestamp("issue_comment_retry_queued_at", { withTimezone: true }),
|
||||
contextSnapshot: jsonb("context_snapshot").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -32,6 +32,7 @@ export { labels } from "./labels.js";
|
||||
export { issueLabels } from "./issue_labels.js";
|
||||
export { issueApprovals } from "./issue_approvals.js";
|
||||
export { issueComments } from "./issue_comments.js";
|
||||
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
|
||||
export { issueInboxArchives } from "./issue_inbox_archives.js";
|
||||
export { feedbackVotes } from "./feedback_votes.js";
|
||||
export { feedbackExports } from "./feedback_exports.js";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
|
||||
export const issueExecutionDecisions = pgTable(
|
||||
"issue_execution_decisions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
stageId: uuid("stage_id").notNull(),
|
||||
stageType: text("stage_type").notNull(),
|
||||
actorAgentId: uuid("actor_agent_id").references(() => agents.id),
|
||||
actorUserId: text("actor_user_id"),
|
||||
outcome: text("outcome").notNull(),
|
||||
body: text("body").notNull(),
|
||||
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIssueIdx: index("issue_execution_decisions_company_issue_idx").on(table.companyId, table.issueId),
|
||||
stageIdx: index("issue_execution_decisions_stage_idx").on(table.issueId, table.stageId, table.createdAt),
|
||||
}),
|
||||
);
|
||||
@@ -47,6 +47,8 @@ export const issues = pgTable(
|
||||
requestDepth: integer("request_depth").notNull().default(0),
|
||||
billingCode: text("billing_code"),
|
||||
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
||||
executionPolicy: jsonb("execution_policy").$type<Record<string, unknown>>(),
|
||||
executionState: jsonb("execution_state").$type<Record<string, unknown>>(),
|
||||
executionWorkspaceId: uuid("execution_workspace_id")
|
||||
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
|
||||
executionWorkspacePreference: text("execution_workspace_preference"),
|
||||
|
||||
@@ -138,6 +138,18 @@ export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
|
||||
export const ISSUE_RELATION_TYPES = ["blocks"] as const;
|
||||
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
|
||||
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_STAGE_TYPES = ["review", "approval"] as const;
|
||||
export type IssueExecutionStageType = (typeof ISSUE_EXECUTION_STAGE_TYPES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_STATE_STATUSES = ["idle", "pending", "changes_requested", "completed"] as const;
|
||||
export type IssueExecutionStateStatus = (typeof ISSUE_EXECUTION_STATE_STATUSES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_DECISION_OUTCOMES = ["approved", "changes_requested"] as const;
|
||||
export type IssueExecutionDecisionOutcome = (typeof ISSUE_EXECUTION_DECISION_OUTCOMES)[number];
|
||||
|
||||
export const GOAL_LEVELS = ["company", "team", "agent", "task"] as const;
|
||||
export type GoalLevel = (typeof GOAL_LEVELS)[number];
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export {
|
||||
ISSUE_PRIORITIES,
|
||||
ISSUE_ORIGIN_KINDS,
|
||||
ISSUE_RELATION_TYPES,
|
||||
ISSUE_EXECUTION_POLICY_MODES,
|
||||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
ISSUE_EXECUTION_STATE_STATUSES,
|
||||
ISSUE_EXECUTION_DECISION_OUTCOMES,
|
||||
GOAL_LEVELS,
|
||||
GOAL_STATUSES,
|
||||
PROJECT_STATUSES,
|
||||
@@ -84,6 +88,10 @@ export {
|
||||
type IssuePriority,
|
||||
type IssueOriginKind,
|
||||
type IssueRelationType,
|
||||
type IssueExecutionPolicyMode,
|
||||
type IssueExecutionStageType,
|
||||
type IssueExecutionStateStatus,
|
||||
type IssueExecutionDecisionOutcome,
|
||||
type GoalLevel,
|
||||
type GoalStatus,
|
||||
type ProjectStatus,
|
||||
@@ -233,6 +241,12 @@ export type {
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueRelation,
|
||||
IssueRelationIssueSummary,
|
||||
IssueExecutionPolicy,
|
||||
IssueExecutionState,
|
||||
IssueExecutionStage,
|
||||
IssueExecutionStageParticipant,
|
||||
IssueExecutionStagePrincipal,
|
||||
IssueExecutionDecision,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueDocumentSummary,
|
||||
@@ -425,6 +439,8 @@ export {
|
||||
createIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
updateIssueSchema,
|
||||
issueExecutionPolicySchema,
|
||||
issueExecutionStateSchema,
|
||||
issueExecutionWorkspaceSettingsSchema,
|
||||
checkoutIssueSchema,
|
||||
addIssueCommentSchema,
|
||||
|
||||
@@ -98,6 +98,12 @@ export type {
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueRelation,
|
||||
IssueRelationIssueSummary,
|
||||
IssueExecutionPolicy,
|
||||
IssueExecutionState,
|
||||
IssueExecutionStage,
|
||||
IssueExecutionStageParticipant,
|
||||
IssueExecutionStagePrincipal,
|
||||
IssueExecutionDecision,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueDocumentSummary,
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { IssueOriginKind, IssuePriority, IssueStatus } from "../constants.js";
|
||||
import type {
|
||||
IssueExecutionDecisionOutcome,
|
||||
IssueExecutionPolicyMode,
|
||||
IssueExecutionStageType,
|
||||
IssueExecutionStateStatus,
|
||||
IssueOriginKind,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
} from "../constants.js";
|
||||
import type { Goal } from "./goal.js";
|
||||
import type { Project, ProjectWorkspace } from "./project.js";
|
||||
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
|
||||
@@ -115,6 +123,56 @@ export interface IssueRelation {
|
||||
relatedIssue: IssueRelationIssueSummary;
|
||||
}
|
||||
|
||||
export interface IssueExecutionStagePrincipal {
|
||||
type: "agent" | "user";
|
||||
agentId?: string | null;
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
export interface IssueExecutionStageParticipant extends IssueExecutionStagePrincipal {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface IssueExecutionStage {
|
||||
id: string;
|
||||
type: IssueExecutionStageType;
|
||||
approvalsNeeded: number;
|
||||
participants: IssueExecutionStageParticipant[];
|
||||
}
|
||||
|
||||
export interface IssueExecutionPolicy {
|
||||
mode: IssueExecutionPolicyMode;
|
||||
commentRequired: boolean;
|
||||
stages: IssueExecutionStage[];
|
||||
}
|
||||
|
||||
export interface IssueExecutionState {
|
||||
status: IssueExecutionStateStatus;
|
||||
currentStageId: string | null;
|
||||
currentStageIndex: number | null;
|
||||
currentStageType: IssueExecutionStageType | null;
|
||||
currentParticipant: IssueExecutionStagePrincipal | null;
|
||||
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||
completedStageIds: string[];
|
||||
lastDecisionId: string | null;
|
||||
lastDecisionOutcome: IssueExecutionDecisionOutcome | null;
|
||||
}
|
||||
|
||||
export interface IssueExecutionDecision {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
stageId: string;
|
||||
stageType: IssueExecutionStageType;
|
||||
actorAgentId: string | null;
|
||||
actorUserId: string | null;
|
||||
outcome: IssueExecutionDecisionOutcome;
|
||||
body: string;
|
||||
createdByRunId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -143,6 +201,8 @@ export interface Issue {
|
||||
requestDepth: number;
|
||||
billingCode: string | null;
|
||||
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
||||
executionPolicy?: IssueExecutionPolicy | null;
|
||||
executionState?: IssueExecutionState | null;
|
||||
executionWorkspaceId: string | null;
|
||||
executionWorkspacePreference: string | null;
|
||||
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
||||
|
||||
@@ -131,6 +131,8 @@ export {
|
||||
createIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
updateIssueSchema,
|
||||
issueExecutionPolicySchema,
|
||||
issueExecutionStateSchema,
|
||||
issueExecutionWorkspaceSettingsSchema,
|
||||
checkoutIssueSchema,
|
||||
addIssueCommentSchema,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
|
||||
import {
|
||||
ISSUE_EXECUTION_DECISION_OUTCOMES,
|
||||
ISSUE_EXECUTION_POLICY_MODES,
|
||||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
ISSUE_EXECUTION_STATE_STATUSES,
|
||||
ISSUE_PRIORITIES,
|
||||
ISSUE_STATUSES,
|
||||
} from "../constants.js";
|
||||
|
||||
export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [
|
||||
"inherit",
|
||||
@@ -36,6 +43,76 @@ export const issueAssigneeAdapterOverridesSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const issueExecutionStagePrincipalBaseSchema = z.object({
|
||||
type: z.enum(["agent", "user"]),
|
||||
agentId: z.string().uuid().optional().nullable(),
|
||||
userId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const issueExecutionStagePrincipalSchema = issueExecutionStagePrincipalBaseSchema
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.type === "agent") {
|
||||
if (!value.agentId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants require agentId", path: ["agentId"] });
|
||||
}
|
||||
if (value.userId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants cannot set userId", path: ["userId"] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!value.userId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants require userId", path: ["userId"] });
|
||||
}
|
||||
if (value.agentId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants cannot set agentId", path: ["agentId"] });
|
||||
}
|
||||
});
|
||||
|
||||
export const issueExecutionStageParticipantSchema = issueExecutionStagePrincipalBaseSchema.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.type === "agent") {
|
||||
if (!value.agentId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants require agentId", path: ["agentId"] });
|
||||
}
|
||||
if (value.userId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants cannot set userId", path: ["userId"] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!value.userId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants require userId", path: ["userId"] });
|
||||
}
|
||||
if (value.agentId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants cannot set agentId", path: ["agentId"] });
|
||||
}
|
||||
});
|
||||
|
||||
export const issueExecutionStageSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
type: z.enum(ISSUE_EXECUTION_STAGE_TYPES),
|
||||
approvalsNeeded: z.number().int().positive().optional().default(1),
|
||||
participants: z.array(issueExecutionStageParticipantSchema).default([]),
|
||||
});
|
||||
|
||||
export const issueExecutionPolicySchema = z.object({
|
||||
mode: z.enum(ISSUE_EXECUTION_POLICY_MODES).optional().default("normal"),
|
||||
commentRequired: z.boolean().optional().default(true),
|
||||
stages: z.array(issueExecutionStageSchema).default([]),
|
||||
});
|
||||
|
||||
export const issueExecutionStateSchema = z.object({
|
||||
status: z.enum(ISSUE_EXECUTION_STATE_STATUSES),
|
||||
currentStageId: z.string().uuid().nullable(),
|
||||
currentStageIndex: z.number().int().nonnegative().nullable(),
|
||||
currentStageType: z.enum(ISSUE_EXECUTION_STAGE_TYPES).nullable(),
|
||||
currentParticipant: issueExecutionStagePrincipalSchema.nullable(),
|
||||
returnAssignee: issueExecutionStagePrincipalSchema.nullable(),
|
||||
completedStageIds: z.array(z.string().uuid()).default([]),
|
||||
lastDecisionId: z.string().uuid().nullable(),
|
||||
lastDecisionOutcome: z.enum(ISSUE_EXECUTION_DECISION_OUTCOMES).nullable(),
|
||||
});
|
||||
|
||||
export const createIssueSchema = z.object({
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
projectWorkspaceId: z.string().uuid().optional().nullable(),
|
||||
@@ -52,6 +129,7 @@ export const createIssueSchema = z.object({
|
||||
requestDepth: z.number().int().nonnegative().optional().default(0),
|
||||
billingCode: z.string().optional().nullable(),
|
||||
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
|
||||
executionPolicy: issueExecutionPolicySchema.optional().nullable(),
|
||||
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
||||
executionWorkspacePreference: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional().nullable(),
|
||||
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -307,6 +307,14 @@ describe("heartbeat comment wake batching", () => {
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: agentId,
|
||||
createdByRunId: firstRun?.id ?? null,
|
||||
body: "Heartbeat acknowledged",
|
||||
});
|
||||
|
||||
const comment2 = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
@@ -415,4 +423,114 @@ describe("heartbeat comment wake batching", () => {
|
||||
await gateway.close();
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Gateway Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2_000,
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Require a comment",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
|
||||
const firstRun = await heartbeat.wakeup(agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: { issueId },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
});
|
||||
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||
gateway.releaseFirstWait();
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId))
|
||||
.orderBy(asc(heartbeatRuns.createdAt));
|
||||
return runs.length === 2 && runs.every((run) => run.status === "succeeded");
|
||||
});
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId))
|
||||
.orderBy(asc(heartbeatRuns.createdAt));
|
||||
|
||||
expect(runs).toHaveLength(2);
|
||||
expect(runs[0]?.issueCommentStatus).toBe("retry_queued");
|
||||
expect(runs[1]?.retryOfRunId).toBe(runs[0]?.id);
|
||||
expect(runs[1]?.issueCommentStatus).toBe("retry_exhausted");
|
||||
|
||||
const comments = await db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, issueId));
|
||||
expect(comments).toHaveLength(0);
|
||||
|
||||
await waitFor(async () => {
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
|
||||
return wakeups.length >= 2;
|
||||
});
|
||||
|
||||
const payloads = gateway.getAgentPayloads();
|
||||
expect(payloads).toHaveLength(2);
|
||||
expect(runs[1]?.contextSnapshot).toMatchObject({
|
||||
retryReason: "missing_issue_comment",
|
||||
});
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
|
||||
describe("issue execution policy transitions", () => {
|
||||
const coderAgentId = "11111111-1111-4111-8111-111111111111";
|
||||
const qaAgentId = "22222222-2222-4222-8222-222222222222";
|
||||
const ctoUserId = "cto-user";
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: qaAgentId }],
|
||||
},
|
||||
{
|
||||
type: "approval",
|
||||
participants: [{ type: "user", userId: ctoUserId }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("routes executor completion into review", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Implemented the feature",
|
||||
});
|
||||
|
||||
expect(result.patch.status).toBe("in_review");
|
||||
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "pending",
|
||||
currentStageType: "review",
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
});
|
||||
expect(result.decision).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns review changes to the prior executor", () => {
|
||||
const reviewStageId = policy?.stages[0]?.id ?? "review-stage";
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "in_progress",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: qaAgentId },
|
||||
commentBody: "Needs another pass on edge cases",
|
||||
});
|
||||
|
||||
expect(result.patch.status).toBe("in_progress");
|
||||
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "changes_requested",
|
||||
currentStageType: "review",
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
});
|
||||
expect(result.decision).toMatchObject({
|
||||
stageId: reviewStageId,
|
||||
stageType: "review",
|
||||
outcome: "changes_requested",
|
||||
});
|
||||
});
|
||||
|
||||
it("advances approved review work into approval", () => {
|
||||
const reviewStageId = policy?.stages[0]?.id ?? "review-stage";
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: qaAgentId },
|
||||
commentBody: "QA signoff complete",
|
||||
});
|
||||
|
||||
expect(result.patch.status).toBe("in_review");
|
||||
expect(result.patch.assigneeAgentId).toBeNull();
|
||||
expect(result.patch.assigneeUserId).toBe(ctoUserId);
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "pending",
|
||||
currentStageType: "approval",
|
||||
completedStageIds: [reviewStageId],
|
||||
currentParticipant: { type: "user", userId: ctoUserId },
|
||||
});
|
||||
expect(result.decision).toMatchObject({
|
||||
stageId: reviewStageId,
|
||||
stageType: "review",
|
||||
outcome: "approved",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { issueExecutionDecisions } from "@paperclipai/db";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
SVG_CONTENT_TYPE,
|
||||
} from "../attachment-types.js";
|
||||
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
|
||||
|
||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||
const updateIssueRouteSchema = updateIssueSchema.extend({
|
||||
@@ -1065,6 +1067,7 @@ export function issueRoutes(
|
||||
const actor = getActorInfo(req);
|
||||
const issue = await svc.create(companyId, {
|
||||
...req.body,
|
||||
executionPolicy: normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
@@ -1184,6 +1187,31 @@ export function issueRoutes(
|
||||
if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
|
||||
updateFields.status = "todo";
|
||||
}
|
||||
if (req.body.executionPolicy !== undefined) {
|
||||
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||
}
|
||||
|
||||
const transition = applyIssueExecutionPolicyTransition({
|
||||
issue: existing,
|
||||
policy:
|
||||
updateFields.executionPolicy !== undefined
|
||||
? (updateFields.executionPolicy as NonNullable<typeof updateFields.executionPolicy> | null)
|
||||
: normalizeIssueExecutionPolicy(existing.executionPolicy ?? null),
|
||||
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
|
||||
requestedAssigneePatch: {
|
||||
assigneeAgentId:
|
||||
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
|
||||
assigneeUserId:
|
||||
req.body.assigneeUserId === undefined ? undefined : (req.body.assigneeUserId as string | null),
|
||||
},
|
||||
actor: {
|
||||
agentId: actor.agentId ?? null,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
},
|
||||
commentBody,
|
||||
});
|
||||
Object.assign(updateFields, transition.patch);
|
||||
|
||||
let issue;
|
||||
try {
|
||||
issue = await svc.update(id, {
|
||||
@@ -1338,7 +1366,22 @@ export function issueRoutes(
|
||||
|
||||
}
|
||||
|
||||
const assigneeChanged = assigneeWillChange;
|
||||
if (transition.decision) {
|
||||
await db.insert(issueExecutionDecisions).values({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
stageId: transition.decision.stageId,
|
||||
stageType: transition.decision.stageType,
|
||||
actorAgentId: actor.agentId ?? null,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
outcome: transition.decision.outcome,
|
||||
body: transition.decision.body,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const assigneeChanged =
|
||||
issue.assigneeAgentId !== existing.assigneeAgentId || issue.assigneeUserId !== existing.assigneeUserId;
|
||||
const statusChangedFromBacklog =
|
||||
existing.status === "backlog" &&
|
||||
issue.status !== "backlog" &&
|
||||
|
||||
@@ -1835,6 +1835,210 @@ export function heartbeatService(db: Db) {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function patchRunIssueCommentStatus(
|
||||
runId: string,
|
||||
patch: Partial<Pick<typeof heartbeatRuns.$inferInsert, "issueCommentStatus" | "issueCommentSatisfiedByCommentId" | "issueCommentRetryQueuedAt">>,
|
||||
) {
|
||||
return db
|
||||
.update(heartbeatRuns)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function findRunIssueComment(runId: string, companyId: string, issueId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
eq(issueComments.issueId, issueId),
|
||||
eq(issueComments.createdByRunId, runId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function enqueueMissingIssueCommentRetry(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
issueId: string,
|
||||
) {
|
||||
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
|
||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
||||
const retryContextSnapshot = {
|
||||
...contextSnapshot,
|
||||
retryOfRunId: run.id,
|
||||
wakeReason: "missing_issue_comment",
|
||||
retryReason: "missing_issue_comment",
|
||||
missingIssueCommentForRunId: run.id,
|
||||
};
|
||||
const now = new Date();
|
||||
|
||||
const retryRun = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`,
|
||||
);
|
||||
|
||||
const issue = await tx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue) return null;
|
||||
|
||||
const wakeupRequest = await tx
|
||||
.insert(agentWakeupRequests)
|
||||
.values({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "missing_issue_comment",
|
||||
payload: {
|
||||
issueId,
|
||||
retryOfRunId: run.id,
|
||||
retryReason: "missing_issue_comment",
|
||||
},
|
||||
status: "queued",
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const queuedRun = await tx
|
||||
.insert(heartbeatRuns)
|
||||
.values({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
wakeupRequestId: wakeupRequest.id,
|
||||
contextSnapshot: retryContextSnapshot,
|
||||
sessionIdBefore: sessionBefore,
|
||||
retryOfRunId: run.id,
|
||||
issueCommentStatus: "not_applicable",
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
await tx
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
runId: queuedRun.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequest.id));
|
||||
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: queuedRun.id,
|
||||
executionAgentNameKey: normalizeAgentNameKey(agent.name),
|
||||
executionLockedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(issues.id, issue.id));
|
||||
|
||||
await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
issueCommentStatus: "retry_queued",
|
||||
issueCommentRetryQueuedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
|
||||
return queuedRun;
|
||||
});
|
||||
|
||||
if (!retryRun) return null;
|
||||
|
||||
publishLiveEvent({
|
||||
companyId: retryRun.companyId,
|
||||
type: "heartbeat.run.queued",
|
||||
payload: {
|
||||
runId: retryRun.id,
|
||||
agentId: retryRun.agentId,
|
||||
invocationSource: retryRun.invocationSource,
|
||||
triggerDetail: retryRun.triggerDetail,
|
||||
wakeupRequestId: retryRun.wakeupRequestId,
|
||||
},
|
||||
});
|
||||
|
||||
return retryRun;
|
||||
}
|
||||
|
||||
async function finalizeIssueCommentPolicy(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
) {
|
||||
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||
const issueId = readNonEmptyString(contextSnapshot.issueId);
|
||||
if (!issueId) {
|
||||
if (run.issueCommentStatus !== "not_applicable") {
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "not_applicable",
|
||||
issueCommentSatisfiedByCommentId: null,
|
||||
issueCommentRetryQueuedAt: null,
|
||||
});
|
||||
}
|
||||
return { outcome: "not_applicable" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
const postedComment = await findRunIssueComment(run.id, run.companyId, issueId);
|
||||
if (postedComment) {
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "satisfied",
|
||||
issueCommentSatisfiedByCommentId: postedComment.id,
|
||||
issueCommentRetryQueuedAt: null,
|
||||
});
|
||||
return { outcome: "satisfied" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
if (readNonEmptyString(contextSnapshot.retryReason) === "missing_issue_comment") {
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "retry_exhausted",
|
||||
issueCommentSatisfiedByCommentId: null,
|
||||
});
|
||||
await appendRunEvent(run, await nextRunEventSeq(run.id), {
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
message: "Run ended without an issue comment after one retry; no further comment wake will be queued",
|
||||
});
|
||||
return { outcome: "retry_exhausted" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
const queuedRun = await enqueueMissingIssueCommentRetry(run, agent, issueId);
|
||||
if (queuedRun) {
|
||||
await appendRunEvent(run, await nextRunEventSeq(run.id), {
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
message: "Run ended without an issue comment; queued one follow-up wake to require a comment",
|
||||
});
|
||||
return { outcome: "retry_queued" as const, queuedRun };
|
||||
}
|
||||
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "retry_exhausted",
|
||||
issueCommentSatisfiedByCommentId: null,
|
||||
});
|
||||
return { outcome: "retry_exhausted" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
async function enqueueProcessLossRetry(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
@@ -3085,7 +3289,7 @@ export function heartbeatService(db: Db) {
|
||||
try {
|
||||
const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null);
|
||||
if (issueComment) {
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id });
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
|
||||
}
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
@@ -3094,6 +3298,7 @@ export function heartbeatService(db: Db) {
|
||||
);
|
||||
}
|
||||
}
|
||||
await finalizeIssueCommentPolicy(finalizedRun, agent);
|
||||
await releaseIssueExecutionAndPromote(finalizedRun);
|
||||
}
|
||||
|
||||
@@ -3160,6 +3365,7 @@ export function heartbeatService(db: Db) {
|
||||
level: "error",
|
||||
message,
|
||||
});
|
||||
await finalizeIssueCommentPolicy(failedRun, agent);
|
||||
await releaseIssueExecutionAndPromote(failedRun);
|
||||
|
||||
await updateRuntimeState(agent, failedRun, {
|
||||
@@ -3211,6 +3417,10 @@ export function heartbeatService(db: Db) {
|
||||
level: "error",
|
||||
message,
|
||||
}).catch(() => undefined);
|
||||
const failedAgent = await getAgent(run.agentId).catch(() => null);
|
||||
if (failedAgent) {
|
||||
await finalizeIssueCommentPolicy(failedRun, failedAgent).catch(() => undefined);
|
||||
}
|
||||
await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined);
|
||||
}
|
||||
// Ensure the agent is not left stuck in "running" if the inner catch handler's
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IssueExecutionDecision, IssueExecutionPolicy, IssueExecutionStage, IssueExecutionStagePrincipal, IssueExecutionState } from "@paperclipai/shared";
|
||||
import { issueExecutionPolicySchema, issueExecutionStateSchema } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
type AssigneeLike = {
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
};
|
||||
|
||||
type IssueLike = AssigneeLike & {
|
||||
status: string;
|
||||
executionPolicy?: IssueExecutionPolicy | Record<string, unknown> | null;
|
||||
executionState?: IssueExecutionState | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type ActorLike = {
|
||||
agentId?: string | null;
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
type RequestedAssigneePatch = {
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
};
|
||||
|
||||
type TransitionInput = {
|
||||
issue: IssueLike;
|
||||
policy: IssueExecutionPolicy | null;
|
||||
requestedStatus?: string;
|
||||
requestedAssigneePatch: RequestedAssigneePatch;
|
||||
actor: ActorLike;
|
||||
commentBody?: string | null;
|
||||
};
|
||||
|
||||
type TransitionResult = {
|
||||
patch: Record<string, unknown>;
|
||||
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
|
||||
};
|
||||
|
||||
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
|
||||
const IDLE_STATUS: IssueExecutionState["status"] = "idle";
|
||||
const PENDING_STATUS: IssueExecutionState["status"] = "pending";
|
||||
const CHANGES_REQUESTED_STATUS: IssueExecutionState["status"] = "changes_requested";
|
||||
|
||||
export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPolicy | null {
|
||||
if (input == null) return null;
|
||||
const parsed = issueExecutionPolicySchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw unprocessable("Invalid execution policy", parsed.error.flatten());
|
||||
}
|
||||
|
||||
const stages = parsed.data.stages
|
||||
.map((stage) => {
|
||||
const participants: IssueExecutionStage["participants"] = stage.participants
|
||||
.map((participant) => ({
|
||||
id: participant.id ?? randomUUID(),
|
||||
type: participant.type,
|
||||
agentId: participant.type === "agent" ? participant.agentId ?? null : null,
|
||||
userId: participant.type === "user" ? participant.userId ?? null : null,
|
||||
}))
|
||||
.filter((participant) => (participant.type === "agent" ? Boolean(participant.agentId) : Boolean(participant.userId)));
|
||||
|
||||
const dedupedParticipants: IssueExecutionStage["participants"] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const participant of participants) {
|
||||
const key = participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
dedupedParticipants.push(participant);
|
||||
}
|
||||
|
||||
if (dedupedParticipants.length === 0) return null;
|
||||
return {
|
||||
id: stage.id ?? randomUUID(),
|
||||
type: stage.type,
|
||||
approvalsNeeded: 1,
|
||||
participants: dedupedParticipants,
|
||||
};
|
||||
})
|
||||
.filter((stage): stage is NonNullable<typeof stage> => stage !== null);
|
||||
|
||||
if (stages.length === 0) return null;
|
||||
|
||||
return {
|
||||
mode: parsed.data.mode ?? "normal",
|
||||
commentRequired: true,
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIssueExecutionState(input: unknown): IssueExecutionState | null {
|
||||
if (input == null) return null;
|
||||
const parsed = issueExecutionStateSchema.safeParse(input);
|
||||
if (!parsed.success) return null;
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function assigneePrincipal(input: AssigneeLike): IssueExecutionStagePrincipal | null {
|
||||
if (input.assigneeAgentId) {
|
||||
return { type: "agent", agentId: input.assigneeAgentId, userId: null };
|
||||
}
|
||||
if (input.assigneeUserId) {
|
||||
return { type: "user", userId: input.assigneeUserId, agentId: null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function actorPrincipal(actor: ActorLike): IssueExecutionStagePrincipal | null {
|
||||
if (actor.agentId) return { type: "agent", agentId: actor.agentId, userId: null };
|
||||
if (actor.userId) return { type: "user", userId: actor.userId, agentId: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
function principalsEqual(a: IssueExecutionStagePrincipal | null, b: IssueExecutionStagePrincipal | null): boolean {
|
||||
if (!a || !b) return false;
|
||||
if (a.type !== b.type) return false;
|
||||
return a.type === "agent" ? a.agentId === b.agentId : a.userId === b.userId;
|
||||
}
|
||||
|
||||
function findStageById(policy: IssueExecutionPolicy, stageId: string | null | undefined) {
|
||||
if (!stageId) return null;
|
||||
return policy.stages.find((stage) => stage.id === stageId) ?? null;
|
||||
}
|
||||
|
||||
function nextPendingStage(policy: IssueExecutionPolicy, state: IssueExecutionState | null) {
|
||||
const completed = new Set(state?.completedStageIds ?? []);
|
||||
return policy.stages.find((stage) => !completed.has(stage.id)) ?? null;
|
||||
}
|
||||
|
||||
function selectStageParticipant(
|
||||
stage: IssueExecutionStage,
|
||||
opts?: {
|
||||
preferred?: IssueExecutionStagePrincipal | null;
|
||||
exclude?: IssueExecutionStagePrincipal | null;
|
||||
},
|
||||
): IssueExecutionStagePrincipal | null {
|
||||
const participants = stage.participants.filter((participant) => !principalsEqual(participant, opts?.exclude ?? null));
|
||||
if (participants.length === 0) return null;
|
||||
if (opts?.preferred) {
|
||||
const preferred = participants.find((participant) => principalsEqual(participant, opts.preferred ?? null));
|
||||
if (preferred) return preferred;
|
||||
}
|
||||
const first = participants[0];
|
||||
return first ? { type: first.type, agentId: first.agentId ?? null, userId: first.userId ?? null } : null;
|
||||
}
|
||||
|
||||
function patchForPrincipal(principal: IssueExecutionStagePrincipal | null) {
|
||||
if (!principal) {
|
||||
return { assigneeAgentId: null, assigneeUserId: null };
|
||||
}
|
||||
return principal.type === "agent"
|
||||
? { assigneeAgentId: principal.agentId ?? null, assigneeUserId: null }
|
||||
: { assigneeAgentId: null, assigneeUserId: principal.userId ?? null };
|
||||
}
|
||||
|
||||
function buildCompletedState(previous: IssueExecutionState | null, currentStage: IssueExecutionStage): IssueExecutionState {
|
||||
const completedStageIds = Array.from(new Set([...(previous?.completedStageIds ?? []), currentStage.id]));
|
||||
return {
|
||||
status: COMPLETED_STATUS,
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: previous?.returnAssignee ?? null,
|
||||
completedStageIds,
|
||||
lastDecisionId: previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: "approved",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPendingState(input: {
|
||||
previous: IssueExecutionState | null;
|
||||
stage: IssueExecutionStage;
|
||||
stageIndex: number;
|
||||
participant: IssueExecutionStagePrincipal;
|
||||
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||
}): IssueExecutionState {
|
||||
return {
|
||||
status: PENDING_STATUS,
|
||||
currentStageId: input.stage.id,
|
||||
currentStageIndex: input.stageIndex,
|
||||
currentStageType: input.stage.type,
|
||||
currentParticipant: input.participant,
|
||||
returnAssignee: input.returnAssignee,
|
||||
completedStageIds: input.previous?.completedStageIds ?? [],
|
||||
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildChangesRequestedState(previous: IssueExecutionState, currentStage: IssueExecutionStage): IssueExecutionState {
|
||||
return {
|
||||
...previous,
|
||||
status: CHANGES_REQUESTED_STATUS,
|
||||
currentStageId: currentStage.id,
|
||||
currentStageType: currentStage.type,
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
};
|
||||
}
|
||||
|
||||
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentAssignee = assigneePrincipal(input.issue);
|
||||
const actor = actorPrincipal(input.actor);
|
||||
const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch);
|
||||
const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null;
|
||||
const requestedStatus = input.requestedStatus;
|
||||
|
||||
if (!input.policy) {
|
||||
if (existingState) {
|
||||
patch.executionState = null;
|
||||
if (input.issue.status === "in_review" && existingState.returnAssignee) {
|
||||
patch.status = "in_progress";
|
||||
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
|
||||
}
|
||||
}
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (
|
||||
(input.issue.status === "done" || input.issue.status === "cancelled") &&
|
||||
requestedStatus &&
|
||||
requestedStatus !== "done" &&
|
||||
requestedStatus !== "cancelled"
|
||||
) {
|
||||
patch.executionState = null;
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (currentStage && input.issue.status === "in_review") {
|
||||
if (!principalsEqual(existingState?.currentParticipant ?? null, actor)) {
|
||||
if (requestedStatus && requestedStatus !== "in_review") {
|
||||
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
|
||||
}
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (requestedStatus === "done") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Approving a review or approval stage requires a comment");
|
||||
}
|
||||
const approvedState = buildCompletedState(existingState, currentStage);
|
||||
const nextStage = nextPendingStage(
|
||||
input.policy,
|
||||
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
|
||||
);
|
||||
|
||||
if (!nextStage) {
|
||||
patch.executionState = approvedState;
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.type,
|
||||
outcome: "approved",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const participant = selectStageParticipant(nextStage, {
|
||||
preferred: explicitAssignee,
|
||||
exclude: existingState?.returnAssignee ?? null,
|
||||
});
|
||||
if (!participant) {
|
||||
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
|
||||
}
|
||||
|
||||
patch.status = "in_review";
|
||||
Object.assign(patch, patchForPrincipal(participant));
|
||||
patch.executionState = buildPendingState({
|
||||
previous: approvedState,
|
||||
stage: nextStage,
|
||||
stageIndex: input.policy.stages.findIndex((stage) => stage.id === nextStage.id),
|
||||
participant,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.type,
|
||||
outcome: "approved",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedStatus && requestedStatus !== "in_review") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Requesting changes requires a comment");
|
||||
}
|
||||
if (!existingState?.returnAssignee) {
|
||||
throw unprocessable("This execution stage has no return assignee");
|
||||
}
|
||||
patch.status = "in_progress";
|
||||
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
|
||||
patch.executionState = buildChangesRequestedState(existingState, currentStage);
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.type,
|
||||
outcome: "changes_requested",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (requestedStatus !== "done") {
|
||||
return { patch };
|
||||
}
|
||||
|
||||
const pendingStage =
|
||||
existingState?.status === CHANGES_REQUESTED_STATUS && currentStage
|
||||
? currentStage
|
||||
: nextPendingStage(input.policy, existingState);
|
||||
if (!pendingStage) return { patch };
|
||||
|
||||
const returnAssignee = existingState?.returnAssignee ?? currentAssignee;
|
||||
const participant = selectStageParticipant(pendingStage, {
|
||||
preferred:
|
||||
existingState?.status === CHANGES_REQUESTED_STATUS
|
||||
? explicitAssignee ?? existingState.currentParticipant ?? null
|
||||
: explicitAssignee,
|
||||
exclude: returnAssignee,
|
||||
});
|
||||
if (!participant) {
|
||||
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
|
||||
}
|
||||
|
||||
patch.status = "in_review";
|
||||
Object.assign(patch, patchForPrincipal(participant));
|
||||
patch.executionState = buildPendingState({
|
||||
previous: existingState,
|
||||
stage: pendingStage,
|
||||
stageIndex: input.policy.stages.findIndex((stage) => stage.id === pendingStage.id),
|
||||
participant,
|
||||
returnAssignee,
|
||||
});
|
||||
return { patch };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user