Add issue project membership core

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-06 10:05:26 +00:00
1 parent 8a058f9d79
commit 8577a28fc9
8 files changed
+495 -7

No files matched your search

+65
View File
@@ -170,6 +170,71 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
20_000,
);
it(
"replays migration 0128 safely and keeps issue project backfill idempotent",
async () => {
const connectionString = await createTempDatabase();
await applyPendingMigrations(connectionString);
const issueProjectsHash = await migrationHash("0128_issue_projects.sql");
const companyId = "10000000-0000-4000-8000-000000000001";
const projectId = "20000000-0000-4000-8000-000000000001";
const issueId = "30000000-0000-4000-8000-000000000001";
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
await sql.unsafe(`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES ('${companyId}', 'Paperclip', 'PAP')
`);
await sql.unsafe(`
INSERT INTO "projects" ("id", "company_id", "name", "status")
VALUES ('${projectId}', '${companyId}', 'Core', 'in_progress')
`);
await sql.unsafe(`
INSERT INTO "issues" ("id", "company_id", "project_id", "title", "status", "priority")
VALUES ('${issueId}', '${companyId}', '${projectId}', 'Backfill me', 'todo', 'medium')
`);
await sql.unsafe(`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${issueProjectsHash}'`);
} finally {
await sql.end();
}
await applyPendingMigrations(connectionString);
const sqlAfterFirstReplay = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const rows = await sqlAfterFirstReplay.unsafe<{ issue_id: string; project_id: string; is_primary: boolean }[]>(`
SELECT "issue_id", "project_id", "is_primary"
FROM "issue_projects"
WHERE "issue_id" = '${issueId}'
`);
expect(rows).toEqual([{ issue_id: issueId, project_id: projectId, is_primary: true }]);
await sqlAfterFirstReplay.unsafe(
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${issueProjectsHash}'`,
);
} finally {
await sqlAfterFirstReplay.end();
}
await applyPendingMigrations(connectionString);
const sqlAfterSecondReplay = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const rows = await sqlAfterSecondReplay.unsafe<{ count: string; primary_count: string }[]>(`
SELECT count(*)::text, count(*) FILTER (WHERE "is_primary")::text AS "primary_count"
FROM "issue_projects"
WHERE "issue_id" = '${issueId}' AND "project_id" = '${projectId}'
`);
expect(rows[0]).toEqual({ count: "1", primary_count: "1" });
} finally {
await sqlAfterSecondReplay.end();
}
},
20_000,
);
it(
"replays migration 0046 safely when document revision columns already exist",
async () => {
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS "issue_projects" (
"issue_id" uuid NOT NULL,
"project_id" uuid NOT NULL,
"company_id" uuid NOT NULL,
"is_primary" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "issue_projects_pk" PRIMARY KEY("issue_id","project_id"),
CONSTRAINT "issue_projects_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "issue_projects_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "issue_projects_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "issue_projects_one_primary_uq"
ON "issue_projects" USING btree ("issue_id")
WHERE "is_primary" = true;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_projects_company_project_idx"
ON "issue_projects" USING btree ("company_id","project_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_projects_project_idx"
ON "issue_projects" USING btree ("project_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_projects_issue_idx"
ON "issue_projects" USING btree ("issue_id");
--> statement-breakpoint
INSERT INTO "issue_projects" ("issue_id", "project_id", "company_id", "is_primary")
SELECT "id", "project_id", "company_id", true
FROM "issues"
WHERE "project_id" IS NOT NULL
ON CONFLICT ("issue_id", "project_id") DO NOTHING;
@@ -918,6 +918,13 @@
"when": 1783025224120,
"tag": "0130_run_responsible_user_invariant",
"breakpoints": true
},
{
"idx": 131,
"version": "7",
"when": 1783296000000,
"tag": "0131_issue_projects",
"breakpoints": true
}
]
}
+1
View File
@@ -55,6 +55,7 @@ export { pipelineCaseEvents } from "./pipeline_case_events.js";
export { issueWorkProducts } from "./issue_work_products.js";
export { labels } from "./labels.js";
export { issueLabels } from "./issue_labels.js";
export { issueProjects } from "./issue_projects.js";
export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js";
export { issueThreadInteractions } from "./issue_thread_interactions.js";
+23
View File
@@ -0,0 +1,23 @@
import { sql } from "drizzle-orm";
import { pgTable, uuid, timestamp, boolean, index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { projects } from "./projects.js";
export const issueProjects = pgTable(
"issue_projects",
{
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
isPrimary: boolean("is_primary").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.issueId, table.projectId], name: "issue_projects_pk" }),
onePrimaryUq: uniqueIndex("issue_projects_one_primary_uq").on(table.issueId).where(sql`${table.isPrimary}`),
companyProjectIdx: index("issue_projects_company_project_idx").on(table.companyId, table.projectId),
projectIdx: index("issue_projects_project_idx").on(table.projectId),
issueIdx: index("issue_projects_issue_idx").on(table.issueId),
}),
);
+209 -1
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { asc, eq } from "drizzle-orm";
import { asc, desc, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { sql } from "drizzle-orm";
import {
@@ -17,6 +17,7 @@ import {
issueComments,
issueInboxArchives,
issueDocuments,
issueProjects,
issuePlanDecompositions,
issueRelations,
issueThreadInteractions,
@@ -35,7 +36,10 @@ import {
deriveIssueCommentRunLogAttribution,
ISSUE_LIST_MAX_LIMIT,
issueService,
MAX_ISSUE_PROJECTS,
setIssueProjects,
} from "../services/issues.ts";
import { projectService } from "../services/projects.ts";
import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH, type IssueWorkMode } from "@paperclipai/shared";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
@@ -285,6 +289,210 @@ if (!embeddedPostgresSupport.supported) {
);
}
describeEmbeddedPostgres("issueService project memberships", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-projects-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueProjects);
await db.delete(issues);
await db.delete(projects);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `P${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
return companyId;
}
async function seedProject(companyId: string, input: { id?: string; name?: string; archived?: boolean } = {}) {
const [project] = await db
.insert(projects)
.values({
id: input.id ?? randomUUID(),
companyId,
name: input.name ?? `Project ${randomUUID().slice(0, 8)}`,
status: "in_progress",
archivedAt: input.archived ? new Date() : null,
})
.returning();
return project!;
}
async function seedIssue(companyId: string) {
return await svc.create(companyId, {
title: "Attach to projects",
description: null,
status: "todo",
priority: "medium",
});
}
async function listIssueProjects(issueId: string) {
return await db
.select({
projectId: issueProjects.projectId,
isPrimary: issueProjects.isPrimary,
})
.from(issueProjects)
.where(eq(issueProjects.issueId, issueId))
.orderBy(desc(issueProjects.isPrimary), asc(issueProjects.createdAt), asc(issueProjects.projectId));
}
async function getIssueProjectId(issueId: string) {
return await db
.select({ projectId: issues.projectId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]?.projectId ?? null);
}
it("sets, replaces, and clears memberships while mirroring the primary project", async () => {
const companyId = await seedCompany();
const [projectOne, projectTwo, projectThree] = await Promise.all([
seedProject(companyId, { name: "One" }),
seedProject(companyId, { name: "Two" }),
seedProject(companyId, { name: "Three" }),
]);
const issue = await seedIssue(companyId);
await db.transaction((tx) => setIssueProjects(tx, issue.id, [projectOne.id, projectTwo.id]));
expect(await listIssueProjects(issue.id)).toEqual([
{ projectId: projectOne.id, isPrimary: true },
{ projectId: projectTwo.id, isPrimary: false },
]);
expect(await getIssueProjectId(issue.id)).toBe(projectOne.id);
await db.transaction((tx) => setIssueProjects(tx, issue.id, [projectTwo.id, projectThree.id]));
expect(await listIssueProjects(issue.id)).toEqual([
{ projectId: projectTwo.id, isPrimary: true },
{ projectId: projectThree.id, isPrimary: false },
]);
expect(await getIssueProjectId(issue.id)).toBe(projectTwo.id);
await db.transaction((tx) => setIssueProjects(tx, issue.id, []));
expect(await listIssueProjects(issue.id)).toEqual([]);
expect(await getIssueProjectId(issue.id)).toBeNull();
});
it("keeps legacy projectId create and update paths mirrored into issue_projects", async () => {
const companyId = await seedCompany();
const projectOne = await seedProject(companyId, { name: "One" });
const projectTwo = await seedProject(companyId, { name: "Two" });
const issue = await svc.create(companyId, {
title: "Legacy project id",
description: null,
status: "todo",
priority: "medium",
projectId: projectOne.id,
});
expect(await listIssueProjects(issue.id)).toEqual([
{ projectId: projectOne.id, isPrimary: true },
]);
await svc.update(issue.id, { projectId: projectTwo.id });
expect(await listIssueProjects(issue.id)).toEqual([
{ projectId: projectTwo.id, isPrimary: true },
]);
expect(await getIssueProjectId(issue.id)).toBe(projectTwo.id);
await svc.update(issue.id, { projectId: null });
expect(await listIssueProjects(issue.id)).toEqual([]);
expect(await getIssueProjectId(issue.id)).toBeNull();
});
it("dedupes with first occurrence winning and enforces the membership cap", async () => {
const companyId = await seedCompany();
const projectIds = await Promise.all(
Array.from({ length: MAX_ISSUE_PROJECTS + 1 }, (_, index) =>
seedProject(companyId, { name: `Project ${index}` }).then((project) => project.id),
),
);
const issue = await seedIssue(companyId);
await svc.setProjects(issue.id, [projectIds[1]!, projectIds[0]!, projectIds[1]!]);
expect(await listIssueProjects(issue.id)).toEqual([
{ projectId: projectIds[1]!, isPrimary: true },
{ projectId: projectIds[0]!, isPrimary: false },
]);
expect(await getIssueProjectId(issue.id)).toBe(projectIds[1]);
await expect(svc.setProjects(issue.id, projectIds)).rejects.toMatchObject({
status: 422,
});
});
it("rejects cross-company and archived projects", async () => {
const companyId = await seedCompany();
const otherCompanyId = await seedCompany();
const issue = await seedIssue(companyId);
const otherProject = await seedProject(otherCompanyId);
const archivedProject = await seedProject(companyId, { archived: true });
await expect(svc.setProjects(issue.id, [otherProject.id])).rejects.toMatchObject({
status: 422,
message: "Issue projects must belong to the same company as the issue",
});
await expect(svc.setProjects(issue.id, [archivedProject.id])).rejects.toMatchObject({
status: 422,
message: "Archived projects cannot be attached to issues",
});
});
it("promotes the oldest remaining membership when the primary project is removed", async () => {
const companyId = await seedCompany();
const projectOne = await seedProject(companyId, { name: "One" });
const projectTwo = await seedProject(companyId, { name: "Two" });
const projectThree = await seedProject(companyId, { name: "Three" });
const issue = await seedIssue(companyId);
await svc.setProjects(issue.id, [projectOne.id, projectTwo.id, projectThree.id]);
await projectService(db).remove(projectOne.id);
expect(await listIssueProjects(issue.id)).toEqual([
{ projectId: projectTwo.id, isPrimary: true },
{ projectId: projectThree.id, isPrimary: false },
]);
expect(await getIssueProjectId(issue.id)).toBe(projectTwo.id);
await projectService(db).remove(projectTwo.id);
expect(await listIssueProjects(issue.id)).toEqual([
{ projectId: projectThree.id, isPrimary: true },
]);
expect(await getIssueProjectId(issue.id)).toBe(projectThree.id);
await projectService(db).remove(projectThree.id);
expect(await listIssueProjects(issue.id)).toEqual([]);
expect(await getIssueProjectId(issue.id)).toBeNull();
});
});
describeEmbeddedPostgres("issueService.list participantAgentId", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
+152
View File
@@ -21,6 +21,7 @@ import {
issueAttachments,
issueInboxArchives,
issueLabels,
issueProjects,
issueWatchdogs,
issuePlanDecompositions,
issueRecoveryActions,
@@ -462,6 +463,7 @@ type IssueUserContextInput = {
};
type ProjectGoalReader = Pick<Db, "select">;
type DbReader = Pick<Db, "select">;
type IssueProjectWriter = Pick<Db, "select" | "insert" | "update" | "delete">;
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
labelIds?: string[];
blockedByIssueIds?: string[];
@@ -478,6 +480,148 @@ type IssueChildCreateInput = IssueCreateInput & {
actorAgentId?: string | null;
actorUserId?: string | null;
};
export const MAX_ISSUE_PROJECTS = 8;
function dedupeIssueProjectIds(projectIds: readonly string[]) {
const seen = new Set<string>();
const deduped: string[] = [];
for (const projectId of projectIds) {
if (seen.has(projectId)) continue;
seen.add(projectId);
deduped.push(projectId);
}
return deduped;
}
async function validateIssueProjectIds(
tx: IssueProjectWriter,
companyId: string,
projectIds: readonly string[],
) {
if (projectIds.length === 0) return;
const rows = await tx
.select({
id: projects.id,
companyId: projects.companyId,
archivedAt: projects.archivedAt,
})
.from(projects)
.where(inArray(projects.id, [...projectIds]));
const byId = new Map(rows.map((row) => [row.id, row]));
for (const projectId of projectIds) {
const project = byId.get(projectId);
if (!project) {
throw unprocessable("One or more projects are invalid for this issue");
}
if (project.companyId !== companyId) {
throw unprocessable("Issue projects must belong to the same company as the issue");
}
if (project.archivedAt) {
throw unprocessable("Archived projects cannot be attached to issues");
}
}
}
export async function setIssueProjects(
tx: IssueProjectWriter,
issueId: string,
projectIds: readonly string[],
) {
const dedupedProjectIds = dedupeIssueProjectIds(projectIds);
if (dedupedProjectIds.length > MAX_ISSUE_PROJECTS) {
throw unprocessable(`Issues can belong to at most ${MAX_ISSUE_PROJECTS} projects`);
}
const issue = await tx
.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");
await validateIssueProjectIds(tx, issue.companyId, dedupedProjectIds);
await tx
.update(issueProjects)
.set({ isPrimary: false })
.where(eq(issueProjects.issueId, issueId));
if (dedupedProjectIds.length === 0) {
await tx.delete(issueProjects).where(eq(issueProjects.issueId, issueId));
await tx.update(issues).set({ projectId: null, updatedAt: new Date() }).where(eq(issues.id, issueId));
return { issueId, projectIds: [], primaryProjectId: null };
}
await tx
.delete(issueProjects)
.where(and(eq(issueProjects.issueId, issueId), notInArray(issueProjects.projectId, dedupedProjectIds)));
const createdAtBase = Date.now();
await tx
.insert(issueProjects)
.values(
dedupedProjectIds.map((projectId, index) => ({
issueId,
projectId,
companyId: issue.companyId,
isPrimary: index === 0,
createdAt: new Date(createdAtBase + index),
})),
)
.onConflictDoUpdate({
target: [issueProjects.issueId, issueProjects.projectId],
set: {
companyId: issue.companyId,
isPrimary: sql`excluded.is_primary`,
},
});
const primaryProjectId = dedupedProjectIds[0] ?? null;
await tx.update(issues).set({ projectId: primaryProjectId, updatedAt: new Date() }).where(eq(issues.id, issueId));
return { issueId, projectIds: dedupedProjectIds, primaryProjectId };
}
export async function promoteIssueProjectsAfterProjectRemoval(
tx: IssueProjectWriter,
projectId: string,
) {
const primaryIssueRows = await tx
.select({ issueId: issues.id })
.from(issues)
.where(eq(issues.projectId, projectId));
await tx.delete(issueProjects).where(eq(issueProjects.projectId, projectId));
for (const row of primaryIssueRows) {
const replacement = await tx
.select({ projectId: issueProjects.projectId })
.from(issueProjects)
.where(eq(issueProjects.issueId, row.issueId))
.orderBy(asc(issueProjects.createdAt), asc(issueProjects.projectId))
.limit(1)
.then((rows) => rows[0] ?? null);
await tx
.update(issueProjects)
.set({ isPrimary: false })
.where(eq(issueProjects.issueId, row.issueId));
if (replacement) {
await tx
.update(issueProjects)
.set({ isPrimary: true })
.where(and(eq(issueProjects.issueId, row.issueId), eq(issueProjects.projectId, replacement.projectId)));
}
await tx
.update(issues)
.set({ projectId: replacement?.projectId ?? null, updatedAt: new Date() })
.where(eq(issues.id, row.issueId));
}
return primaryIssueRows.length;
}
type AcceptedPlanDecompositionInput = {
acceptedPlanRevisionId: string;
children: IssueChildCreateInput[];
@@ -5550,6 +5694,9 @@ export function issueService(db: Db) {
);
const [issue] = await tx.insert(issues).values(values).returning();
if (issue.projectId) {
await setIssueProjects(tx, issue.id, [issue.projectId]);
}
if (watchdog) {
await upsertIssueWatchdogForIssue(tx, companyId, issue.id, {
agentId: watchdog.agentId,
@@ -5748,6 +5895,9 @@ export function issueService(db: Db) {
.returning()
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!updated) return null;
if (issueData.projectId !== undefined || patch.projectId !== undefined) {
await setIssueProjects(tx, updated.id, updated.projectId ? [updated.projectId] : []);
}
if (nextLabelIds !== undefined) {
await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx);
}
@@ -5820,6 +5970,8 @@ export function issueService(db: Db) {
return dbOrTx === db ? db.transaction(runUpdate) : runUpdate(dbOrTx);
},
setProjects: async (id: string, projectIds: string[]) => db.transaction((tx) => setIssueProjects(tx, id, projectIds)),
clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => {
const rows = await db
.select({
+8 -6
View File
@@ -32,6 +32,7 @@ import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runt
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import { promoteIssueProjectsAfterProjectRemoval } from "./issues.js";
type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
@@ -857,15 +858,16 @@ export function projectService(db: Db) {
},
remove: (id: string) =>
db
.delete(projects)
.where(eq(projects.id, id))
.returning()
.then((rows) => {
db.transaction(async (tx) => {
await promoteIssueProjectsAfterProjectRemoval(tx, id);
const rows = await tx
.delete(projects)
.where(eq(projects.id, id))
.returning();
const row = rows[0] ?? null;
if (!row) return null;
return { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) };
}),
}),
listWorkspaces: async (projectId: string): Promise<ProjectWorkspace[]> => {
const rows = await db