mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Add issue project membership core
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8a058f9d79
commit
8577a28fc9
8 files changed
+495
-7
No files matched your search
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
@@ -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>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user