mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add blocker relations and dependency wakeups
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
2f73346a64
commit
dde4cc070e
18 changed files with 13924 additions and 69 deletions
|
|
@ -29,4 +29,5 @@ export {
|
||||||
createEmbeddedPostgresLogBuffer,
|
createEmbeddedPostgresLogBuffer,
|
||||||
formatEmbeddedPostgresError,
|
formatEmbeddedPostgresError,
|
||||||
} from "./embedded-postgres-error.js";
|
} from "./embedded-postgres-error.js";
|
||||||
|
export { issueRelations } from "./schema/issue_relations.js";
|
||||||
export * from "./schema/index.js";
|
export * from "./schema/index.js";
|
||||||
|
|
|
||||||
20
packages/db/src/migrations/0049_flawless_abomination.sql
Normal file
20
packages/db/src/migrations/0049_flawless_abomination.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
CREATE TABLE "issue_relations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"issue_id" uuid NOT NULL,
|
||||||
|
"related_issue_id" uuid NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"created_by_agent_id" uuid,
|
||||||
|
"created_by_user_id" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_relations" ADD CONSTRAINT "issue_relations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_relations" ADD CONSTRAINT "issue_relations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_relations" ADD CONSTRAINT "issue_relations_related_issue_id_issues_id_fk" FOREIGN KEY ("related_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_relations" ADD CONSTRAINT "issue_relations_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "issue_relations_company_issue_idx" ON "issue_relations" USING btree ("company_id","issue_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "issue_relations_company_related_issue_idx" ON "issue_relations" USING btree ("company_id","related_issue_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "issue_relations_company_type_idx" ON "issue_relations" USING btree ("company_id","type");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "issue_relations_company_edge_uq" ON "issue_relations" USING btree ("company_id","issue_id","related_issue_id","type");
|
||||||
12766
packages/db/src/migrations/meta/0049_snapshot.json
Normal file
12766
packages/db/src/migrations/meta/0049_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -344,6 +344,13 @@
|
||||||
"when": 1775145655557,
|
"when": 1775145655557,
|
||||||
"tag": "0048_flashy_marrow",
|
"tag": "0048_flashy_marrow",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 49,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775349863293,
|
||||||
|
"tag": "0049_flawless_abomination",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||||
export { projectGoals } from "./project_goals.js";
|
export { projectGoals } from "./project_goals.js";
|
||||||
export { goals } from "./goals.js";
|
export { goals } from "./goals.js";
|
||||||
export { issues } from "./issues.js";
|
export { issues } from "./issues.js";
|
||||||
|
export { issueRelations } from "./issue_relations.js";
|
||||||
export { routines, routineTriggers, routineRuns } from "./routines.js";
|
export { routines, routineTriggers, routineRuns } from "./routines.js";
|
||||||
export { issueWorkProducts } from "./issue_work_products.js";
|
export { issueWorkProducts } from "./issue_work_products.js";
|
||||||
export { labels } from "./labels.js";
|
export { labels } from "./labels.js";
|
||||||
|
|
|
||||||
30
packages/db/src/schema/issue_relations.ts
Normal file
30
packages/db/src/schema/issue_relations.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { agents } from "./agents.js";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { issues } from "./issues.js";
|
||||||
|
|
||||||
|
export const issueRelations = pgTable(
|
||||||
|
"issue_relations",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||||
|
relatedIssueId: uuid("related_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||||
|
type: text("type").notNull(),
|
||||||
|
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
createdByUserId: text("created_by_user_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyIssueIdx: index("issue_relations_company_issue_idx").on(table.companyId, table.issueId),
|
||||||
|
companyRelatedIssueIdx: index("issue_relations_company_related_issue_idx").on(table.companyId, table.relatedIssueId),
|
||||||
|
companyTypeIdx: index("issue_relations_company_type_idx").on(table.companyId, table.type),
|
||||||
|
companyEdgeUq: uniqueIndex("issue_relations_company_edge_uq").on(
|
||||||
|
table.companyId,
|
||||||
|
table.issueId,
|
||||||
|
table.relatedIssueId,
|
||||||
|
table.type,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -135,6 +135,9 @@ export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
|
||||||
export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution"] as const;
|
export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution"] as const;
|
||||||
export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
|
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 GOAL_LEVELS = ["company", "team", "agent", "task"] as const;
|
export const GOAL_LEVELS = ["company", "team", "agent", "task"] as const;
|
||||||
export type GoalLevel = (typeof GOAL_LEVELS)[number];
|
export type GoalLevel = (typeof GOAL_LEVELS)[number];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export {
|
||||||
INBOX_MINE_ISSUE_STATUS_FILTER,
|
INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||||
ISSUE_PRIORITIES,
|
ISSUE_PRIORITIES,
|
||||||
ISSUE_ORIGIN_KINDS,
|
ISSUE_ORIGIN_KINDS,
|
||||||
|
ISSUE_RELATION_TYPES,
|
||||||
GOAL_LEVELS,
|
GOAL_LEVELS,
|
||||||
GOAL_STATUSES,
|
GOAL_STATUSES,
|
||||||
PROJECT_STATUSES,
|
PROJECT_STATUSES,
|
||||||
|
|
@ -82,6 +83,7 @@ export {
|
||||||
type IssueStatus,
|
type IssueStatus,
|
||||||
type IssuePriority,
|
type IssuePriority,
|
||||||
type IssueOriginKind,
|
type IssueOriginKind,
|
||||||
|
type IssueRelationType,
|
||||||
type GoalLevel,
|
type GoalLevel,
|
||||||
type GoalStatus,
|
type GoalStatus,
|
||||||
type ProjectStatus,
|
type ProjectStatus,
|
||||||
|
|
@ -229,6 +231,8 @@ export type {
|
||||||
IssueWorkProductReviewState,
|
IssueWorkProductReviewState,
|
||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
|
IssueRelation,
|
||||||
|
IssueRelationIssueSummary,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
IssueDocument,
|
IssueDocument,
|
||||||
IssueDocumentSummary,
|
IssueDocumentSummary,
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,8 @@ export type {
|
||||||
export type {
|
export type {
|
||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
|
IssueRelation,
|
||||||
|
IssueRelationIssueSummary,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
IssueDocument,
|
IssueDocument,
|
||||||
IssueDocumentSummary,
|
IssueDocumentSummary,
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,25 @@ export interface LegacyPlanDocument {
|
||||||
source: "issue_description";
|
source: "issue_description";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IssueRelationIssueSummary {
|
||||||
|
id: string;
|
||||||
|
identifier: string | null;
|
||||||
|
title: string;
|
||||||
|
status: IssueStatus;
|
||||||
|
priority: IssuePriority;
|
||||||
|
assigneeAgentId: string | null;
|
||||||
|
assigneeUserId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueRelation {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
issueId: string;
|
||||||
|
relatedIssueId: string;
|
||||||
|
type: "blocks";
|
||||||
|
relatedIssue: IssueRelationIssueSummary;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Issue {
|
export interface Issue {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
|
@ -133,6 +152,8 @@ export interface Issue {
|
||||||
hiddenAt: Date | null;
|
hiddenAt: Date | null;
|
||||||
labelIds?: string[];
|
labelIds?: string[];
|
||||||
labels?: IssueLabel[];
|
labels?: IssueLabel[];
|
||||||
|
blockedBy?: IssueRelationIssueSummary[];
|
||||||
|
blocks?: IssueRelationIssueSummary[];
|
||||||
planDocument?: IssueDocument | null;
|
planDocument?: IssueDocument | null;
|
||||||
documentSummaries?: IssueDocumentSummary[];
|
documentSummaries?: IssueDocumentSummary[];
|
||||||
legacyPlanDocument?: LegacyPlanDocument | null;
|
legacyPlanDocument?: LegacyPlanDocument | null;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export const createIssueSchema = z.object({
|
||||||
projectWorkspaceId: z.string().uuid().optional().nullable(),
|
projectWorkspaceId: z.string().uuid().optional().nullable(),
|
||||||
goalId: z.string().uuid().optional().nullable(),
|
goalId: z.string().uuid().optional().nullable(),
|
||||||
parentId: z.string().uuid().optional().nullable(),
|
parentId: z.string().uuid().optional().nullable(),
|
||||||
|
blockedByIssueIds: z.array(z.string().uuid()).optional(),
|
||||||
inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(),
|
inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(),
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -120,9 +120,14 @@ describe("issue comment reopen routes", () => {
|
||||||
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
|
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
"11111111-1111-4111-8111-111111111111",
|
||||||
});
|
expect.objectContaining({
|
||||||
|
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
actorAgentId: null,
|
||||||
|
actorUserId: "local-board",
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
@ -144,10 +149,15 @@ describe("issue comment reopen routes", () => {
|
||||||
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
|
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
"11111111-1111-4111-8111-111111111111",
|
||||||
status: "todo",
|
expect.objectContaining({
|
||||||
});
|
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
status: "todo",
|
||||||
|
actorAgentId: null,
|
||||||
|
actorUserId: "local-board",
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
|
||||||
195
server/src/__tests__/issue-dependency-wakeups-routes.test.ts
Normal file
195
server/src/__tests__/issue-dependency-wakeups-routes.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { issueRoutes } from "../routes/issues.js";
|
||||||
|
|
||||||
|
const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined));
|
||||||
|
const mockIssueService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
getByIdentifier: vi.fn(async () => null),
|
||||||
|
update: vi.fn(),
|
||||||
|
listWakeableBlockedDependents: vi.fn(),
|
||||||
|
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||||
|
findMentionedAgents: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/index.js", () => ({
|
||||||
|
accessService: () => ({
|
||||||
|
canUser: vi.fn(),
|
||||||
|
hasPermission: vi.fn(),
|
||||||
|
}),
|
||||||
|
agentService: () => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
}),
|
||||||
|
documentService: () => ({
|
||||||
|
getIssueDocumentPayload: vi.fn(async () => ({})),
|
||||||
|
}),
|
||||||
|
executionWorkspaceService: () => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
}),
|
||||||
|
goalService: () => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
getDefaultCompanyGoal: vi.fn(),
|
||||||
|
}),
|
||||||
|
heartbeatService: () => ({
|
||||||
|
wakeup: mockWakeup,
|
||||||
|
reportRunActivity: vi.fn(async () => undefined),
|
||||||
|
}),
|
||||||
|
issueApprovalService: () => ({}),
|
||||||
|
issueService: () => mockIssueService,
|
||||||
|
logActivity: vi.fn(async () => undefined),
|
||||||
|
projectService: () => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
listByIds: vi.fn(async () => []),
|
||||||
|
}),
|
||||||
|
routineService: () => ({
|
||||||
|
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||||
|
}),
|
||||||
|
workProductService: () => ({
|
||||||
|
listForIssue: vi.fn(async () => []),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).actor = {
|
||||||
|
type: "board",
|
||||||
|
userId: "local-board",
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api", issueRoutes({} as any, {} as any));
|
||||||
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
res.status(err?.status ?? 500).json({ error: err?.message ?? "Internal server error" });
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("issue dependency wakeups in issue routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||||
|
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wakes dependents when the final blocker transitions to done", async () => {
|
||||||
|
mockIssueService.getById.mockResolvedValue({
|
||||||
|
id: "issue-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
identifier: "PAP-100",
|
||||||
|
title: "Finish blocker",
|
||||||
|
description: null,
|
||||||
|
status: "blocked",
|
||||||
|
priority: "medium",
|
||||||
|
parentId: null,
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
assigneeUserId: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
labels: [],
|
||||||
|
labelIds: [],
|
||||||
|
});
|
||||||
|
mockIssueService.update.mockResolvedValue({
|
||||||
|
id: "issue-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
identifier: "PAP-100",
|
||||||
|
title: "Finish blocker",
|
||||||
|
description: null,
|
||||||
|
status: "done",
|
||||||
|
priority: "medium",
|
||||||
|
parentId: null,
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
assigneeUserId: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
labels: [],
|
||||||
|
labelIds: [],
|
||||||
|
});
|
||||||
|
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "issue-2",
|
||||||
|
assigneeAgentId: "agent-2",
|
||||||
|
blockerIssueIds: ["issue-1", "issue-3"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await request(createApp()).patch("/api/issues/issue-1").send({ status: "done" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockWakeup).toHaveBeenCalledWith(
|
||||||
|
"agent-2",
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: "issue_blockers_resolved",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
issueId: "issue-2",
|
||||||
|
resolvedBlockerIssueId: "issue-1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wakes the parent when all direct children become terminal", async () => {
|
||||||
|
mockIssueService.getById.mockResolvedValue({
|
||||||
|
id: "child-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
identifier: "PAP-101",
|
||||||
|
title: "Last child",
|
||||||
|
description: null,
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "medium",
|
||||||
|
parentId: "parent-1",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
assigneeUserId: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
labels: [],
|
||||||
|
labelIds: [],
|
||||||
|
});
|
||||||
|
mockIssueService.update.mockResolvedValue({
|
||||||
|
id: "child-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
identifier: "PAP-101",
|
||||||
|
title: "Last child",
|
||||||
|
description: null,
|
||||||
|
status: "done",
|
||||||
|
priority: "medium",
|
||||||
|
parentId: "parent-1",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
assigneeUserId: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
labels: [],
|
||||||
|
labelIds: [],
|
||||||
|
});
|
||||||
|
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue({
|
||||||
|
id: "parent-1",
|
||||||
|
assigneeAgentId: "agent-9",
|
||||||
|
childIssueIds: ["child-0", "child-1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(createApp()).patch("/api/issues/child-1").send({ status: "done" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockWakeup).toHaveBeenCalledWith(
|
||||||
|
"agent-9",
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: "issue_children_completed",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
issueId: "parent-1",
|
||||||
|
completedChildIssueId: "child-1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,6 +7,7 @@ import { errorHandler } from "../middleware/index.js";
|
||||||
const mockIssueService = vi.hoisted(() => ({
|
const mockIssueService = vi.hoisted(() => ({
|
||||||
getById: vi.fn(),
|
getById: vi.fn(),
|
||||||
getAncestors: vi.fn(),
|
getAncestors: vi.fn(),
|
||||||
|
getRelationSummaries: vi.fn(),
|
||||||
findMentionedProjectIds: vi.fn(),
|
findMentionedProjectIds: vi.fn(),
|
||||||
getCommentCursor: vi.fn(),
|
getCommentCursor: vi.fn(),
|
||||||
getComment: vi.fn(),
|
getComment: vi.fn(),
|
||||||
|
|
@ -123,6 +124,7 @@ describe("issue goal context routes", () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
|
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
|
||||||
mockIssueService.getAncestors.mockResolvedValue([]);
|
mockIssueService.getAncestors.mockResolvedValue([]);
|
||||||
|
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||||
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
|
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
|
||||||
mockIssueService.getCommentCursor.mockResolvedValue({
|
mockIssueService.getCommentCursor.mockResolvedValue({
|
||||||
totalComments: 0,
|
totalComments: 0,
|
||||||
|
|
@ -201,4 +203,33 @@ describe("issue goal context routes", () => {
|
||||||
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
||||||
expect(res.body.attachments).toEqual([]);
|
expect(res.body.attachments).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces blocker summaries on GET /issues/:id/heartbeat-context", async () => {
|
||||||
|
mockIssueService.getRelationSummaries.mockResolvedValue({
|
||||||
|
blockedBy: [
|
||||||
|
{
|
||||||
|
id: "55555555-5555-4555-8555-555555555555",
|
||||||
|
identifier: "PAP-580",
|
||||||
|
title: "Finish wakeup plumbing",
|
||||||
|
status: "done",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
blocks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(createApp()).get(
|
||||||
|
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.issue.blockedBy).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "55555555-5555-4555-8555-555555555555",
|
||||||
|
identifier: "PAP-580",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
agents,
|
agents,
|
||||||
|
|
@ -10,6 +11,7 @@ import {
|
||||||
instanceSettings,
|
instanceSettings,
|
||||||
issueComments,
|
issueComments,
|
||||||
issueInboxArchives,
|
issueInboxArchives,
|
||||||
|
issueRelations,
|
||||||
issues,
|
issues,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
|
|
@ -24,6 +26,22 @@ import { issueService } from "../services/issues.ts";
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
|
|
||||||
|
async function ensureIssueRelationsTable(db: ReturnType<typeof createDb>) {
|
||||||
|
await db.execute(sql.raw(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "issue_relations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"issue_id" uuid NOT NULL,
|
||||||
|
"related_issue_id" uuid NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"created_by_agent_id" uuid,
|
||||||
|
"created_by_user_id" text,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
if (!embeddedPostgresSupport.supported) {
|
if (!embeddedPostgresSupport.supported) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
`Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||||
|
|
@ -39,10 +57,12 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
|
||||||
db = createDb(tempDb.connectionString);
|
db = createDb(tempDb.connectionString);
|
||||||
svc = issueService(db);
|
svc = issueService(db);
|
||||||
|
await ensureIssueRelationsTable(db);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.delete(issueComments);
|
await db.delete(issueComments);
|
||||||
|
await db.delete(issueRelations);
|
||||||
await db.delete(issueInboxArchives);
|
await db.delete(issueInboxArchives);
|
||||||
await db.delete(activityLog);
|
await db.delete(activityLog);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
|
|
@ -594,10 +614,12 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
|
||||||
db = createDb(tempDb.connectionString);
|
db = createDb(tempDb.connectionString);
|
||||||
svc = issueService(db);
|
svc = issueService(db);
|
||||||
|
await ensureIssueRelationsTable(db);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.delete(issueComments);
|
await db.delete(issueComments);
|
||||||
|
await db.delete(issueRelations);
|
||||||
await db.delete(issueInboxArchives);
|
await db.delete(issueInboxArchives);
|
||||||
await db.delete(activityLog);
|
await db.delete(activityLog);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
|
|
@ -859,3 +881,210 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let svc!: ReturnType<typeof issueService>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-blockers-");
|
||||||
|
db = createDb(tempDb.connectionString);
|
||||||
|
svc = issueService(db);
|
||||||
|
await ensureIssueRelationsTable(db);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(issueComments);
|
||||||
|
await db.delete(issueRelations);
|
||||||
|
await db.delete(issueInboxArchives);
|
||||||
|
await db.delete(activityLog);
|
||||||
|
await db.delete(issues);
|
||||||
|
await db.delete(executionWorkspaces);
|
||||||
|
await db.delete(projectWorkspaces);
|
||||||
|
await db.delete(projects);
|
||||||
|
await db.delete(agents);
|
||||||
|
await db.delete(instanceSettings);
|
||||||
|
await db.delete(companies);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await tempDb?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blockerId = randomUUID();
|
||||||
|
const blockedId = randomUUID();
|
||||||
|
await db.insert(issues).values([
|
||||||
|
{
|
||||||
|
id: blockerId,
|
||||||
|
companyId,
|
||||||
|
title: "Blocker",
|
||||||
|
status: "todo",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: blockedId,
|
||||||
|
companyId,
|
||||||
|
title: "Blocked issue",
|
||||||
|
status: "blocked",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await svc.update(blockedId, {
|
||||||
|
blockedByIssueIds: [blockerId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const blockerRelations = await svc.getRelationSummaries(blockerId);
|
||||||
|
const blockedRelations = await svc.getRelationSummaries(blockedId);
|
||||||
|
|
||||||
|
expect(blockerRelations.blocks.map((relation) => relation.id)).toEqual([blockedId]);
|
||||||
|
expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects blocking cycles", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issueA = randomUUID();
|
||||||
|
const issueB = randomUUID();
|
||||||
|
await db.insert(issues).values([
|
||||||
|
{ id: issueA, companyId, title: "Issue A", status: "todo", priority: "medium" },
|
||||||
|
{ id: issueB, companyId, title: "Issue B", status: "todo", priority: "medium" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await svc.update(issueA, { blockedByIssueIds: [issueB] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.update(issueB, { blockedByIssueIds: [issueA] }),
|
||||||
|
).rejects.toMatchObject({ status: 422 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only returns dependents once every blocker is done", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const assigneeAgentId = randomUUID();
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
await db.insert(agents).values({
|
||||||
|
id: assigneeAgentId,
|
||||||
|
companyId,
|
||||||
|
name: "CodexCoder",
|
||||||
|
role: "engineer",
|
||||||
|
status: "active",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const blockerA = randomUUID();
|
||||||
|
const blockerB = randomUUID();
|
||||||
|
const blockedIssueId = randomUUID();
|
||||||
|
await db.insert(issues).values([
|
||||||
|
{ id: blockerA, companyId, title: "Blocker A", status: "done", priority: "medium" },
|
||||||
|
{ id: blockerB, companyId, title: "Blocker B", status: "todo", priority: "medium" },
|
||||||
|
{
|
||||||
|
id: blockedIssueId,
|
||||||
|
companyId,
|
||||||
|
title: "Blocked issue",
|
||||||
|
status: "blocked",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await svc.update(blockedIssueId, { blockedByIssueIds: [blockerA, blockerB] });
|
||||||
|
|
||||||
|
expect(await svc.listWakeableBlockedDependents(blockerA)).toEqual([]);
|
||||||
|
|
||||||
|
await svc.update(blockerB, { status: "done" });
|
||||||
|
|
||||||
|
expect(await svc.listWakeableBlockedDependents(blockerA)).toEqual([
|
||||||
|
{
|
||||||
|
id: blockedIssueId,
|
||||||
|
assigneeAgentId,
|
||||||
|
blockerIssueIds: [blockerA, blockerB],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wakes parents only when all direct children are terminal", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const assigneeAgentId = randomUUID();
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
await db.insert(agents).values({
|
||||||
|
id: assigneeAgentId,
|
||||||
|
companyId,
|
||||||
|
name: "CodexCoder",
|
||||||
|
role: "engineer",
|
||||||
|
status: "active",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentId = randomUUID();
|
||||||
|
const childA = randomUUID();
|
||||||
|
const childB = randomUUID();
|
||||||
|
await db.insert(issues).values([
|
||||||
|
{
|
||||||
|
id: parentId,
|
||||||
|
companyId,
|
||||||
|
title: "Parent issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: childA,
|
||||||
|
companyId,
|
||||||
|
parentId,
|
||||||
|
title: "Child A",
|
||||||
|
status: "done",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: childB,
|
||||||
|
companyId,
|
||||||
|
parentId,
|
||||||
|
title: "Child B",
|
||||||
|
status: "blocked",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toBeNull();
|
||||||
|
|
||||||
|
await svc.update(childB, { status: "cancelled" });
|
||||||
|
|
||||||
|
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toEqual({
|
||||||
|
id: parentId,
|
||||||
|
assigneeAgentId,
|
||||||
|
childIssueIds: [childA, childB],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -442,11 +442,12 @@ export function issueRoutes(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, issue.companyId);
|
assertCompanyAccess(req, issue.companyId);
|
||||||
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload] = await Promise.all([
|
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations] = await Promise.all([
|
||||||
resolveIssueProjectAndGoal(issue),
|
resolveIssueProjectAndGoal(issue),
|
||||||
svc.getAncestors(issue.id),
|
svc.getAncestors(issue.id),
|
||||||
svc.findMentionedProjectIds(issue.id),
|
svc.findMentionedProjectIds(issue.id),
|
||||||
documentsSvc.getIssueDocumentPayload(issue),
|
documentsSvc.getIssueDocumentPayload(issue),
|
||||||
|
svc.getRelationSummaries(issue.id),
|
||||||
]);
|
]);
|
||||||
const mentionedProjects = mentionedProjectIds.length > 0
|
const mentionedProjects = mentionedProjectIds.length > 0
|
||||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||||
|
|
@ -459,6 +460,8 @@ export function issueRoutes(
|
||||||
...issue,
|
...issue,
|
||||||
goalId: goal?.id ?? issue.goalId,
|
goalId: goal?.id ?? issue.goalId,
|
||||||
ancestors,
|
ancestors,
|
||||||
|
blockedBy: relations.blockedBy,
|
||||||
|
blocks: relations.blocks,
|
||||||
...documentPayload,
|
...documentPayload,
|
||||||
project: project ?? null,
|
project: project ?? null,
|
||||||
goal: goal ?? null,
|
goal: goal ?? null,
|
||||||
|
|
@ -482,11 +485,13 @@ export function issueRoutes(
|
||||||
? req.query.wakeCommentId.trim()
|
? req.query.wakeCommentId.trim()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const [{ project, goal }, ancestors, commentCursor, wakeComment, attachments] = await Promise.all([
|
const [{ project, goal }, ancestors, commentCursor, wakeComment, relations, attachments] =
|
||||||
|
await Promise.all([
|
||||||
resolveIssueProjectAndGoal(issue),
|
resolveIssueProjectAndGoal(issue),
|
||||||
svc.getAncestors(issue.id),
|
svc.getAncestors(issue.id),
|
||||||
svc.getCommentCursor(issue.id),
|
svc.getCommentCursor(issue.id),
|
||||||
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
||||||
|
svc.getRelationSummaries(issue.id),
|
||||||
svc.listAttachments(issue.id),
|
svc.listAttachments(issue.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -501,6 +506,8 @@ export function issueRoutes(
|
||||||
projectId: issue.projectId,
|
projectId: issue.projectId,
|
||||||
goalId: goal?.id ?? issue.goalId,
|
goalId: goal?.id ?? issue.goalId,
|
||||||
parentId: issue.parentId,
|
parentId: issue.parentId,
|
||||||
|
blockedBy: relations.blockedBy,
|
||||||
|
blocks: relations.blocks,
|
||||||
assigneeAgentId: issue.assigneeAgentId,
|
assigneeAgentId: issue.assigneeAgentId,
|
||||||
assigneeUserId: issue.assigneeUserId,
|
assigneeUserId: issue.assigneeUserId,
|
||||||
updatedAt: issue.updatedAt,
|
updatedAt: issue.updatedAt,
|
||||||
|
|
@ -1058,7 +1065,11 @@ export function issueRoutes(
|
||||||
action: "issue.created",
|
action: "issue.created",
|
||||||
entityType: "issue",
|
entityType: "issue",
|
||||||
entityId: issue.id,
|
entityId: issue.id,
|
||||||
details: { title: issue.title, identifier: issue.identifier },
|
details: {
|
||||||
|
title: issue.title,
|
||||||
|
identifier: issue.identifier,
|
||||||
|
...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
void queueIssueAssignmentWakeup({
|
void queueIssueAssignmentWakeup({
|
||||||
|
|
@ -1104,6 +1115,10 @@ export function issueRoutes(
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const isClosed = existing.status === "done" || existing.status === "cancelled";
|
const isClosed = existing.status === "done" || existing.status === "cancelled";
|
||||||
|
const existingRelations =
|
||||||
|
Array.isArray(req.body.blockedByIssueIds)
|
||||||
|
? await svc.getRelationSummaries(existing.id)
|
||||||
|
: null;
|
||||||
const {
|
const {
|
||||||
comment: commentBody,
|
comment: commentBody,
|
||||||
reopen: reopenRequested,
|
reopen: reopenRequested,
|
||||||
|
|
@ -1158,7 +1173,11 @@ export function issueRoutes(
|
||||||
}
|
}
|
||||||
let issue;
|
let issue;
|
||||||
try {
|
try {
|
||||||
issue = await svc.update(id, updateFields);
|
issue = await svc.update(id, {
|
||||||
|
...updateFields,
|
||||||
|
actorAgentId: actor.agentId ?? null,
|
||||||
|
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HttpError && err.status === 422) {
|
if (err instanceof HttpError && err.status === 422) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -1187,6 +1206,15 @@ export function issueRoutes(
|
||||||
res.status(404).json({ error: "Issue not found" });
|
res.status(404).json({ error: "Issue not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown } = issue;
|
||||||
|
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
|
||||||
|
const updatedRelations = await svc.getRelationSummaries(issue.id);
|
||||||
|
issueResponse = {
|
||||||
|
...issue,
|
||||||
|
blockedBy: updatedRelations.blockedBy,
|
||||||
|
blocks: updatedRelations.blocks,
|
||||||
|
};
|
||||||
|
}
|
||||||
await routinesSvc.syncRunStatusForIssue(issue.id);
|
await routinesSvc.syncRunStatusForIssue(issue.id);
|
||||||
|
|
||||||
if (actor.runId) {
|
if (actor.runId) {
|
||||||
|
|
@ -1201,6 +1229,9 @@ export function issueRoutes(
|
||||||
previous[key] = (existing as Record<string, unknown>)[key];
|
previous[key] = (existing as Record<string, unknown>)[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(req.body.blockedByIssueIds)) {
|
||||||
|
previous.blockedByIssueIds = existingRelations?.blockedBy.map((relation) => relation.id) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
const hasFieldChanges = Object.keys(previous).length > 0;
|
const hasFieldChanges = Object.keys(previous).length > 0;
|
||||||
const reopened =
|
const reopened =
|
||||||
|
|
@ -1229,6 +1260,31 @@ export function issueRoutes(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(req.body.blockedByIssueIds)) {
|
||||||
|
const previousBlockedByIds = new Set((existingRelations?.blockedBy ?? []).map((relation) => relation.id));
|
||||||
|
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);
|
||||||
|
const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate));
|
||||||
|
const removedBlockedByIssueIds = [...previousBlockedByIds].filter((candidate) => !nextBlockedByIds.has(candidate));
|
||||||
|
if (addedBlockedByIssueIds.length > 0 || removedBlockedByIssueIds.length > 0) {
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "issue.blockers_updated",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
identifier: issue.identifier,
|
||||||
|
blockedByIssueIds: req.body.blockedByIssueIds,
|
||||||
|
addedBlockedByIssueIds,
|
||||||
|
removedBlockedByIssueIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (issue.status === "done" && existing.status !== "done") {
|
if (issue.status === "done" && existing.status !== "done") {
|
||||||
const tc = getTelemetryClient();
|
const tc = getTelemetryClient();
|
||||||
if (tc && actor.agentId) {
|
if (tc && actor.agentId) {
|
||||||
|
|
@ -1277,10 +1333,18 @@ export function issueRoutes(
|
||||||
|
|
||||||
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
|
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
|
type WakeupRequest = NonNullable<Parameters<typeof heartbeat.wakeup>[1]>;
|
||||||
|
const wakeups = new Map<string, { agentId: string; wakeup: WakeupRequest }>();
|
||||||
|
const addWakeup = (agentId: string, wakeup: WakeupRequest) => {
|
||||||
|
const wakeIssueId =
|
||||||
|
wakeup.payload && typeof wakeup.payload === "object" && typeof wakeup.payload.issueId === "string"
|
||||||
|
? wakeup.payload.issueId
|
||||||
|
: issue.id;
|
||||||
|
wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup });
|
||||||
|
};
|
||||||
|
|
||||||
if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
|
if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
|
||||||
wakeups.set(issue.assigneeAgentId, {
|
addWakeup(issue.assigneeAgentId, {
|
||||||
source: "assignment",
|
source: "assignment",
|
||||||
triggerDetail: "system",
|
triggerDetail: "system",
|
||||||
reason: "issue_assigned",
|
reason: "issue_assigned",
|
||||||
|
|
@ -1300,7 +1364,7 @@ export function issueRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
|
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
|
||||||
wakeups.set(issue.assigneeAgentId, {
|
addWakeup(issue.assigneeAgentId, {
|
||||||
source: "automation",
|
source: "automation",
|
||||||
triggerDetail: "system",
|
triggerDetail: "system",
|
||||||
reason: "issue_status_changed",
|
reason: "issue_status_changed",
|
||||||
|
|
@ -1328,9 +1392,8 @@ export function issueRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const mentionedId of mentionedIds) {
|
for (const mentionedId of mentionedIds) {
|
||||||
if (wakeups.has(mentionedId)) continue;
|
|
||||||
if (actor.actorType === "agent" && actor.actorId === mentionedId) continue;
|
if (actor.actorType === "agent" && actor.actorId === mentionedId) continue;
|
||||||
wakeups.set(mentionedId, {
|
addWakeup(mentionedId, {
|
||||||
source: "automation",
|
source: "automation",
|
||||||
triggerDetail: "system",
|
triggerDetail: "system",
|
||||||
reason: "issue_comment_mentioned",
|
reason: "issue_comment_mentioned",
|
||||||
|
|
@ -1349,14 +1412,69 @@ export function issueRoutes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [agentId, wakeup] of wakeups.entries()) {
|
const becameDone = existing.status !== "done" && issue.status === "done";
|
||||||
|
if (becameDone) {
|
||||||
|
const dependents = await svc.listWakeableBlockedDependents(issue.id);
|
||||||
|
for (const dependent of dependents) {
|
||||||
|
addWakeup(dependent.assigneeAgentId, {
|
||||||
|
source: "automation",
|
||||||
|
triggerDetail: "system",
|
||||||
|
reason: "issue_blockers_resolved",
|
||||||
|
payload: {
|
||||||
|
issueId: dependent.id,
|
||||||
|
resolvedBlockerIssueId: issue.id,
|
||||||
|
blockerIssueIds: dependent.blockerIssueIds,
|
||||||
|
},
|
||||||
|
requestedByActorType: actor.actorType,
|
||||||
|
requestedByActorId: actor.actorId,
|
||||||
|
contextSnapshot: {
|
||||||
|
issueId: dependent.id,
|
||||||
|
taskId: dependent.id,
|
||||||
|
wakeReason: "issue_blockers_resolved",
|
||||||
|
source: "issue.blockers_resolved",
|
||||||
|
resolvedBlockerIssueId: issue.id,
|
||||||
|
blockerIssueIds: dependent.blockerIssueIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const becameTerminal =
|
||||||
|
!["done", "cancelled"].includes(existing.status) && ["done", "cancelled"].includes(issue.status);
|
||||||
|
if (becameTerminal && issue.parentId) {
|
||||||
|
const parent = await svc.getWakeableParentAfterChildCompletion(issue.parentId);
|
||||||
|
if (parent) {
|
||||||
|
addWakeup(parent.assigneeAgentId, {
|
||||||
|
source: "automation",
|
||||||
|
triggerDetail: "system",
|
||||||
|
reason: "issue_children_completed",
|
||||||
|
payload: {
|
||||||
|
issueId: parent.id,
|
||||||
|
completedChildIssueId: issue.id,
|
||||||
|
childIssueIds: parent.childIssueIds,
|
||||||
|
},
|
||||||
|
requestedByActorType: actor.actorType,
|
||||||
|
requestedByActorId: actor.actorId,
|
||||||
|
contextSnapshot: {
|
||||||
|
issueId: parent.id,
|
||||||
|
taskId: parent.id,
|
||||||
|
wakeReason: "issue_children_completed",
|
||||||
|
source: "issue.children_completed",
|
||||||
|
completedChildIssueId: issue.id,
|
||||||
|
childIssueIds: parent.childIssueIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { agentId, wakeup } of wakeups.values()) {
|
||||||
heartbeat
|
heartbeat
|
||||||
.wakeup(agentId, wakeup)
|
.wakeup(agentId, wakeup)
|
||||||
.catch((err) => logger.warn({ err, issueId: issue.id, agentId }, "failed to wake agent on issue update"));
|
.catch((err) => logger.warn({ err, issueId: issue.id, agentId }, "failed to wake agent on issue update"));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
res.json({ ...issue, comment });
|
res.json({ ...issueResponse, comment });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete("/issues/:id", async (req, res) => {
|
router.delete("/issues/:id", async (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
issueAttachments,
|
issueAttachments,
|
||||||
issueInboxArchives,
|
issueInboxArchives,
|
||||||
issueLabels,
|
issueLabels,
|
||||||
|
issueRelations,
|
||||||
issueComments,
|
issueComments,
|
||||||
issueDocuments,
|
issueDocuments,
|
||||||
issueReadStates,
|
issueReadStates,
|
||||||
|
|
@ -21,6 +22,7 @@ import {
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
|
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||||
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
|
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
|
||||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -114,8 +116,13 @@ type ProjectGoalReader = Pick<Db, "select">;
|
||||||
type DbReader = Pick<Db, "select">;
|
type DbReader = Pick<Db, "select">;
|
||||||
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
|
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
|
||||||
labelIds?: string[];
|
labelIds?: string[];
|
||||||
|
blockedByIssueIds?: string[];
|
||||||
inheritExecutionWorkspaceFromIssueId?: string | null;
|
inheritExecutionWorkspaceFromIssueId?: string | null;
|
||||||
};
|
};
|
||||||
|
type IssueRelationSummaryMap = {
|
||||||
|
blockedBy: IssueRelationIssueSummary[];
|
||||||
|
blocks: IssueRelationIssueSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
||||||
if (actorRunId) return checkoutRunId === actorRunId;
|
if (actorRunId) return checkoutRunId === actorRunId;
|
||||||
|
|
@ -675,6 +682,177 @@ export function issueService(db: Db) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getIssueRelationSummaryMap(
|
||||||
|
companyId: string,
|
||||||
|
issueIds: string[],
|
||||||
|
dbOrTx: DbReader = db,
|
||||||
|
): Promise<Map<string, IssueRelationSummaryMap>> {
|
||||||
|
const uniqueIssueIds = [...new Set(issueIds)];
|
||||||
|
const empty = new Map<string, IssueRelationSummaryMap>();
|
||||||
|
for (const issueId of uniqueIssueIds) {
|
||||||
|
empty.set(issueId, { blockedBy: [], blocks: [] });
|
||||||
|
}
|
||||||
|
if (uniqueIssueIds.length === 0) return empty;
|
||||||
|
|
||||||
|
const [blockedByRows, blockingRows] = await Promise.all([
|
||||||
|
dbOrTx
|
||||||
|
.select({
|
||||||
|
currentIssueId: issueRelations.relatedIssueId,
|
||||||
|
relatedId: issues.id,
|
||||||
|
identifier: issues.identifier,
|
||||||
|
title: issues.title,
|
||||||
|
status: issues.status,
|
||||||
|
priority: issues.priority,
|
||||||
|
assigneeAgentId: issues.assigneeAgentId,
|
||||||
|
assigneeUserId: issues.assigneeUserId,
|
||||||
|
})
|
||||||
|
.from(issueRelations)
|
||||||
|
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(issueRelations.companyId, companyId),
|
||||||
|
eq(issueRelations.type, "blocks"),
|
||||||
|
inArray(issueRelations.relatedIssueId, uniqueIssueIds),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dbOrTx
|
||||||
|
.select({
|
||||||
|
currentIssueId: issueRelations.issueId,
|
||||||
|
relatedId: issues.id,
|
||||||
|
identifier: issues.identifier,
|
||||||
|
title: issues.title,
|
||||||
|
status: issues.status,
|
||||||
|
priority: issues.priority,
|
||||||
|
assigneeAgentId: issues.assigneeAgentId,
|
||||||
|
assigneeUserId: issues.assigneeUserId,
|
||||||
|
})
|
||||||
|
.from(issueRelations)
|
||||||
|
.innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(issueRelations.companyId, companyId),
|
||||||
|
eq(issueRelations.type, "blocks"),
|
||||||
|
inArray(issueRelations.issueId, uniqueIssueIds),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const row of blockedByRows) {
|
||||||
|
empty.get(row.currentIssueId)?.blockedBy.push({
|
||||||
|
id: row.relatedId,
|
||||||
|
identifier: row.identifier,
|
||||||
|
title: row.title,
|
||||||
|
status: row.status as IssueRelationIssueSummary["status"],
|
||||||
|
priority: row.priority as IssueRelationIssueSummary["priority"],
|
||||||
|
assigneeAgentId: row.assigneeAgentId,
|
||||||
|
assigneeUserId: row.assigneeUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of blockingRows) {
|
||||||
|
empty.get(row.currentIssueId)?.blocks.push({
|
||||||
|
id: row.relatedId,
|
||||||
|
identifier: row.identifier,
|
||||||
|
title: row.title,
|
||||||
|
status: row.status as IssueRelationIssueSummary["status"],
|
||||||
|
priority: row.priority as IssueRelationIssueSummary["priority"],
|
||||||
|
assigneeAgentId: row.assigneeAgentId,
|
||||||
|
assigneeUserId: row.assigneeUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const relations of empty.values()) {
|
||||||
|
relations.blockedBy.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
relations.blocks.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
}
|
||||||
|
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertNoBlockingCycles(
|
||||||
|
companyId: string,
|
||||||
|
issueId: string,
|
||||||
|
blockerIssueIds: string[],
|
||||||
|
dbOrTx: DbReader = db,
|
||||||
|
) {
|
||||||
|
if (blockerIssueIds.length === 0) return;
|
||||||
|
|
||||||
|
const rows = await dbOrTx
|
||||||
|
.select({
|
||||||
|
blockerIssueId: issueRelations.issueId,
|
||||||
|
blockedIssueId: issueRelations.relatedIssueId,
|
||||||
|
})
|
||||||
|
.from(issueRelations)
|
||||||
|
.where(and(eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks")));
|
||||||
|
|
||||||
|
const adjacency = new Map<string, string[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = adjacency.get(row.blockerIssueId) ?? [];
|
||||||
|
list.push(row.blockedIssueId);
|
||||||
|
adjacency.set(row.blockerIssueId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const blockerIssueId of blockerIssueIds) {
|
||||||
|
const queue = [...(adjacency.get(issueId) ?? [])];
|
||||||
|
const visited = new Set<string>([issueId]);
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift()!;
|
||||||
|
if (current === blockerIssueId) {
|
||||||
|
throw unprocessable("Blocking relations cannot contain cycles");
|
||||||
|
}
|
||||||
|
if (visited.has(current)) continue;
|
||||||
|
visited.add(current);
|
||||||
|
queue.push(...(adjacency.get(current) ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncBlockedByIssueIds(
|
||||||
|
issueId: string,
|
||||||
|
companyId: string,
|
||||||
|
blockedByIssueIds: string[],
|
||||||
|
actor: { agentId?: string | null; userId?: string | null } = {},
|
||||||
|
dbOrTx: any = db,
|
||||||
|
) {
|
||||||
|
const deduped = [...new Set(blockedByIssueIds)];
|
||||||
|
if (deduped.some((candidate) => candidate === issueId)) {
|
||||||
|
throw unprocessable("Issue cannot be blocked by itself");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deduped.length > 0) {
|
||||||
|
const relatedIssues = await dbOrTx
|
||||||
|
.select({ id: issues.id })
|
||||||
|
.from(issues)
|
||||||
|
.where(and(eq(issues.companyId, companyId), inArray(issues.id, deduped)));
|
||||||
|
if (relatedIssues.length !== deduped.length) {
|
||||||
|
throw unprocessable("Blocked-by issues must belong to the same company");
|
||||||
|
}
|
||||||
|
await assertNoBlockingCycles(companyId, issueId, deduped, dbOrTx);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbOrTx
|
||||||
|
.delete(issueRelations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(issueRelations.companyId, companyId),
|
||||||
|
eq(issueRelations.relatedIssueId, issueId),
|
||||||
|
eq(issueRelations.type, "blocks"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deduped.length === 0) return;
|
||||||
|
|
||||||
|
await dbOrTx.insert(issueRelations).values(
|
||||||
|
deduped.map((blockerIssueId) => ({
|
||||||
|
companyId,
|
||||||
|
issueId: blockerIssueId,
|
||||||
|
relatedIssueId: issueId,
|
||||||
|
type: "blocks",
|
||||||
|
createdByAgentId: actor.agentId ?? null,
|
||||||
|
createdByUserId: actor.userId ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function isTerminalOrMissingHeartbeatRun(runId: string) {
|
async function isTerminalOrMissingHeartbeatRun(runId: string) {
|
||||||
const run = await db
|
const run = await db
|
||||||
.select({ status: heartbeatRuns.status })
|
.select({ status: heartbeatRuns.status })
|
||||||
|
|
@ -1076,11 +1254,125 @@ export function issueService(db: Db) {
|
||||||
return getIssueByIdentifier(identifier);
|
return getIssueByIdentifier(identifier);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getRelationSummaries: async (issueId: string) => {
|
||||||
|
const issue = await db
|
||||||
|
.select({ id: issues.id, companyId: issues.companyId })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, issueId))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!issue) throw notFound("Issue not found");
|
||||||
|
const relations = await getIssueRelationSummaryMap(issue.companyId, [issueId], db);
|
||||||
|
return relations.get(issueId) ?? { blockedBy: [], blocks: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
listWakeableBlockedDependents: async (blockerIssueId: string) => {
|
||||||
|
const blockerIssue = await db
|
||||||
|
.select({ id: issues.id, companyId: issues.companyId })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, blockerIssueId))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!blockerIssue) return [];
|
||||||
|
|
||||||
|
const candidates = await db
|
||||||
|
.select({
|
||||||
|
id: issues.id,
|
||||||
|
assigneeAgentId: issues.assigneeAgentId,
|
||||||
|
status: issues.status,
|
||||||
|
})
|
||||||
|
.from(issueRelations)
|
||||||
|
.innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(issueRelations.companyId, blockerIssue.companyId),
|
||||||
|
eq(issueRelations.type, "blocks"),
|
||||||
|
eq(issueRelations.issueId, blockerIssueId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) return [];
|
||||||
|
|
||||||
|
const candidateIds = candidates.map((candidate) => candidate.id);
|
||||||
|
const blockerRows = await db
|
||||||
|
.select({
|
||||||
|
issueId: issueRelations.relatedIssueId,
|
||||||
|
blockerIssueId: issueRelations.issueId,
|
||||||
|
blockerStatus: issues.status,
|
||||||
|
})
|
||||||
|
.from(issueRelations)
|
||||||
|
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(issueRelations.companyId, blockerIssue.companyId),
|
||||||
|
eq(issueRelations.type, "blocks"),
|
||||||
|
inArray(issueRelations.relatedIssueId, candidateIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const blockersByIssueId = new Map<string, Array<{ blockerIssueId: string; blockerStatus: string }>>();
|
||||||
|
for (const row of blockerRows) {
|
||||||
|
const list = blockersByIssueId.get(row.issueId) ?? [];
|
||||||
|
list.push({ blockerIssueId: row.blockerIssueId, blockerStatus: row.blockerStatus });
|
||||||
|
blockersByIssueId.set(row.issueId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.filter((candidate) => candidate.assigneeAgentId && !["backlog", "done", "cancelled"].includes(candidate.status))
|
||||||
|
.map((candidate) => {
|
||||||
|
const blockers = blockersByIssueId.get(candidate.id) ?? [];
|
||||||
|
return {
|
||||||
|
...candidate,
|
||||||
|
blockerIssueIds: blockers.map((blocker) => blocker.blockerIssueId),
|
||||||
|
allBlockersDone: blockers.length > 0 && blockers.every((blocker) => blocker.blockerStatus === "done"),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((candidate) => candidate.allBlockersDone)
|
||||||
|
.map((candidate) => ({
|
||||||
|
id: candidate.id,
|
||||||
|
assigneeAgentId: candidate.assigneeAgentId!,
|
||||||
|
blockerIssueIds: candidate.blockerIssueIds,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
getWakeableParentAfterChildCompletion: async (parentIssueId: string) => {
|
||||||
|
const parent = await db
|
||||||
|
.select({
|
||||||
|
id: issues.id,
|
||||||
|
assigneeAgentId: issues.assigneeAgentId,
|
||||||
|
status: issues.status,
|
||||||
|
companyId: issues.companyId,
|
||||||
|
})
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, parentIssueId))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!parent || !parent.assigneeAgentId || ["backlog", "done", "cancelled"].includes(parent.status)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = await db
|
||||||
|
.select({ id: issues.id, status: issues.status })
|
||||||
|
.from(issues)
|
||||||
|
.where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parentIssueId)));
|
||||||
|
if (children.length === 0) return null;
|
||||||
|
if (!children.every((child) => child.status === "done" || child.status === "cancelled")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: parent.id,
|
||||||
|
assigneeAgentId: parent.assigneeAgentId,
|
||||||
|
childIssueIds: children.map((child) => child.id),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
create: async (
|
create: async (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
data: IssueCreateInput,
|
data: IssueCreateInput,
|
||||||
) => {
|
) => {
|
||||||
const { labelIds: inputLabelIds, inheritExecutionWorkspaceFromIssueId, ...issueData } = data;
|
const {
|
||||||
|
labelIds: inputLabelIds,
|
||||||
|
blockedByIssueIds,
|
||||||
|
inheritExecutionWorkspaceFromIssueId,
|
||||||
|
...issueData
|
||||||
|
} = data;
|
||||||
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
|
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
|
||||||
if (!isolatedWorkspacesEnabled) {
|
if (!isolatedWorkspacesEnabled) {
|
||||||
delete issueData.executionWorkspaceId;
|
delete issueData.executionWorkspaceId;
|
||||||
|
|
@ -1223,12 +1515,32 @@ export function issueService(db: Db) {
|
||||||
if (inputLabelIds) {
|
if (inputLabelIds) {
|
||||||
await syncIssueLabels(issue.id, companyId, inputLabelIds, tx);
|
await syncIssueLabels(issue.id, companyId, inputLabelIds, tx);
|
||||||
}
|
}
|
||||||
|
if (blockedByIssueIds !== undefined) {
|
||||||
|
await syncBlockedByIssueIds(
|
||||||
|
issue.id,
|
||||||
|
companyId,
|
||||||
|
blockedByIssueIds,
|
||||||
|
{
|
||||||
|
agentId: issueData.createdByAgentId ?? null,
|
||||||
|
userId: issueData.createdByUserId ?? null,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
const [enriched] = await withIssueLabels(tx, [issue]);
|
const [enriched] = await withIssueLabels(tx, [issue]);
|
||||||
return enriched;
|
return enriched;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (id: string, data: Partial<typeof issues.$inferInsert> & { labelIds?: string[] }) => {
|
update: async (
|
||||||
|
id: string,
|
||||||
|
data: Partial<typeof issues.$inferInsert> & {
|
||||||
|
labelIds?: string[];
|
||||||
|
blockedByIssueIds?: string[];
|
||||||
|
actorAgentId?: string | null;
|
||||||
|
actorUserId?: string | null;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(issues)
|
.from(issues)
|
||||||
|
|
@ -1236,7 +1548,13 @@ export function issueService(db: Db) {
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
const { labelIds: nextLabelIds, ...issueData } = data;
|
const {
|
||||||
|
labelIds: nextLabelIds,
|
||||||
|
blockedByIssueIds,
|
||||||
|
actorAgentId,
|
||||||
|
actorUserId,
|
||||||
|
...issueData
|
||||||
|
} = data;
|
||||||
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
|
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
|
||||||
if (!isolatedWorkspacesEnabled) {
|
if (!isolatedWorkspacesEnabled) {
|
||||||
delete issueData.executionWorkspaceId;
|
delete issueData.executionWorkspaceId;
|
||||||
|
|
@ -1328,6 +1646,18 @@ export function issueService(db: Db) {
|
||||||
if (nextLabelIds !== undefined) {
|
if (nextLabelIds !== undefined) {
|
||||||
await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx);
|
await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx);
|
||||||
}
|
}
|
||||||
|
if (blockedByIssueIds !== undefined) {
|
||||||
|
await syncBlockedByIssueIds(
|
||||||
|
updated.id,
|
||||||
|
existing.companyId,
|
||||||
|
blockedByIssueIds,
|
||||||
|
{
|
||||||
|
agentId: actorAgentId ?? null,
|
||||||
|
userId: actorUserId ?? null,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
const [enriched] = await withIssueLabels(tx, [updated]);
|
const [enriched] = await withIssueLabels(tx, [updated]);
|
||||||
return enriched;
|
return enriched;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ interface IssuePropertiesProps {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
onUpdate: (data: Record<string, unknown>) => void;
|
onUpdate: (data: Record<string, unknown>) => void;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
childIssues?: Issue[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
|
@ -118,7 +117,7 @@ function PropertyPicker({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IssueProperties({ issue, onUpdate, inline, childIssues }: IssuePropertiesProps) {
|
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const companyId = issue.companyId ?? selectedCompanyId;
|
const companyId = issue.companyId ?? selectedCompanyId;
|
||||||
|
|
@ -126,6 +125,8 @@ export function IssueProperties({ issue, onUpdate, inline, childIssues }: IssueP
|
||||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||||
const [projectOpen, setProjectOpen] = useState(false);
|
const [projectOpen, setProjectOpen] = useState(false);
|
||||||
const [projectSearch, setProjectSearch] = useState("");
|
const [projectSearch, setProjectSearch] = useState("");
|
||||||
|
const [blockedByOpen, setBlockedByOpen] = useState(false);
|
||||||
|
const [blockedBySearch, setBlockedBySearch] = useState("");
|
||||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||||
const [labelSearch, setLabelSearch] = useState("");
|
const [labelSearch, setLabelSearch] = useState("");
|
||||||
const [newLabelName, setNewLabelName] = useState("");
|
const [newLabelName, setNewLabelName] = useState("");
|
||||||
|
|
@ -164,6 +165,12 @@ export function IssueProperties({ issue, onUpdate, inline, childIssues }: IssueP
|
||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: allIssues } = useQuery({
|
||||||
|
queryKey: queryKeys.issues.list(companyId!),
|
||||||
|
queryFn: () => issuesApi.list(companyId!),
|
||||||
|
enabled: !!companyId && blockedByOpen,
|
||||||
|
});
|
||||||
|
|
||||||
const createLabel = useMutation({
|
const createLabel = useMutation({
|
||||||
mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data),
|
mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data),
|
||||||
onSuccess: async (created) => {
|
onSuccess: async (created) => {
|
||||||
|
|
@ -489,6 +496,88 @@ export function IssueProperties({ issue, onUpdate, inline, childIssues }: IssueP
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? [];
|
||||||
|
const blockedByTrigger = blockedByIds.length > 0 ? (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap min-w-0">
|
||||||
|
{(issue.blockedBy ?? []).slice(0, 2).map((relation) => (
|
||||||
|
<span key={relation.id} className="inline-flex max-w-full items-center rounded-full border border-border px-2 py-0.5 text-xs">
|
||||||
|
<span className="truncate">{relation.identifier ?? relation.title}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{(issue.blockedBy ?? []).length > 2 && (
|
||||||
|
<span className="text-xs text-muted-foreground">+{(issue.blockedBy ?? []).length - 2}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">No blockers</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const blockingIssues = issue.blocks ?? [];
|
||||||
|
const blockerOptions = (allIssues ?? [])
|
||||||
|
.filter((candidate) => candidate.id !== issue.id)
|
||||||
|
.filter((candidate) => {
|
||||||
|
if (!blockedBySearch.trim()) return true;
|
||||||
|
const query = blockedBySearch.toLowerCase();
|
||||||
|
return (
|
||||||
|
(candidate.identifier ?? "").toLowerCase().includes(query) ||
|
||||||
|
candidate.title.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aLabel = `${a.identifier ?? ""} ${a.title}`.trim();
|
||||||
|
const bLabel = `${b.identifier ?? ""} ${b.title}`.trim();
|
||||||
|
return aLabel.localeCompare(bLabel);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleBlockedBy = (blockedByIssueId: string) => {
|
||||||
|
const nextBlockedByIds = blockedByIds.includes(blockedByIssueId)
|
||||||
|
? blockedByIds.filter((candidate) => candidate !== blockedByIssueId)
|
||||||
|
: [...blockedByIds, blockedByIssueId];
|
||||||
|
onUpdate({ blockedByIssueIds: nextBlockedByIds });
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockedByContent = (
|
||||||
|
<>
|
||||||
|
<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 issues..."
|
||||||
|
value={blockedBySearch}
|
||||||
|
onChange={(e) => setBlockedBySearch(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",
|
||||||
|
blockedByIds.length === 0 && "bg-accent",
|
||||||
|
)}
|
||||||
|
onClick={() => onUpdate({ blockedByIssueIds: [] })}
|
||||||
|
>
|
||||||
|
No blockers
|
||||||
|
</button>
|
||||||
|
{blockerOptions.map((candidate) => {
|
||||||
|
const selected = blockedByIds.includes(candidate.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={candidate.id}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs rounded hover:bg-accent/50",
|
||||||
|
selected && "bg-accent",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleBlockedBy(candidate.id)}
|
||||||
|
>
|
||||||
|
<StatusIcon status={candidate.status} />
|
||||||
|
<span className="truncate">
|
||||||
|
{candidate.identifier ? `${candidate.identifier} ` : ""}
|
||||||
|
{candidate.title}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -561,6 +650,49 @@ export function IssueProperties({ issue, onUpdate, inline, childIssues }: IssueP
|
||||||
{projectContent}
|
{projectContent}
|
||||||
</PropertyPicker>
|
</PropertyPicker>
|
||||||
|
|
||||||
|
<PropertyPicker
|
||||||
|
inline={inline}
|
||||||
|
label="Blocked by"
|
||||||
|
open={blockedByOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setBlockedByOpen(open);
|
||||||
|
if (!open) setBlockedBySearch("");
|
||||||
|
}}
|
||||||
|
triggerContent={blockedByTrigger}
|
||||||
|
triggerClassName="min-w-0 max-w-full"
|
||||||
|
popoverClassName="w-72"
|
||||||
|
>
|
||||||
|
{blockedByContent}
|
||||||
|
</PropertyPicker>
|
||||||
|
|
||||||
|
<PropertyRow label="Blocking">
|
||||||
|
{blockingIssues.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{blockingIssues.map((relation) => (
|
||||||
|
<Link
|
||||||
|
key={relation.id}
|
||||||
|
to={`/issues/${relation.identifier ?? relation.id}`}
|
||||||
|
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
{relation.identifier ?? relation.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">None</span>
|
||||||
|
)}
|
||||||
|
</PropertyRow>
|
||||||
|
|
||||||
|
{issue.parentId && (
|
||||||
|
<PropertyRow label="Parent">
|
||||||
|
<Link
|
||||||
|
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
|
||||||
|
className="text-sm hover:underline"
|
||||||
|
>
|
||||||
|
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
</PropertyRow>
|
||||||
|
)}
|
||||||
{issue.requestDepth > 0 && (
|
{issue.requestDepth > 0 && (
|
||||||
<PropertyRow label="Depth">
|
<PropertyRow label="Depth">
|
||||||
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
||||||
|
|
@ -605,52 +737,6 @@ export function IssueProperties({ issue, onUpdate, inline, childIssues }: IssueP
|
||||||
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
|
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(issue.parentId || (childIssues && childIssues.length > 0)) && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<div className="space-y-3">
|
|
||||||
{issue.parentId && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">Parent task</p>
|
|
||||||
<div className="flex items-start gap-1.5">
|
|
||||||
{issue.ancestors?.[0] != null && (
|
|
||||||
<div className="shrink-0 mt-0.5">
|
|
||||||
<StatusIcon status={issue.ancestors[0].status} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
|
|
||||||
className="text-sm hover:underline"
|
|
||||||
>
|
|
||||||
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{childIssues && childIssues.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">Sub-tasks</p>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{childIssues.map((child) => (
|
|
||||||
<div key={child.id} className="flex items-start gap-1.5">
|
|
||||||
<div className="shrink-0 mt-0.5">
|
|
||||||
<StatusIcon status={child.status} />
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to={`/issues/${child.identifier ?? child.id}`}
|
|
||||||
className="text-sm hover:underline"
|
|
||||||
>
|
|
||||||
{child.title}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue