diff --git a/apps/api/src/gitea-integration/controllers/import-gitea-issues.ts b/apps/api/src/gitea-integration/controllers/import-gitea-issues.ts index e055011a90..0d438466d4 100644 --- a/apps/api/src/gitea-integration/controllers/import-gitea-issues.ts +++ b/apps/api/src/gitea-integration/controllers/import-gitea-issues.ts @@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { activityTable, + externalLinkTable, integrationTable, labelTable, projectTable, @@ -14,6 +15,7 @@ import { isKaneoComment } from "../../plugins/gitea/utils/comment-origin"; import { createGiteaClient, type GiteaIssue, + type GiteaComment, type GiteaLabel, type GiteaPullRequest, } from "../../plugins/gitea/utils/gitea-api"; @@ -29,6 +31,12 @@ import { import { formatTaskDescriptionFromIssue } from "../../plugins/github/utils/format"; import { claimTaskNumber } from "../../task/controllers/claim-task-numbers"; +import { + type IntegrationDatabase, + linkedTaskScope, + withIntegrationTask, +} from "../../plugins/github/services/integration-task-scope"; + type ImportResult = { imported: number; updated: number; @@ -209,71 +217,115 @@ async function importSingleIssue( const priority = extractIssuePriority(adaptedLabels); const status = extractIssueStatus(adaptedLabels); - if (existingLink) { - const updateData: Record = { - title: issue.title, - description: formatTaskDescriptionFromIssue(issue.body), - }; - - if (priority) updateData.priority = priority; - if (status) updateData.status = status; - - await db - .update(taskTable) - .set(updateData) - .where(eq(taskTable.id, existingLink.taskId)); + const comments = await fetchIssueComments(issue.number, config, client); - await importLabelsForTask(labels, existingLink.taskId, workspaceId); - - await importCommentsForTask( - issue.number, + if (existingLink) { + const result = await withIntegrationTask( existingLink.taskId, - config, - client, + { id: integrationId, projectId, project: { workspaceId } }, + async (database, afterCommit) => { + const [linked] = await database + .select({ id: externalLinkTable.id }) + .from(externalLinkTable) + .where( + and( + eq(externalLinkTable.id, existingLink.id), + eq(externalLinkTable.taskId, existingLink.taskId), + eq(externalLinkTable.integrationId, integrationId), + ), + ) + .for("update"); + if (!linked) return "skipped" as const; + + const updateData: Record = { + title: issue.title, + description: formatTaskDescriptionFromIssue(issue.body), + }; + + if (priority) updateData.priority = priority; + if (status) updateData.status = status; + + await database + .update(taskTable) + .set(updateData) + .where(linkedTaskScope(existingLink.taskId, projectId)); + + await importLabelsForTask( + labels, + existingLink.taskId, + workspaceId, + database, + ); + + await importCommentsForTask(comments, existingLink.taskId, database); + + afterCommit(async () => { + for (const type of [ + "task.updated", + "task.labels_updated", + "comment.updated", + ]) + await publishEvent(type, { + projectId, + taskId: existingLink.taskId, + }); + }); + return "updated" as const; + }, ); - - return "updated"; + return result ?? "skipped"; } - const createdTask = await db.transaction(async (tx) => { - const nextNumber = await claimTaskNumber(projectId, tx); + const createdTask = await withIntegrationTask( + null, + { id: integrationId, projectId, project: { workspaceId } }, + async (tx) => { + const nextNumber = await claimTaskNumber(projectId, tx); - const taskValues: typeof taskTable.$inferInsert = { - projectId, - userId: null, - title: issue.title, - description: formatTaskDescriptionFromIssue(issue.body), - status: status || "to-do", - priority: priority ?? "low", - number: nextNumber, - }; + const taskValues: typeof taskTable.$inferInsert = { + projectId, + userId: null, + title: issue.title, + description: formatTaskDescriptionFromIssue(issue.body), + status: status || "to-do", + priority: priority ?? "low", + number: nextNumber, + }; - const [created] = await tx.insert(taskTable).values(taskValues).returning(); + const [created] = await tx + .insert(taskTable) + .values(taskValues) + .returning(); - if (!created) { - throw new Error("Failed to create task"); - } + if (!created) { + throw new Error("Failed to create task"); + } - return created; - }); + await createExternalLink( + { + taskId: created.id, + integrationId, + resourceType: "issue", + externalId: issue.number.toString(), + url: issue.html_url, + title: issue.title, + metadata: { + state: issue.state, + createdFrom: "gitea-import", + author: issue.user?.login ?? issue.user?.username, + }, + }, + tx, + ); - await createExternalLink({ - taskId: createdTask.id, - integrationId, - resourceType: "issue", - externalId: issue.number.toString(), - url: issue.html_url, - title: issue.title, - metadata: { - state: issue.state, - createdFrom: "gitea-import", - author: issue.user?.login ?? issue.user?.username, - }, - }); + await importLabelsForTask(labels, created.id, workspaceId, tx); - await importLabelsForTask(labels, createdTask.id, workspaceId); + await importCommentsForTask(comments, created.id, tx); - await importCommentsForTask(issue.number, createdTask.id, config, client); + return created; + }, + ); + if (!createdTask) return "skipped"; await publishEvent("task.created", { ...createdTask, @@ -293,6 +345,7 @@ async function importLabelsForTask( issueLabels: GiteaIssue["labels"], taskId: string, workspaceId: string, + database: IntegrationDatabase = db, ): Promise { const nonSystemLabels = (issueLabels ?? []) .map((label) => { @@ -316,7 +369,7 @@ async function importLabelsForTask( const expectedNames = nonSystemLabels.map((label) => label.name); if (expectedNames.length > 0) { - await db + await database .delete(labelTable) .where( and( @@ -325,10 +378,10 @@ async function importLabelsForTask( ), ); } else { - await db.delete(labelTable).where(eq(labelTable.taskId, taskId)); + await database.delete(labelTable).where(eq(labelTable.taskId, taskId)); } - const existingLabelsOnTask = await db.query.labelTable.findMany({ + const existingLabelsOnTask = await database.query.labelTable.findMany({ where: expectedNames.length > 0 ? and( @@ -347,7 +400,7 @@ async function importLabelsForTask( continue; } - const existingWorkspaceLabel = await db.query.labelTable.findFirst({ + const existingWorkspaceLabel = await database.query.labelTable.findFirst({ where: and( eq(labelTable.workspaceId, workspaceId), eq(labelTable.name, labelData.name), @@ -356,7 +409,7 @@ async function importLabelsForTask( const colorToUse = existingWorkspaceLabel?.color || labelData.color; - await db + await database .insert(labelTable) .values({ name: labelData.name, @@ -370,18 +423,12 @@ async function importLabelsForTask( } } -async function importCommentsForTask( +async function fetchIssueComments( issueNumber: number, - taskId: string, config: GiteaConfig, client: ReturnType, -): Promise { - const allComments: Array<{ - id: number; - body: string; - html_url: string; - user?: { login?: string; username?: string; avatar_url?: string } | null; - }> = []; +): Promise { + const allComments: GiteaComment[] = []; let page = 1; while (true) { @@ -401,13 +448,21 @@ async function importCommentsForTask( page++; } + return allComments; +} + +async function importCommentsForTask( + allComments: GiteaComment[], + taskId: string, + database: IntegrationDatabase, +): Promise { for (const comment of allComments) { const username = comment.user?.login ?? comment.user?.username ?? ""; if (username.endsWith("[bot]") || isKaneoComment(comment.body)) { continue; } - await db + await database .insert(activityTable) .values({ taskId, @@ -476,4 +531,5 @@ async function linkPullRequestToTask( author: pr.user?.login ?? pr.user?.username, }, }); + await publishEvent("task.updated", { projectId, taskId: task.id }); } diff --git a/apps/api/src/github-integration/controllers/import-issues.ts b/apps/api/src/github-integration/controllers/import-issues.ts index aefebadf61..8c98ca1016 100644 --- a/apps/api/src/github-integration/controllers/import-issues.ts +++ b/apps/api/src/github-integration/controllers/import-issues.ts @@ -2,6 +2,7 @@ import { createId } from "@paralleldrive/cuid2"; import { and, asc, eq, isNull } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; +import { publishEvent } from "../../events"; import { activityTable, columnTable, @@ -45,6 +46,7 @@ import { initialImportState, } from "../import-state"; +type ImportEvent = "task.updated" | "task.labels_updated" | "comment.updated"; type Transaction = Parameters[0]>[0]; async function resolveImportedStatus( tx: Transaction, @@ -172,7 +174,22 @@ export async function importIssues(projectId: string, runId?: string) { }); } const currentRun: typeof githubImportTable.$inferSelect = run; + const notifications = new Map< + string, + { type: ImportEvent; taskId: string } + >(); run = await db.transaction(async (tx) => { + const [currentProject] = await tx + .select() + .from(projectTable) + .where( + and( + eq(projectTable.id, project.id), + eq(projectTable.workspaceId, project.workspaceId), + ), + ) + .for("key share"); + if (!currentProject) throw conflict(); // Serialize against integration changes and webhook issue creation. No // provider request is made while this transaction holds row locks. const [currentIntegration] = await tx @@ -187,7 +204,18 @@ export async function importIssues(projectId: string, runId?: string) { ) throw conflict(); const state = structuredClone(currentRun.state); - await applyPage(tx, payload, state, integration.id, project, config); + await applyPage( + tx, + payload, + state, + integration.id, + project, + config, + (taskId, ...types) => { + for (const type of types) + notifications.set(`${type}:${taskId}`, { type, taskId }); + }, + ); const [saved] = await tx .update(githubImportTable) .set({ state }) @@ -201,6 +229,11 @@ export async function importIssues(projectId: string, runId?: string) { if (!saved) throw conflict(); return saved; }); + // Each bounded page is durable before other clients refresh, including + // continuation pages that change labels, comments or linked resources. + for (const { type, taskId } of notifications.values()) + await publishEvent(type, { projectId, taskId }); + await publishEvent("project.updated", { projectId }); } return importProgress(run.runId, run.state); }); @@ -243,6 +276,7 @@ async function applyPage( integrationId: string, project: typeof projectTable.$inferSelect, config: GitHubConfig, + announce: (taskId: string, ...types: ImportEvent[]) => void, ) { if (state.phase === "issues") { const parsed = issuesPageSchema.safeParse(payload); @@ -283,6 +317,7 @@ async function applyPage( }; await importLabels(tx, issue.labels.nodes, task.id, project.workspaceId); await importComments(tx, issue.comments.nodes, task.id, state.startedAt); + announce(task.id, "task.updated", "task.labels_updated", "comment.updated"); nextIssuePart(state); return; } @@ -297,7 +332,8 @@ async function applyPage( state.phase = "complete"; return; } - await linkPull(tx, pull, integrationId, project, config); + const taskId = await linkPull(tx, pull, integrationId, project, config); + if (taskId) announce(taskId, "task.updated"); } if (!page.pageInfo.hasNextPage || page.nodes.length === 0) state.phase = "complete"; @@ -305,13 +341,29 @@ async function applyPage( } const current = state.currentIssue; if (!current) throw new Error("Import continuation missing"); - const task = await tx.query.taskTable.findFirst({ - where: and( - eq(taskTable.id, current.taskId), - eq(taskTable.projectId, project.id), - ), - }); - if (!task) { + const [task] = await tx + .select() + .from(taskTable) + .where( + and( + eq(taskTable.id, current.taskId), + eq(taskTable.projectId, project.id), + ), + ) + .for("no key update"); + const [linked] = await tx + .select({ id: externalLinkTable.id }) + .from(externalLinkTable) + .where( + and( + eq(externalLinkTable.integrationId, integrationId), + eq(externalLinkTable.resourceType, "issue"), + eq(externalLinkTable.externalId, String(current.number)), + eq(externalLinkTable.taskId, current.taskId), + ), + ) + .for("update"); + if (!task || !linked) { state.skipped++; finishIssue(state); return; @@ -353,6 +405,7 @@ async function applyPage( label.name.startsWith("status:"), ); await importLabels(tx, labels, task.id, project.workspaceId); + announce(task.id, "task.updated", "task.labels_updated"); nextIssuePart(state); } else if (state.phase === "comments") { const parsed = commentsPageSchema.safeParse(payload); @@ -372,6 +425,7 @@ async function applyPage( ); current.moreComments = page.pageInfo.hasNextPage && comments.length > 0; await importComments(tx, comments, task.id, state.startedAt); + announce(task.id, "comment.updated"); nextIssuePart(state); } } @@ -408,13 +462,20 @@ async function importIssue( const priority = extractIssuePriority(issue.labels.nodes); const status = extractIssueStatus(issue.labels.nodes); if (link) { - const task = await tx.query.taskTable.findFirst({ - where: and( - eq(taskTable.id, link.taskId), - eq(taskTable.projectId, projectId), - ), - }); + const [task] = await tx + .select() + .from(taskTable) + .where( + and(eq(taskTable.id, link.taskId), eq(taskTable.projectId, projectId)), + ) + .for("no key update"); if (!task) return null; + const [linked] = await tx + .select({ id: externalLinkTable.id }) + .from(externalLinkTable) + .where(eq(externalLinkTable.id, link.id)) + .for("update"); + if (!linked) return null; const [updated] = await tx .update(taskTable) .set({ @@ -540,6 +601,12 @@ async function linkPull( database: tx, }); if (!task) return; + const [scopedTask] = await tx + .select({ id: taskTable.id }) + .from(taskTable) + .where(and(eq(taskTable.id, task.id), eq(taskTable.projectId, project.id))) + .for("share"); + if (!scopedTask) return; await tx.insert(externalLinkTable).values({ taskId: task.id, integrationId, @@ -553,4 +620,5 @@ async function linkPull( author: pull.author?.login, }), }); + return task.id; } diff --git a/apps/api/src/gitlab-integration/controllers/import-gitlab-issues.ts b/apps/api/src/gitlab-integration/controllers/import-gitlab-issues.ts index 55deb9420c..c8db74d5bd 100644 --- a/apps/api/src/gitlab-integration/controllers/import-gitlab-issues.ts +++ b/apps/api/src/gitlab-integration/controllers/import-gitlab-issues.ts @@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { activityTable, + externalLinkTable, integrationTable, labelTable, projectTable, @@ -22,6 +23,7 @@ import { resolveMergeRequestTask } from "../../plugins/gitlab/services/resolve-m import { createGitlabClient, type GitlabIssue, + type GitlabNote, type GitlabMergeRequest, } from "../../plugins/gitlab/utils/gitlab-api"; import { taskDescriptionFromIssue } from "../../plugins/gitlab/utils/issue-description"; @@ -38,6 +40,12 @@ type ImportResult = { type GitlabClient = ReturnType; const PER_PAGE = 100; +import { + type IntegrationDatabase, + linkedTaskScope, + withIntegrationTask, +} from "../../plugins/github/services/integration-task-scope"; + const MAX_PAGES = 50; export async function importGitlabIssues( @@ -185,64 +193,113 @@ async function importSingleIssue( const priority = extractIssuePriority(labels); const status = extractIssueStatus(labels); - if (existingLink) { - const updateData: Record = { - title: issue.title, - description: taskDescriptionFromIssue(issue.description), - }; - - if (priority) updateData.priority = priority; - if (status) updateData.status = status; - - await db - .update(taskTable) - .set(updateData) - .where(eq(taskTable.id, existingLink.taskId)); - - await importLabelsForTask(labels, existingLink.taskId, workspaceId); - await importNotesForTask(issue, existingLink.taskId, config, client); + const notes = await fetchIssueNotes(issue, config, client); - return "updated"; + if (existingLink) { + const result = await withIntegrationTask( + existingLink.taskId, + { id: integrationId, projectId, project: { workspaceId } }, + async (database, afterCommit) => { + const [linked] = await database + .select({ id: externalLinkTable.id }) + .from(externalLinkTable) + .where( + and( + eq(externalLinkTable.id, existingLink.id), + eq(externalLinkTable.taskId, existingLink.taskId), + eq(externalLinkTable.integrationId, integrationId), + ), + ) + .for("update"); + if (!linked) return "skipped" as const; + + const updateData: Record = { + title: issue.title, + description: taskDescriptionFromIssue(issue.description), + }; + + if (priority) updateData.priority = priority; + if (status) updateData.status = status; + + await database + .update(taskTable) + .set(updateData) + .where(linkedTaskScope(existingLink.taskId, projectId)); + + await importLabelsForTask( + labels, + existingLink.taskId, + workspaceId, + database, + ); + await importNotesForTask(issue, notes, existingLink.taskId, database); + + afterCommit(async () => { + for (const type of [ + "task.updated", + "task.labels_updated", + "comment.updated", + ]) + await publishEvent(type, { + projectId, + taskId: existingLink.taskId, + }); + }); + return "updated" as const; + }, + ); + return result ?? "skipped"; } - const createdTask = await db.transaction(async (tx) => { - const number = await claimTaskNumber(projectId, tx); + const createdTask = await withIntegrationTask( + null, + { id: integrationId, projectId, project: { workspaceId } }, + async (tx) => { + const number = await claimTaskNumber(projectId, tx); - const taskValues: typeof taskTable.$inferInsert = { - projectId, - userId: null, - title: issue.title, - description: taskDescriptionFromIssue(issue.description), - status: status || "to-do", - priority: priority ?? "low", - number, - }; - - const [created] = await tx.insert(taskTable).values(taskValues).returning(); + const taskValues: typeof taskTable.$inferInsert = { + projectId, + userId: null, + title: issue.title, + description: taskDescriptionFromIssue(issue.description), + status: status || "to-do", + priority: priority ?? "low", + number, + }; + + const [created] = await tx + .insert(taskTable) + .values(taskValues) + .returning(); + + if (!created) { + throw new Error("Failed to create task"); + } - if (!created) { - throw new Error("Failed to create task"); - } + await createExternalLink( + { + taskId: created.id, + integrationId, + resourceType: "issue", + externalId: issue.iid.toString(), + url: issue.web_url, + title: issue.title, + metadata: { + state: issue.state, + createdFrom: "gitlab-import", + author: issue.author?.username ?? issue.author?.name, + }, + }, + tx, + ); - return created; - }); + await importLabelsForTask(labels, created.id, workspaceId, tx); + await importNotesForTask(issue, notes, created.id, tx); - await createExternalLink({ - taskId: createdTask.id, - integrationId, - resourceType: "issue", - externalId: issue.iid.toString(), - url: issue.web_url, - title: issue.title, - metadata: { - state: issue.state, - createdFrom: "gitlab-import", - author: issue.author?.username ?? issue.author?.name, + return created; }, - }); - - await importLabelsForTask(labels, createdTask.id, workspaceId); - await importNotesForTask(issue, createdTask.id, config, client); + ); + if (!createdTask) return "skipped"; await publishEvent("task.created", { ...createdTask, @@ -262,6 +319,7 @@ async function importLabelsForTask( issueLabels: string[], taskId: string, workspaceId: string, + database: IntegrationDatabase = db, ): Promise { const names = issueLabels.filter((name) => name && !isSystemLabelName(name)); @@ -270,7 +328,7 @@ async function importLabelsForTask( return; } - const existingLabelsOnTask = await db.query.labelTable.findMany({ + const existingLabelsOnTask = await database.query.labelTable.findMany({ where: and(eq(labelTable.taskId, taskId), inArray(labelTable.name, names)), }); @@ -279,14 +337,14 @@ async function importLabelsForTask( continue; } - const existingWorkspaceLabel = await db.query.labelTable.findFirst({ + const existingWorkspaceLabel = await database.query.labelTable.findFirst({ where: and( eq(labelTable.workspaceId, workspaceId), eq(labelTable.name, name), ), }); - await db + await database .insert(labelTable) .values({ name, @@ -300,12 +358,12 @@ async function importLabelsForTask( } } -async function importNotesForTask( +async function fetchIssueNotes( issue: GitlabIssue, - taskId: string, config: GitlabConfig, client: GitlabClient, -): Promise { +): Promise { + const allNotes: GitlabNote[] = []; for (let page = 1; page <= MAX_PAGES; page++) { const notes = await client.listIssueNotes( config.projectPath, @@ -316,39 +374,48 @@ async function importNotesForTask( if (notes.length === 0) break; - for (const note of notes) { - // Skip system notes (label/state changes) and internal notes. - if (note.system || note.internal) { - continue; - } + allNotes.push(...notes); + if (notes.length < PER_PAGE) break; + } + return allNotes; +} - const username = note.author?.username ?? note.author?.name ?? ""; - - await db - .insert(activityTable) - .values({ - taskId, - type: "comment", - content: note.body, - externalUserName: username || "Unknown", - externalUserAvatar: note.author?.avatar_url ?? null, - externalSource: "gitlab", - // The notes API has no URL, so link to the anchor on the issue page. - externalUrl: `${issue.web_url}#note_${note.id}`, - eventData: { - externalCommentId: note.id, - }, - }) - .onConflictDoNothing({ - target: [ - activityTable.taskId, - activityTable.externalSource, - activityTable.externalUrl, - ], - }); +async function importNotesForTask( + issue: GitlabIssue, + notes: GitlabNote[], + taskId: string, + database: IntegrationDatabase, +): Promise { + for (const note of notes) { + // Skip system notes (label/state changes) and internal notes. + if (note.system || note.internal) { + continue; } - if (notes.length < PER_PAGE) break; + const username = note.author?.username ?? note.author?.name ?? ""; + + await database + .insert(activityTable) + .values({ + taskId, + type: "comment", + content: note.body, + externalUserName: username || "Unknown", + externalUserAvatar: note.author?.avatar_url ?? null, + externalSource: "gitlab", + // The notes API has no URL, so link to the anchor on the issue page. + externalUrl: `${issue.web_url}#note_${note.id}`, + eventData: { + externalCommentId: note.id, + }, + }) + .onConflictDoNothing({ + target: [ + activityTable.taskId, + activityTable.externalSource, + activityTable.externalUrl, + ], + }); } } @@ -401,4 +468,5 @@ async function linkMergeRequestToTask( author: mergeRequest.author?.username ?? mergeRequest.author?.name, }, }); + await publishEvent("task.updated", { projectId, taskId: task.id }); } diff --git a/apps/api/src/plugins/gitea/utils/resolve-column.ts b/apps/api/src/plugins/gitea/utils/resolve-column.ts index 12928a3dae..138744826c 100644 --- a/apps/api/src/plugins/gitea/utils/resolve-column.ts +++ b/apps/api/src/plugins/gitea/utils/resolve-column.ts @@ -6,8 +6,9 @@ export async function resolveTargetStatus( projectId: string, eventType: string, fallbackStatus: string, + database: Pick = db, ): Promise { - const projectColumns = await db + const projectColumns = await database .select({ id: columnTable.id, slug: columnTable.slug, @@ -20,7 +21,7 @@ export async function resolveTargetStatus( return fallbackStatus; } - const rule = await db.query.workflowRuleTable.findFirst({ + const rule = await database.query.workflowRuleTable.findFirst({ where: and( eq(workflowRuleTable.projectId, projectId), eq(workflowRuleTable.integrationType, "gitea"), diff --git a/apps/api/src/plugins/gitea/webhooks/issue-closed.ts b/apps/api/src/plugins/gitea/webhooks/issue-closed.ts index 1aa0e8a1b4..a8e0104864 100644 --- a/apps/api/src/plugins/gitea/webhooks/issue-closed.ts +++ b/apps/api/src/plugins/gitea/webhooks/issue-closed.ts @@ -1,6 +1,8 @@ +import { withIntegrationLink } from "../../github/services/with-integration-link"; +import { linkedTaskScope } from "../../github/services/integration-task-scope"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; -import { externalLinkTable, taskTable } from "../../../database/schema"; +import { externalLinkTable } from "../../../database/schema"; import { publishEvent } from "../../../events"; import { updateExternalLink } from "../../github/services/link-manager"; import { updateTaskStatus } from "../../github/services/task-service"; @@ -65,69 +67,85 @@ export async function handleGiteaIssueClosed( continue; } - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, externalLink.taskId), - }); + await withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope(externalLink.taskId, integration.projectId), + }); - if (!task) { - continue; - } + if (!task) { + return; + } - let existingMetadata: Record = {}; - if (externalLink.metadata) { - try { - existingMetadata = JSON.parse(externalLink.metadata) as Record< - string, - unknown - >; - } catch (error) { - console.warn("Failed to parse Gitea issue metadata for close sync", { - externalLinkId: externalLink.id, - metadata: externalLink.metadata, - error, - }); - } - } + let existingMetadata: Record = {}; + if (externalLink.metadata) { + try { + existingMetadata = JSON.parse(externalLink.metadata) as Record< + string, + unknown + >; + } catch (error) { + console.warn( + "Failed to parse Gitea issue metadata for close sync", + { + externalLinkId: externalLink.id, + metadata: externalLink.metadata, + error, + }, + ); + } + } - const lastOutbound = existingMetadata.lastOutboundStateSyncAt; - if (typeof lastOutbound === "number" && Number.isFinite(lastOutbound)) { - const eventMs = parseIssueUpdatedAtMs(issue); - if ( - eventMs !== null && - Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS - ) { - continue; - } - } + const lastOutbound = existingMetadata.lastOutboundStateSyncAt; + if (typeof lastOutbound === "number" && Number.isFinite(lastOutbound)) { + const eventMs = parseIssueUpdatedAtMs(issue); + if ( + eventMs !== null && + Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS + ) { + return; + } + } - const targetStatus = await resolveTargetStatus( - task.projectId, - "issue_closed", - "done", - ); + const targetStatus = await resolveTargetStatus( + task.projectId, + "issue_closed", + "done", + db, + ); - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } + const statusResult = await updateTaskStatus(task.id, targetStatus, db); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } - await updateExternalLink(externalLink.id, { - metadata: { - ...existingMetadata, - state: "closed", + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "closed", + }, + }, + db, + ); }, - }); + ); } } diff --git a/apps/api/src/plugins/gitea/webhooks/issue-comment-created.ts b/apps/api/src/plugins/gitea/webhooks/issue-comment-created.ts index b6fd060e55..be4d1bf2df 100644 --- a/apps/api/src/plugins/gitea/webhooks/issue-comment-created.ts +++ b/apps/api/src/plugins/gitea/webhooks/issue-comment-created.ts @@ -1,4 +1,4 @@ -import db from "../../../database"; +import { withIntegrationLink } from "../../github/services/with-integration-link"; import { activityTable } from "../../../database/schema"; import { findExternalLink } from "../../github/services/link-manager"; import { @@ -68,26 +68,32 @@ export async function handleGiteaIssueCommentCreated( continue; } - await db - .insert(activityTable) - .values({ - taskId: existingLink.taskId, - type: "comment", - content: comment.body, - externalUserName: username || "Unknown", - externalUserAvatar: comment.user?.avatar_url ?? null, - externalSource: "gitea", - externalUrl: comment.html_url, - eventData: { - externalCommentId: comment.id, - }, - }) - .onConflictDoNothing({ - target: [ - activityTable.taskId, - activityTable.externalSource, - activityTable.externalUrl, - ], - }); + await withIntegrationLink( + existingLink, + integration, + async (db, _afterCommit, existingLink) => { + await db + .insert(activityTable) + .values({ + taskId: existingLink.taskId, + type: "comment", + content: comment.body, + externalUserName: username || "Unknown", + externalUserAvatar: comment.user?.avatar_url ?? null, + externalSource: "gitea", + externalUrl: comment.html_url, + eventData: { + externalCommentId: comment.id, + }, + }) + .onConflictDoNothing({ + target: [ + activityTable.taskId, + activityTable.externalSource, + activityTable.externalUrl, + ], + }); + }, + ); } } diff --git a/apps/api/src/plugins/gitea/webhooks/issue-edited.ts b/apps/api/src/plugins/gitea/webhooks/issue-edited.ts index b609982eb2..87c2d377b0 100644 --- a/apps/api/src/plugins/gitea/webhooks/issue-edited.ts +++ b/apps/api/src/plugins/gitea/webhooks/issue-edited.ts @@ -1,5 +1,6 @@ -import { eq } from "drizzle-orm"; -import db from "../../../database"; +import { publishEvent } from "../../../events"; +import { withIntegrationLink } from "../../github/services/with-integration-link"; +import { linkedTaskScope } from "../../github/services/integration-task-scope"; import { taskTable } from "../../../database/schema"; import { findExternalLink, @@ -63,98 +64,114 @@ export async function handleGiteaIssueEdited( continue; } - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, externalLink.taskId), - }); + await withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope(externalLink.taskId, integration.projectId), + }); - if (!task) { - continue; - } - - const metadata = externalLink.metadata - ? JSON.parse(externalLink.metadata) - : {}; - - const updateData: Record = {}; - const updatedMetadata = { ...metadata }; - - if (!updatedMetadata.lastSync) { - updatedMetadata.lastSync = {}; - } + if (!task) { + return; + } - if (changes.title) { - const lastTitleSync = metadata.lastSync?.title; + const metadata = externalLink.metadata + ? JSON.parse(externalLink.metadata) + : {}; - let shouldUpdateTitle = true; + const updateData: Record = {}; + const updatedMetadata = { ...metadata }; - if (lastTitleSync) { - if ( - lastTitleSync.value === issue.title && - lastTitleSync.source === "kaneo" - ) { - shouldUpdateTitle = false; + if (!updatedMetadata.lastSync) { + updatedMetadata.lastSync = {}; } - const timeSinceLastSync = - Date.now() - new Date(lastTitleSync.timestamp).getTime(); - if (timeSinceLastSync < 2000 && shouldUpdateTitle) { - shouldUpdateTitle = false; + if (changes.title) { + const lastTitleSync = metadata.lastSync?.title; + + let shouldUpdateTitle = true; + + if (lastTitleSync) { + if ( + lastTitleSync.value === issue.title && + lastTitleSync.source === "kaneo" + ) { + shouldUpdateTitle = false; + } + const timeSinceLastSync = + Date.now() - new Date(lastTitleSync.timestamp).getTime(); + if (timeSinceLastSync < 2000 && shouldUpdateTitle) { + shouldUpdateTitle = false; + } + } + + if (shouldUpdateTitle) { + updateData.title = issue.title; + updatedMetadata.lastSync.title = { + timestamp: new Date().toISOString(), + source: "gitea", + value: issue.title, + }; + } } - } - - if (shouldUpdateTitle) { - updateData.title = issue.title; - updatedMetadata.lastSync.title = { - timestamp: new Date().toISOString(), - source: "gitea", - value: issue.title, - }; - } - } - if (changes.body) { - const lastDescSync = metadata.lastSync?.description; - const formattedDescription = formatTaskDescriptionFromIssue(issue.body); - - let shouldUpdateDescription = true; - - if (lastDescSync) { - if ( - lastDescSync.value === formattedDescription && - lastDescSync.source === "kaneo" - ) { - shouldUpdateDescription = false; + if (changes.body) { + const lastDescSync = metadata.lastSync?.description; + const formattedDescription = formatTaskDescriptionFromIssue( + issue.body, + ); + + let shouldUpdateDescription = true; + + if (lastDescSync) { + if ( + lastDescSync.value === formattedDescription && + lastDescSync.source === "kaneo" + ) { + shouldUpdateDescription = false; + } + const timeSinceLastSync = + Date.now() - new Date(lastDescSync.timestamp).getTime(); + if (timeSinceLastSync < 2000 && shouldUpdateDescription) { + shouldUpdateDescription = false; + } + } + + if (shouldUpdateDescription) { + updateData.description = formattedDescription; + updatedMetadata.lastSync.description = { + timestamp: new Date().toISOString(), + source: "gitea", + value: formattedDescription, + }; + } } - const timeSinceLastSync = - Date.now() - new Date(lastDescSync.timestamp).getTime(); - if (timeSinceLastSync < 2000 && shouldUpdateDescription) { - shouldUpdateDescription = false; + if (Object.keys(updateData).length > 0) { + await db + .update(taskTable) + .set(updateData) + .where(linkedTaskScope(task.id, integration.projectId)); + + await updateExternalLink( + externalLink.id, + { + title: issue.title, + metadata: updatedMetadata, + }, + db, + ); + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: externalLink.taskId, + }), + ); } - } - - if (shouldUpdateDescription) { - updateData.description = formattedDescription; - updatedMetadata.lastSync.description = { - timestamp: new Date().toISOString(), - source: "gitea", - value: formattedDescription, - }; - } - } - - if (Object.keys(updateData).length > 0) { - await db - .update(taskTable) - .set(updateData) - .where(eq(taskTable.id, task.id)); - await updateExternalLink(externalLink.id, { - title: issue.title, - metadata: updatedMetadata, - }); - } - - return; + return; + }, + ); } } diff --git a/apps/api/src/plugins/gitea/webhooks/issue-labeled.ts b/apps/api/src/plugins/gitea/webhooks/issue-labeled.ts index f0057cba4a..db17fdf433 100644 --- a/apps/api/src/plugins/gitea/webhooks/issue-labeled.ts +++ b/apps/api/src/plugins/gitea/webhooks/issue-labeled.ts @@ -1,5 +1,9 @@ +import { withIntegrationLink } from "../../github/services/with-integration-link"; +import { + type IntegrationDatabase, + linkedTaskScope, +} from "../../github/services/integration-task-scope"; import { eq, inArray } from "drizzle-orm"; -import db from "../../../database"; import { labelTable, taskTable } from "../../../database/schema"; import { publishEvent } from "../../../events"; import { findExternalLink } from "../../github/services/link-manager"; @@ -56,6 +60,7 @@ async function syncGiteaLabelsToTask( taskId: string, workspaceId: string, giteaLabels: Array<{ name: string; color?: string }>, + db: IntegrationDatabase, ) { const desiredNames = new Set(giteaLabels.map((l) => l.name)); const existingRows = await db.query.labelTable.findMany({ @@ -141,117 +146,161 @@ export async function handleGiteaIssueLabeled( continue; } - const priority = extractIssuePriority(issue.labels); - const status = extractIssueStatus(issue.labels); + await withIntegrationLink( + existingLink, + integration, + async (db, afterCommit, existingLink) => { + const priority = extractIssuePriority(issue.labels); + const status = extractIssueStatus(issue.labels); - if (priority) { - await db - .update(taskTable) - .set({ priority }) - .where(eq(taskTable.id, existingLink.taskId)); - } + if (priority) { + await db + .update(taskTable) + .set({ priority }) + .where( + linkedTaskScope(existingLink.taskId, integration.projectId), + ); + } - if (status) { - const statusResult = await updateTaskStatus( - existingLink.taskId, - status, - ); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } - } + if (status) { + const statusResult = await updateTaskStatus( + existingLink.taskId, + status, + db, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + } - if (payload.action === "label_updated") { - if (issue.labels === undefined) { - continue; - } + if (payload.action === "label_updated") { + if (issue.labels === undefined) { + return; + } - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, existingLink.taskId), - with: { - project: true, - }, - }); - if (task?.project?.workspaceId) { - await syncGiteaLabelsToTask( - existingLink.taskId, - task.project.workspaceId, - giteaLabelsForSync(issue.labels), - ); - } - continue; - } - - if (!addedLabel) { - continue; - } + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope( + existingLink.taskId, + integration.projectId, + ), + with: { + project: true, + }, + }); + if (task?.project?.workspaceId) { + await syncGiteaLabelsToTask( + existingLink.taskId, + task.project.workspaceId, + giteaLabelsForSync(issue.labels), + db, + ); + afterCommit(() => + publishEvent("task.labels_updated", { + projectId: integration.projectId, + taskId: existingLink.taskId, + }), + ); + } + return; + } - if (isSystemLabelName(addedLabel.name)) { - continue; - } + if (!addedLabel) { + if (priority) + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: existingLink.taskId, + }), + ); + return; + } - if (payload.action === "labeled") { - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, existingLink.taskId), - with: { - project: true, - }, - }); + if (isSystemLabelName(addedLabel.name)) { + if (priority) + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: existingLink.taskId, + }), + ); + return; + } - if (task?.project?.workspaceId) { - const existingLabel = await db.query.labelTable.findFirst({ - where: (table, { and, eq: e }) => - and( - e(table.workspaceId, task.project.workspaceId), - e(table.name, addedLabel.name), - e(table.taskId, task.id), + if (payload.action === "labeled") { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope( + existingLink.taskId, + integration.projectId, ), - }); + with: { + project: true, + }, + }); - if (!existingLabel) { - const color = addedLabel.color - ? `#${addedLabel.color.replace(/^#/, "")}` - : "#6B7280"; - await db - .insert(labelTable) - .values({ - name: addedLabel.name, - color, - taskId: task.id, - workspaceId: task.project.workspaceId, - }) - .onConflictDoNothing({ - target: [labelTable.taskId, labelTable.name], + if (task?.project?.workspaceId) { + const existingLabel = await db.query.labelTable.findFirst({ + where: (table, { and, eq: e }) => + and( + e(table.workspaceId, task.project.workspaceId), + e(table.name, addedLabel.name), + e(table.taskId, task.id), + ), }); + + if (!existingLabel) { + const color = addedLabel.color + ? `#${addedLabel.color.replace(/^#/, "")}` + : "#6B7280"; + await db + .insert(labelTable) + .values({ + name: addedLabel.name, + color, + taskId: task.id, + workspaceId: task.project.workspaceId, + }) + .onConflictDoNothing({ + target: [labelTable.taskId, labelTable.name], + }); + } + } } - } - } - if (payload.action === "unlabeled") { - const labelsToDelete = await db.query.labelTable.findMany({ - where: (table, { and, eq: e }) => - and( - e(table.taskId, existingLink.taskId), - e(table.name, addedLabel.name), - ), - }); + if (payload.action === "unlabeled") { + const labelsToDelete = await db.query.labelTable.findMany({ + where: (table, { and, eq: e }) => + and( + e(table.taskId, existingLink.taskId), + e(table.name, addedLabel.name), + ), + }); - for (const label of labelsToDelete) { - await db.delete(labelTable).where(eq(labelTable.id, label.id)); - } - } + for (const label of labelsToDelete) { + await db.delete(labelTable).where(eq(labelTable.id, label.id)); + } + } + afterCommit(() => + publishEvent("task.labels_updated", { + projectId: integration.projectId, + taskId: existingLink.taskId, + }), + ); + }, + ); } catch (error) { console.error("Gitea issue_labeled handler failed for integration", { integrationId: integration.id, diff --git a/apps/api/src/plugins/gitea/webhooks/issue-reopened.ts b/apps/api/src/plugins/gitea/webhooks/issue-reopened.ts index 0e853b867e..2f6dd84607 100644 --- a/apps/api/src/plugins/gitea/webhooks/issue-reopened.ts +++ b/apps/api/src/plugins/gitea/webhooks/issue-reopened.ts @@ -1,6 +1,8 @@ +import { withIntegrationLink } from "../../github/services/with-integration-link"; +import { linkedTaskScope } from "../../github/services/integration-task-scope"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; -import { externalLinkTable, taskTable } from "../../../database/schema"; +import { externalLinkTable } from "../../../database/schema"; import { publishEvent } from "../../../events"; import { updateExternalLink } from "../../github/services/link-manager"; import { updateTaskStatus } from "../../github/services/task-service"; @@ -66,70 +68,93 @@ export async function handleGiteaIssueReopened( continue; } - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, externalLink.taskId), - }); + await withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope(externalLink.taskId, integration.projectId), + }); - if (!task) { - continue; - } + if (!task) { + return; + } - let existingMetadata: Record = {}; - if (externalLink.metadata) { - try { - existingMetadata = JSON.parse(externalLink.metadata) as Record< - string, - unknown - >; - } catch (error) { - console.warn("Failed to parse Gitea issue metadata for reopen sync", { - externalLinkId: externalLink.id, - metadata: externalLink.metadata, - error, - }); - } - } + let existingMetadata: Record = {}; + if (externalLink.metadata) { + try { + existingMetadata = JSON.parse(externalLink.metadata) as Record< + string, + unknown + >; + } catch (error) { + console.warn( + "Failed to parse Gitea issue metadata for reopen sync", + { + externalLinkId: externalLink.id, + metadata: externalLink.metadata, + error, + }, + ); + } + } - const lastOutbound = existingMetadata.lastOutboundStateSyncAt; - if (typeof lastOutbound === "number" && Number.isFinite(lastOutbound)) { - const eventMs = parseIssueUpdatedAtMs(issue); - if ( - eventMs !== null && - Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS - ) { - continue; - } - } + const lastOutbound = existingMetadata.lastOutboundStateSyncAt; + if ( + typeof lastOutbound === "number" && + Number.isFinite(lastOutbound) + ) { + const eventMs = parseIssueUpdatedAtMs(issue); + if ( + eventMs !== null && + Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS + ) { + return; + } + } - const targetStatus = await resolveTargetStatus( - task.projectId, - "issue_reopened", - "to-do", - ); + const targetStatus = await resolveTargetStatus( + task.projectId, + "issue_reopened", + "to-do", + db, + ); - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + db, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } - await updateExternalLink(externalLink.id, { - metadata: { - ...existingMetadata, - state: "open", + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "open", + }, + }, + db, + ); }, - }); + ); } catch (error) { console.error("Gitea issue_reopened handler failed for integration", { integrationId: integration.id, diff --git a/apps/api/src/plugins/gitea/webhooks/pull-request-closed.ts b/apps/api/src/plugins/gitea/webhooks/pull-request-closed.ts index 6bb4aedf0b..4b9790f606 100644 --- a/apps/api/src/plugins/gitea/webhooks/pull-request-closed.ts +++ b/apps/api/src/plugins/gitea/webhooks/pull-request-closed.ts @@ -1,3 +1,4 @@ +import { withIntegrationLink } from "../../github/services/with-integration-link"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; import { externalLinkTable } from "../../../database/schema"; @@ -67,63 +68,87 @@ export async function handleGiteaPullRequestClosed( continue; } - const task = await findTaskById(externalLink.taskId); + await withIntegrationLink( + externalLink, + integration, + async (database, afterCommit, lockedLink) => { + const task = await findTaskById(externalLink.taskId, database); - if (!task) { - continue; - } + if (!task) { + return; + } - const existingMetadata = externalLink.metadata - ? JSON.parse(externalLink.metadata) - : {}; + const existingMetadata = lockedLink.metadata + ? JSON.parse(lockedLink.metadata) + : {}; - await updateExternalLink(externalLink.id, { - metadata: { - ...existingMetadata, - state: "closed", - merged: pull_request.merged, - mergedAt: pull_request.merged_at, - }, - }); + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "closed", + merged: pull_request.merged, + mergedAt: pull_request.merged_at, + }, + }, + database, + ); - if (pull_request.merged) { - const allTaskPRs = await db.query.externalLinkTable.findMany({ - where: and( - eq(externalLinkTable.taskId, task.id), - eq(externalLinkTable.resourceType, "pull_request"), - ), - }); - - const hasOpenPRs = allTaskPRs.some((pr) => { - if (pr.id === externalLink.id) return false; - const metadata = pr.metadata ? JSON.parse(pr.metadata) : {}; - return metadata.state === "open"; - }); - - if (!hasOpenPRs) { - const targetStatus = await resolveTargetStatus( - integration.projectId, - "pr_merged", - config.statusTransitions?.onPRMerge || "done", + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: task.id, + }), ); - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", + + if (pull_request.merged) { + const allTaskPRs = await database.query.externalLinkTable.findMany({ + where: and( + eq(externalLinkTable.taskId, task.id), + eq(externalLinkTable.resourceType, "pull_request"), + ), + }); + + const hasOpenPRs = allTaskPRs.some((pr) => { + if (pr.id === externalLink.id) return false; + const metadata = pr.metadata ? JSON.parse(pr.metadata) : {}; + return metadata.state === "open"; }); + + if (!hasOpenPRs) { + const targetStatus = await resolveTargetStatus( + integration.projectId, + "pr_merged", + config.statusTransitions?.onPRMerge || "done", + database, + ); + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + database, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + } } - } - } + }, + ); return; } diff --git a/apps/api/src/plugins/gitea/webhooks/pull-request-opened.ts b/apps/api/src/plugins/gitea/webhooks/pull-request-opened.ts index 8cd4bf1299..4edff4679a 100644 --- a/apps/api/src/plugins/gitea/webhooks/pull-request-opened.ts +++ b/apps/api/src/plugins/gitea/webhooks/pull-request-opened.ts @@ -1,3 +1,4 @@ +import { withIntegrationTask } from "../../github/services/integration-task-scope"; import { publishEvent } from "../../../events"; import { createExternalLink, @@ -92,44 +93,86 @@ export async function handleGiteaPullRequestOpened( if (integrationId && integration.id !== integrationId) return; const branchName = pull_request.head.ref; - await createExternalLink({ - taskId: task.id, - integrationId: integration.id, - resourceType: "pull_request", - externalId: pull_request.number.toString(), - url: pull_request.html_url, - title: pull_request.title, - metadata: { - state: pull_request.state, - draft: pull_request.draft, - merged: pull_request.merged, - branch: branchName, - author: pull_request.user?.login ?? pull_request.user?.username, - }, - }); + await withIntegrationTask( + task.id, + integration, + async (database, afterCommit) => { + const currentTask = await resolvePullRequestTask({ + integrationId: integration.id, + projectId: integration.projectId, + projectSlug: integration.project.slug, + config, + repositoryUrl: repository.html_url, + pullRequest: pull_request, + database, + }); + if (currentTask?.id !== task.id) return; + if ( + await findExternalLink( + integration.id, + "pull_request", + pull_request.number.toString(), + database, + ) + ) + return; + await createExternalLink( + { + taskId: task.id, + integrationId: integration.id, + resourceType: "pull_request", + externalId: pull_request.number.toString(), + url: pull_request.html_url, + title: pull_request.title, + metadata: { + state: pull_request.state, + draft: pull_request.draft, + merged: pull_request.merged, + branch: branchName, + author: pull_request.user?.login ?? pull_request.user?.username, + }, + }, + database, + ); - const targetStatus = await resolveTargetStatus( - integration.projectId, - "pr_opened", - config.statusTransitions?.onPROpen || "in-review", - ); + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: task.id, + }), + ); - if (task.status !== targetStatus) { - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } - } + const targetStatus = await resolveTargetStatus( + integration.projectId, + "pr_opened", + config.statusTransitions?.onPROpen || "in-review", + database, + ); + + if (currentTask.status !== targetStatus) { + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + database, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + } + }, + ); } diff --git a/apps/api/src/plugins/gitea/webhooks/push.ts b/apps/api/src/plugins/gitea/webhooks/push.ts index ede741dba6..1f8f55eec3 100644 --- a/apps/api/src/plugins/gitea/webhooks/push.ts +++ b/apps/api/src/plugins/gitea/webhooks/push.ts @@ -1,3 +1,7 @@ +import { + linkedTaskScope, + withIntegrationTask, +} from "../../github/services/integration-task-scope"; import { publishEvent } from "../../../events"; import { createOrUpdateExternalLink } from "../../github/services/link-manager"; import { @@ -121,51 +125,82 @@ export async function handleGiteaPush( const treeUrl = `${repository.html_url}/src/branch/${branchName}`; - const branchLink = await createOrUpdateExternalLink({ - taskId: task.id, - integrationId: integration.id, - resourceType: "branch", - externalId: branchName, - url: treeUrl, - title: branchName, - metadata: { - lastCommit: headCommit - ? { - sha: headCommit.id, - message: headCommit.message, - author: headCommit.author?.name, - timestamp: - "timestamp" in headCommit ? headCommit.timestamp : undefined, - } - : null, + const taskId = task.id; + await withIntegrationTask( + taskId, + integration, + async (database, afterCommit) => { + const current = await database.query.taskTable.findFirst({ + where: linkedTaskScope(taskId, integration.projectId), + }); + if (!current) return; + const task = current; + const branchLink = await createOrUpdateExternalLink( + { + taskId: task.id, + integrationId: integration.id, + resourceType: "branch", + externalId: branchName, + url: treeUrl, + title: branchName, + metadata: { + lastCommit: headCommit + ? { + sha: headCommit.id, + message: headCommit.message, + author: headCommit.author?.name, + timestamp: + "timestamp" in headCommit + ? headCommit.timestamp + : undefined, + } + : null, + }, + }, + database, + ); + + const targetStatus = await resolveTargetStatus( + integration.projectId, + "branch_push", + config.statusTransitions?.onBranchPush || "in-progress", + database, + ); + + const canMove = + branchLink.created || !(await isTaskInFinalState(task, database)); + + if (task.status !== targetStatus && canMove) { + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + database, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + } + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: task.id, + }), + ); }, - }); - - const targetStatus = await resolveTargetStatus( - integration.projectId, - "branch_push", - config.statusTransitions?.onBranchPush || "in-progress", ); - - const canMove = branchLink.created || !(await isTaskInFinalState(task)); - - if (task.status !== targetStatus && canMove) { - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } - } } } diff --git a/apps/api/src/plugins/github/services/integration-task-scope.ts b/apps/api/src/plugins/github/services/integration-task-scope.ts new file mode 100644 index 0000000000..1db5efb30b --- /dev/null +++ b/apps/api/src/plugins/github/services/integration-task-scope.ts @@ -0,0 +1,74 @@ +import { and, eq, sql } from "drizzle-orm"; +import db from "../../../database"; +import { + externalLinkTable, + integrationTable, + projectTable, + taskTable, +} from "../../../database/schema"; + +export type IntegrationDatabase = + | typeof db + | Parameters[0]>[0]; +type IntegrationScope = { + id: string; + projectId: string; + project?: { workspaceId: string }; +}; + +// Key-share protects the project/workspace key while allowing task-number +// allocation. Task locks protect their project until related writes commit. +export async function withIntegrationTask( + taskId: string | null, + integration: IntegrationScope, + apply: ( + database: IntegrationDatabase, + afterCommit: (effect: () => Promise) => void, + ) => Promise, +): Promise { + const effects: Array<() => Promise> = []; + const result = await db.transaction(async (tx) => { + const [project] = await tx + .select({ id: projectTable.id }) + .from(projectTable) + .innerJoin( + integrationTable, + eq(integrationTable.projectId, projectTable.id), + ) + .where( + and( + eq(integrationTable.id, integration.id), + eq(projectTable.id, integration.projectId), + integration.project + ? eq(projectTable.workspaceId, integration.project.workspaceId) + : undefined, + ), + ) + .for("key share", { of: [projectTable, integrationTable] }); + if (!project) return undefined; + if (taskId !== null) { + const [task] = await tx + .select({ id: taskTable.id }) + .from(taskTable) + .where( + and( + eq(taskTable.id, taskId), + eq(taskTable.projectId, integration.projectId), + ), + ) + .for("no key update"); + if (!task) return undefined; + } + return apply(tx, (effect) => effects.push(effect)); + }); + for (const effect of effects) await effect(); + return result; +} + +export function linkedTaskScope(taskId: string, projectId: string) { + return and(eq(taskTable.id, taskId), eq(taskTable.projectId, projectId)); +} + +export function externalLinkScope() { + return sql`exists (select 1 from ${taskTable} scoped_task join ${integrationTable} scoped_integration on scoped_integration.project_id = scoped_task.project_id where scoped_task.id = ${externalLinkTable.taskId} and scoped_integration.id = ${externalLinkTable.integrationId})`; +} diff --git a/apps/api/src/plugins/github/services/link-manager.ts b/apps/api/src/plugins/github/services/link-manager.ts index abf8ba1b7f..bcde2fcc18 100644 --- a/apps/api/src/plugins/github/services/link-manager.ts +++ b/apps/api/src/plugins/github/services/link-manager.ts @@ -1,6 +1,12 @@ import { and, eq } from "drizzle-orm"; import db from "../../../database"; -import { externalLinkTable } from "../../../database/schema"; +import { + externalLinkTable, + integrationTable, + taskTable, +} from "../../../database/schema"; + +import { externalLinkScope } from "./integration-task-scope"; type DbOrTx = typeof db | Parameters[0]>[0]; @@ -24,6 +30,26 @@ export async function createExternalLink( params: CreateExternalLinkParams, database: DbOrTx = db, ): Promise<{ id: string }> { + if (database === db) { + return db.transaction((tx) => createExternalLink(params, tx)); + } + const [task] = await database + .select({ id: taskTable.id }) + .from(taskTable) + .innerJoin( + integrationTable, + eq(integrationTable.projectId, taskTable.projectId), + ) + .where( + and( + eq(taskTable.id, params.taskId), + eq(integrationTable.id, params.integrationId), + ), + ) + .for("share", { of: taskTable }); + if (!task) + throw new Error("Task no longer belongs to the integration project"); + const result = await database .insert(externalLinkTable) .values({ @@ -56,6 +82,7 @@ export async function findExternalLink( eq(externalLinkTable.integrationId, integrationId), eq(externalLinkTable.resourceType, resourceType), eq(externalLinkTable.externalId, externalId), + externalLinkScope(), ), }); } @@ -70,13 +97,14 @@ export async function findExternalLinkByTaskAndType( eq(externalLinkTable.taskId, taskId), eq(externalLinkTable.integrationId, integrationId), eq(externalLinkTable.resourceType, resourceType), + externalLinkScope(), ), }); } export async function findExternalLinksByTask(taskId: string) { return db.query.externalLinkTable.findMany({ - where: eq(externalLinkTable.taskId, taskId), + where: and(eq(externalLinkTable.taskId, taskId), externalLinkScope()), with: { integration: true, }, @@ -86,6 +114,7 @@ export async function findExternalLinksByTask(taskId: string) { export async function updateExternalLink( id: string, params: UpdateExternalLinkParams, + database: DbOrTx = db, ) { const updateData: Record = {}; @@ -103,7 +132,7 @@ export async function updateExternalLink( return; } - await db + await database .update(externalLinkTable) .set(updateData) .where(eq(externalLinkTable.id, id)); @@ -111,23 +140,29 @@ export async function updateExternalLink( export async function createOrUpdateExternalLink( params: CreateExternalLinkParams, + database: DbOrTx = db, ): Promise<{ id: string; created: boolean }> { const existing = await findExternalLink( params.integrationId, params.resourceType, params.externalId, + database, ); if (existing) { - await updateExternalLink(existing.id, { - title: params.title, - url: params.url, - metadata: params.metadata, - }); + await updateExternalLink( + existing.id, + { + title: params.title, + url: params.url, + metadata: params.metadata, + }, + database, + ); return { id: existing.id, created: false }; } - const link = await createExternalLink(params); + const link = await createExternalLink(params, database); return { id: link.id, created: true }; } diff --git a/apps/api/src/plugins/github/services/task-service.ts b/apps/api/src/plugins/github/services/task-service.ts index 7d49daa2cf..995042f719 100644 --- a/apps/api/src/plugins/github/services/task-service.ts +++ b/apps/api/src/plugins/github/services/task-service.ts @@ -9,6 +9,8 @@ import { import { type GitHubConfig, hasVerifiedGitHubBinding } from "../config"; import { extractTaskLinks } from "../utils/task-references"; +import type { IntegrationDatabase } from "./integration-task-scope"; + export type TaskRow = InferSelectModel; export type UpdateTaskStatusResult = @@ -17,8 +19,12 @@ export type UpdateTaskStatusResult = const NON_COLUMN_STATUSES = new Set(["planned", "archived"]); -export async function findTaskByNumber(projectId: string, taskNumber: number) { - return db.query.taskTable.findFirst({ +export async function findTaskByNumber( + projectId: string, + taskNumber: number, + database: Pick = db, +) { + return database.query.taskTable.findFirst({ where: and( eq(taskTable.projectId, projectId), eq(taskTable.number, taskNumber), @@ -43,8 +49,11 @@ export async function findTaskByLink( }); } -export async function findTaskById(taskId: string) { - return db.query.taskTable.findFirst({ +export async function findTaskById( + taskId: string, + database: Pick = db, +) { + return database.query.taskTable.findFirst({ where: eq(taskTable.id, taskId), }); } @@ -52,8 +61,9 @@ export async function findTaskById(taskId: string) { export async function updateTaskStatus( taskId: string, newStatus: string, + database: IntegrationDatabase = db, ): Promise { - const task = await db.query.taskTable.findFirst({ + const task = await database.query.taskTable.findFirst({ where: eq(taskTable.id, taskId), }); @@ -63,7 +73,7 @@ export async function updateTaskStatus( let columnId: string | null = null; - const column = await db.query.columnTable.findFirst({ + const column = await database.query.columnTable.findFirst({ where: and( eq(columnTable.projectId, task.projectId), eq(columnTable.slug, newStatus), @@ -79,14 +89,13 @@ export async function updateTaskStatus( return { applied: false }; } - await db + const [after] = await database .update(taskTable) .set({ status: newStatus, columnId }) - .where(eq(taskTable.id, taskId)); - - const after = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, taskId), - }); + .where( + and(eq(taskTable.id, taskId), eq(taskTable.projectId, task.projectId)), + ) + .returning(); if (!after) { return { applied: false }; @@ -95,13 +104,16 @@ export async function updateTaskStatus( return { applied: true, before: task, after }; } -export async function isTaskInFinalState(task: { - projectId: string; - status: string; - columnId: string | null; -}): Promise { +export async function isTaskInFinalState( + task: { + projectId: string; + status: string; + columnId: string | null; + }, + database: Pick = db, +): Promise { if (task.columnId) { - const columnById = await db.query.columnTable.findFirst({ + const columnById = await database.query.columnTable.findFirst({ where: and( eq(columnTable.id, task.columnId), eq(columnTable.projectId, task.projectId), @@ -113,7 +125,7 @@ export async function isTaskInFinalState(task: { } } - const columnByStatus = await db.query.columnTable.findFirst({ + const columnByStatus = await database.query.columnTable.findFirst({ where: and( eq(columnTable.projectId, task.projectId), eq(columnTable.slug, task.status), diff --git a/apps/api/src/plugins/github/services/with-integration-link.ts b/apps/api/src/plugins/github/services/with-integration-link.ts new file mode 100644 index 0000000000..bc65a35131 --- /dev/null +++ b/apps/api/src/plugins/github/services/with-integration-link.ts @@ -0,0 +1,36 @@ +import { and, eq } from "drizzle-orm"; +import { externalLinkTable } from "../../../database/schema"; +import { + withIntegrationTask, + type IntegrationDatabase, +} from "./integration-task-scope"; + +export function withIntegrationLink( + link: { id: string; taskId: string }, + integration: Parameters[1], + apply: ( + database: IntegrationDatabase, + afterCommit: (effect: () => Promise) => void, + lockedLink: typeof externalLinkTable.$inferSelect, + ) => Promise, +) { + return withIntegrationTask( + link.taskId, + integration, + async (database, afterCommit) => { + const [lockedLink] = await database + .select() + .from(externalLinkTable) + .where( + and( + eq(externalLinkTable.id, link.id), + eq(externalLinkTable.taskId, link.taskId), + eq(externalLinkTable.integrationId, integration.id), + ), + ) + .for("update"); + if (!lockedLink) return; + return apply(database, afterCommit, lockedLink); + }, + ); +} diff --git a/apps/api/src/plugins/github/webhooks/issue-closed.ts b/apps/api/src/plugins/github/webhooks/issue-closed.ts index 9a8933b9ad..dca9644028 100644 --- a/apps/api/src/plugins/github/webhooks/issue-closed.ts +++ b/apps/api/src/plugins/github/webhooks/issue-closed.ts @@ -1,6 +1,8 @@ +import { withIntegrationLink } from "../services/with-integration-link"; +import { linkedTaskScope } from "../services/integration-task-scope"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; -import { externalLinkTable, taskTable } from "../../../database/schema"; +import { externalLinkTable } from "../../../database/schema"; import { publishEvent } from "../../../events"; import { updateExternalLink } from "../services/link-manager"; import { @@ -45,51 +47,64 @@ export async function handleIssueClosed(payload: IssueClosedPayload) { continue; } - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, externalLink.taskId), - }); + await withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope(externalLink.taskId, integration.projectId), + }); - if (!task) { - continue; - } + if (!task) { + return; + } - const existingMetadata = parseLinkMetadata(externalLink.metadata, { - externalLinkId: externalLink.id, - source: "issue_closed", - }); + const existingMetadata = parseLinkMetadata(externalLink.metadata, { + externalLinkId: externalLink.id, + source: "issue_closed", + }); - if (existingMetadata.createdFrom === "kaneo") { - continue; - } + if (existingMetadata.createdFrom === "kaneo") { + return; + } - const targetStatus = await resolveTargetStatus( - task.projectId, - "issue_closed", - "done", - ); + const targetStatus = await resolveTargetStatus( + task.projectId, + "issue_closed", + "done", + db, + ); - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } + const statusResult = await updateTaskStatus(task.id, targetStatus, db); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } - await updateExternalLink(externalLink.id, { - metadata: { - ...existingMetadata, - state: "closed", + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "closed", + }, + }, + db, + ); }, - }); + ); } } diff --git a/apps/api/src/plugins/github/webhooks/issue-comment-created.ts b/apps/api/src/plugins/github/webhooks/issue-comment-created.ts index ff88fa658e..23071dc0e7 100644 --- a/apps/api/src/plugins/github/webhooks/issue-comment-created.ts +++ b/apps/api/src/plugins/github/webhooks/issue-comment-created.ts @@ -1,4 +1,4 @@ -import db from "../../../database"; +import { withIntegrationLink } from "../services/with-integration-link"; import { activityTable } from "../../../database/schema"; import { findExternalLink } from "../services/link-manager"; import { findAllIntegrationsByRepo } from "../services/task-service"; @@ -53,25 +53,31 @@ export async function handleIssueCommentCreated( continue; } - await db - .insert(activityTable) - .values({ - taskId: existingLink.taskId, - type: "comment", - content: comment.body, - externalUserName: comment.user?.login ?? "Unknown", - externalUserAvatar: comment.user?.avatar_url ?? null, - externalSource: "github", - externalUrl: comment.html_url, - }) - .onConflictDoNothing({ - target: [ - activityTable.taskId, - activityTable.externalSource, - activityTable.externalUrl, - ], - }); + await withIntegrationLink( + existingLink, + integration, + async (db, _afterCommit, existingLink) => { + await db + .insert(activityTable) + .values({ + taskId: existingLink.taskId, + type: "comment", + content: comment.body, + externalUserName: comment.user?.login ?? "Unknown", + externalUserAvatar: comment.user?.avatar_url ?? null, + externalSource: "github", + externalUrl: comment.html_url, + }) + .onConflictDoNothing({ + target: [ + activityTable.taskId, + activityTable.externalSource, + activityTable.externalUrl, + ], + }); - return; + return; + }, + ); } } diff --git a/apps/api/src/plugins/github/webhooks/issue-edited.ts b/apps/api/src/plugins/github/webhooks/issue-edited.ts index 8847206bc6..4d40cc2426 100644 --- a/apps/api/src/plugins/github/webhooks/issue-edited.ts +++ b/apps/api/src/plugins/github/webhooks/issue-edited.ts @@ -1,5 +1,6 @@ -import { eq } from "drizzle-orm"; -import db from "../../../database"; +import { publishEvent } from "../../../events"; +import { withIntegrationLink } from "../services/with-integration-link"; +import { linkedTaskScope } from "../services/integration-task-scope"; import { taskTable } from "../../../database/schema"; import { findExternalLink, updateExternalLink } from "../services/link-manager"; import { findAllIntegrationsByRepo } from "../services/task-service"; @@ -69,128 +70,142 @@ export async function handleIssueEdited(payload: IssueEditedPayload) { continue; } - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, externalLink.taskId), - }); - - if (!task) { - console.error(`Task ${externalLink.taskId} not found`); - continue; - } - - const metadata = parseLinkMetadata( - externalLink.metadata, - { - externalLinkId: externalLink.id, - source: "issue_edited", - }, - ); - - const updateData: Record = {}; - const updatedMetadata: IssueEditedMetadata = { ...metadata }; + await withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope(externalLink.taskId, integration.projectId), + }); + + if (!task) { + console.error(`Task ${externalLink.taskId} not found`); + return; + } - if (!updatedMetadata.lastSync) { - updatedMetadata.lastSync = {}; - } + const metadata = parseLinkMetadata( + externalLink.metadata, + { + externalLinkId: externalLink.id, + source: "issue_edited", + }, + ); - if (changes.title) { - const lastTitleSync = metadata.lastSync?.title; + const updateData: Record = {}; + const updatedMetadata: IssueEditedMetadata = { ...metadata }; - let shouldUpdateTitle = true; + if (!updatedMetadata.lastSync) { + updatedMetadata.lastSync = {}; + } - if (lastTitleSync) { - if ( - lastTitleSync.value === issue.title && - lastTitleSync.source === "kaneo" - ) { - console.log("Skipping title update - already synced from Kaneo"); - shouldUpdateTitle = false; + if (changes.title) { + const lastTitleSync = metadata.lastSync?.title; + + let shouldUpdateTitle = true; + + if (lastTitleSync) { + if ( + lastTitleSync.value === issue.title && + lastTitleSync.source === "kaneo" + ) { + console.log("Skipping title update - already synced from Kaneo"); + shouldUpdateTitle = false; + } + const timeSinceLastSync = + Date.now() - new Date(lastTitleSync.timestamp ?? 0).getTime(); + if (timeSinceLastSync < 2000 && shouldUpdateTitle) { + console.log( + `Skipping title update - recent sync detected (${timeSinceLastSync}ms ago)`, + ); + shouldUpdateTitle = false; + } + } + + if (shouldUpdateTitle) { + updateData.title = issue.title; + updatedMetadata.lastSync.title = { + timestamp: new Date().toISOString(), + source: "github", + value: issue.title, + }; + console.log( + `Updating task title from GitHub: "${changes.title.from}" → "${issue.title}"`, + ); + } } - const timeSinceLastSync = - Date.now() - new Date(lastTitleSync.timestamp ?? 0).getTime(); - if (timeSinceLastSync < 2000 && shouldUpdateTitle) { - console.log( - `Skipping title update - recent sync detected (${timeSinceLastSync}ms ago)`, + if (changes.body) { + const lastDescSync = metadata.lastSync?.description; + const formattedDescription = formatTaskDescriptionFromIssue( + issue.body, + task.id, ); - shouldUpdateTitle = false; - } - } - - if (shouldUpdateTitle) { - updateData.title = issue.title; - updatedMetadata.lastSync.title = { - timestamp: new Date().toISOString(), - source: "github", - value: issue.title, - }; - console.log( - `Updating task title from GitHub: "${changes.title.from}" → "${issue.title}"`, - ); - } - } - if (changes.body) { - const lastDescSync = metadata.lastSync?.description; - const formattedDescription = formatTaskDescriptionFromIssue( - issue.body, - task.id, - ); + let shouldUpdateDescription = true; + + if (lastDescSync) { + if ( + lastDescSync.value === formattedDescription && + lastDescSync.source === "kaneo" + ) { + console.log( + "Skipping description update - already synced from Kaneo", + ); + shouldUpdateDescription = false; + } + const timeSinceLastSync = + Date.now() - new Date(lastDescSync.timestamp ?? 0).getTime(); + if (timeSinceLastSync < 2000 && shouldUpdateDescription) { + console.log( + `Skipping description update - recent sync detected (${timeSinceLastSync}ms ago)`, + ); + shouldUpdateDescription = false; + } + } + + if (shouldUpdateDescription) { + updateData.description = formattedDescription; + updatedMetadata.lastSync.description = { + timestamp: new Date().toISOString(), + source: "github", + value: formattedDescription, + }; + console.log("Updating task description from GitHub"); + } + } - let shouldUpdateDescription = true; + if (Object.keys(updateData).length > 0) { + await db + .update(taskTable) + .set(updateData) + .where(linkedTaskScope(task.id, integration.projectId)); + + await updateExternalLink( + externalLink.id, + { + title: issue.title, + metadata: updatedMetadata, + }, + db, + ); + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: externalLink.taskId, + }), + ); - if (lastDescSync) { - if ( - lastDescSync.value === formattedDescription && - lastDescSync.source === "kaneo" - ) { console.log( - "Skipping description update - already synced from Kaneo", + `Synced ${Object.keys(updateData).join(", ")} from GitHub issue #${issue.number} to task ${task.id}`, ); - shouldUpdateDescription = false; - } - - const timeSinceLastSync = - Date.now() - new Date(lastDescSync.timestamp ?? 0).getTime(); - if (timeSinceLastSync < 2000 && shouldUpdateDescription) { + } else { console.log( - `Skipping description update - recent sync detected (${timeSinceLastSync}ms ago)`, + `No updates needed for task ${task.id} from issue #${issue.number}`, ); - shouldUpdateDescription = false; } - } - - if (shouldUpdateDescription) { - updateData.description = formattedDescription; - updatedMetadata.lastSync.description = { - timestamp: new Date().toISOString(), - source: "github", - value: formattedDescription, - }; - console.log("Updating task description from GitHub"); - } - } - - if (Object.keys(updateData).length > 0) { - await db - .update(taskTable) - .set(updateData) - .where(eq(taskTable.id, task.id)); - - await updateExternalLink(externalLink.id, { - title: issue.title, - metadata: updatedMetadata, - }); - - console.log( - `Synced ${Object.keys(updateData).join(", ")} from GitHub issue #${issue.number} to task ${task.id}`, - ); - } else { - console.log( - `No updates needed for task ${task.id} from issue #${issue.number}`, - ); - } - return; + return; + }, + ); } } diff --git a/apps/api/src/plugins/github/webhooks/issue-labeled.ts b/apps/api/src/plugins/github/webhooks/issue-labeled.ts index 63252bec6b..c8a664550e 100644 --- a/apps/api/src/plugins/github/webhooks/issue-labeled.ts +++ b/apps/api/src/plugins/github/webhooks/issue-labeled.ts @@ -1,5 +1,6 @@ +import { withIntegrationLink } from "../services/with-integration-link"; +import { linkedTaskScope } from "../services/integration-task-scope"; import { eq } from "drizzle-orm"; -import db from "../../../database"; import { labelTable, taskTable } from "../../../database/schema"; import { publishEvent } from "../../../events"; import { findExternalLink } from "../services/link-manager"; @@ -46,96 +47,130 @@ export async function handleIssueLabeled(payload: IssueLabeledPayload) { continue; } - const priority = extractIssuePriority(issue.labels); - const status = extractIssueStatus(issue.labels); + await withIntegrationLink( + existingLink, + integration, + async (db, afterCommit, existingLink) => { + const priority = extractIssuePriority(issue.labels); + const status = extractIssueStatus(issue.labels); - if (priority) { - await db - .update(taskTable) - .set({ priority }) - .where(eq(taskTable.id, existingLink.taskId)); - } - - if (status) { - const statusResult = await updateTaskStatus(existingLink.taskId, status); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } - } + if (priority) { + await db + .update(taskTable) + .set({ priority }) + .where(linkedTaskScope(existingLink.taskId, integration.projectId)); + } - if (!addedLabel) { - return; - } + if (status) { + const statusResult = await updateTaskStatus( + existingLink.taskId, + status, + db, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + } - const isSystemLabel = - addedLabel.name.startsWith("priority:") || - addedLabel.name.startsWith("status:"); + if (!addedLabel) { + if (priority) + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: existingLink.taskId, + }), + ); + return; + } - if (isSystemLabel) { - return; - } + const isSystemLabel = + addedLabel.name.startsWith("priority:") || + addedLabel.name.startsWith("status:"); + + if (isSystemLabel) { + if (priority) + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: existingLink.taskId, + }), + ); + return; + } - if (payload.action === "labeled") { - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, existingLink.taskId), - with: { - project: true, - }, - }); - - if (task?.project?.workspaceId) { - const existingLabel = await db.query.labelTable.findFirst({ - where: (table, { and, eq }) => - and( - eq(table.workspaceId, task.project.workspaceId), - eq(table.name, addedLabel.name), - eq(table.taskId, task.id), - ), - }); - - if (!existingLabel) { - const color = addedLabel.color ? `#${addedLabel.color}` : "#6B7280"; - await db - .insert(labelTable) - .values({ - name: addedLabel.name, - color, - taskId: task.id, - workspaceId: task.project.workspaceId, - }) - .onConflictDoNothing({ - target: [labelTable.taskId, labelTable.name], + if (payload.action === "labeled") { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope(existingLink.taskId, integration.projectId), + with: { + project: true, + }, + }); + + if (task?.project?.workspaceId) { + const existingLabel = await db.query.labelTable.findFirst({ + where: (table, { and, eq }) => + and( + eq(table.workspaceId, task.project.workspaceId), + eq(table.name, addedLabel.name), + eq(table.taskId, task.id), + ), }); + + if (!existingLabel) { + const color = addedLabel.color + ? `#${addedLabel.color}` + : "#6B7280"; + await db + .insert(labelTable) + .values({ + name: addedLabel.name, + color, + taskId: task.id, + workspaceId: task.project.workspaceId, + }) + .onConflictDoNothing({ + target: [labelTable.taskId, labelTable.name], + }); + } + } } - } - } - if (payload.action === "unlabeled") { - const labelsToDelete = await db.query.labelTable.findMany({ - where: (table, { and, eq }) => - and( - eq(table.taskId, existingLink.taskId), - eq(table.name, addedLabel.name), - ), - }); - - for (const label of labelsToDelete) { - await db.delete(labelTable).where(eq(labelTable.id, label.id)); - } - } + if (payload.action === "unlabeled") { + const labelsToDelete = await db.query.labelTable.findMany({ + where: (table, { and, eq }) => + and( + eq(table.taskId, existingLink.taskId), + eq(table.name, addedLabel.name), + ), + }); + + for (const label of labelsToDelete) { + await db.delete(labelTable).where(eq(labelTable.id, label.id)); + } + } - return; + afterCommit(() => + publishEvent("task.labels_updated", { + projectId: integration.projectId, + taskId: existingLink.taskId, + }), + ); + return; + }, + ); } } diff --git a/apps/api/src/plugins/github/webhooks/issue-opened.ts b/apps/api/src/plugins/github/webhooks/issue-opened.ts index 02029446db..9051982bfc 100644 --- a/apps/api/src/plugins/github/webhooks/issue-opened.ts +++ b/apps/api/src/plugins/github/webhooks/issue-opened.ts @@ -1,5 +1,6 @@ import { and, eq } from "drizzle-orm"; import db from "../../../database"; +import { publishEvent } from "../../../events"; import { columnTable, integrationTable, @@ -130,6 +131,16 @@ export async function handleIssueOpened(payload: IssueOpenedPayload) { return task; }); if (!createdTask) continue; + await publishEvent("task.created", { + ...createdTask, + taskId: createdTask.id, + userId: createdTask.userId ?? "", + type: "task", + content: null, + source: "github", + externalId: issue.number.toString(), + actor: issue.user?.login ?? "github-webhook", + }); const project = await db.query.projectTable.findFirst({ where: eq(projectTable.id, projectId), diff --git a/apps/api/src/plugins/github/webhooks/issue-reopened.ts b/apps/api/src/plugins/github/webhooks/issue-reopened.ts index ffc08b920f..ff22d069f9 100644 --- a/apps/api/src/plugins/github/webhooks/issue-reopened.ts +++ b/apps/api/src/plugins/github/webhooks/issue-reopened.ts @@ -1,6 +1,8 @@ +import { withIntegrationLink } from "../services/with-integration-link"; +import { linkedTaskScope } from "../services/integration-task-scope"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; -import { externalLinkTable, taskTable } from "../../../database/schema"; +import { externalLinkTable } from "../../../database/schema"; import { publishEvent } from "../../../events"; import { updateExternalLink } from "../services/link-manager"; import { @@ -45,54 +47,67 @@ export async function handleIssueReopened(payload: IssueReopenedPayload) { continue; } - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, externalLink.taskId), - }); + await withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope(externalLink.taskId, integration.projectId), + }); - if (!task) { - continue; - } + if (!task) { + return; + } - const existingMetadata = parseLinkMetadata(externalLink.metadata, { - externalLinkId: externalLink.id, - source: "issue_reopened", - }); + const existingMetadata = parseLinkMetadata(externalLink.metadata, { + externalLinkId: externalLink.id, + source: "issue_reopened", + }); - if ( - existingMetadata.createdFrom === "kaneo" || - existingMetadata.state === "open" - ) { - continue; - } + if ( + existingMetadata.createdFrom === "kaneo" || + existingMetadata.state === "open" + ) { + return; + } - const targetStatus = await resolveTargetStatus( - task.projectId, - "issue_reopened", - "to-do", - ); + const targetStatus = await resolveTargetStatus( + task.projectId, + "issue_reopened", + "to-do", + db, + ); - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } + const statusResult = await updateTaskStatus(task.id, targetStatus, db); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } - await updateExternalLink(externalLink.id, { - metadata: { - ...existingMetadata, - state: "open", + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "open", + }, + }, + db, + ); }, - }); + ); } } diff --git a/apps/api/src/plugins/github/webhooks/pull-request-closed.ts b/apps/api/src/plugins/github/webhooks/pull-request-closed.ts index 99affb901b..e059d1612f 100644 --- a/apps/api/src/plugins/github/webhooks/pull-request-closed.ts +++ b/apps/api/src/plugins/github/webhooks/pull-request-closed.ts @@ -1,3 +1,4 @@ +import { withIntegrationLink } from "../services/with-integration-link"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; import { externalLinkTable } from "../../../database/schema"; @@ -53,67 +54,91 @@ export async function handlePullRequestClosed(payload: PRClosedPayload) { continue; } - const task = await findTaskById(externalLink.taskId); + await withIntegrationLink( + externalLink, + integration, + async (database, afterCommit, lockedLink) => { + const task = await findTaskById(externalLink.taskId, database); - if (!task) { - continue; - } - - const existingMetadata = parseLinkMetadata(externalLink.metadata, { - externalLinkId: externalLink.id, - source: "pull_request_closed", - }); - - await updateExternalLink(externalLink.id, { - metadata: { - ...existingMetadata, - state: "closed", - merged: pull_request.merged, - mergedAt: pull_request.merged_at, - }, - }); - - if (pull_request.merged) { - const allTaskPRs = await db.query.externalLinkTable.findMany({ - where: and( - eq(externalLinkTable.taskId, task.id), - eq(externalLinkTable.resourceType, "pull_request"), - ), - }); + if (!task) { + return; + } - const hasOpenPRs = allTaskPRs.some((pr) => { - if (pr.id === externalLink.id) return false; - const metadata = parseLinkMetadata(pr.metadata, { - externalLinkId: pr.id, + const existingMetadata = parseLinkMetadata(lockedLink.metadata, { + externalLinkId: externalLink.id, source: "pull_request_closed", }); - return metadata.state === "open"; - }); - if (!hasOpenPRs) { - const targetStatus = await resolveTargetStatus( - integration.projectId, - "pr_merged", - config.statusTransitions?.onPRMerge || "done", + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "closed", + merged: pull_request.merged, + mergedAt: pull_request.merged_at, + }, + }, + database, ); - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", + + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: task.id, + }), + ); + + if (pull_request.merged) { + const allTaskPRs = await database.query.externalLinkTable.findMany({ + where: and( + eq(externalLinkTable.taskId, task.id), + eq(externalLinkTable.resourceType, "pull_request"), + ), + }); + + const hasOpenPRs = allTaskPRs.some((pr) => { + if (pr.id === externalLink.id) return false; + const metadata = parseLinkMetadata(pr.metadata, { + externalLinkId: pr.id, + source: "pull_request_closed", + }); + return metadata.state === "open"; }); + + if (!hasOpenPRs) { + const targetStatus = await resolveTargetStatus( + integration.projectId, + "pr_merged", + config.statusTransitions?.onPRMerge || "done", + database, + ); + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + database, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + } } - } - } + }, + ); return; } diff --git a/apps/api/src/plugins/github/webhooks/pull-request-opened.ts b/apps/api/src/plugins/github/webhooks/pull-request-opened.ts index 71f2506b38..ce3e362adb 100644 --- a/apps/api/src/plugins/github/webhooks/pull-request-opened.ts +++ b/apps/api/src/plugins/github/webhooks/pull-request-opened.ts @@ -1,3 +1,4 @@ +import { withIntegrationTask } from "../services/integration-task-scope"; import { publishEvent } from "../../../events"; import type { GitHubConfig } from "../config"; import { createExternalLink, findExternalLink } from "../services/link-manager"; @@ -66,44 +67,86 @@ export async function handlePullRequestOpened(payload: PROpenedPayload) { const { integration, config, task } = candidate; const branchName = pull_request.head.ref; - await createExternalLink({ - taskId: task.id, - integrationId: integration.id, - resourceType: "pull_request", - externalId: pull_request.number.toString(), - url: pull_request.html_url, - title: pull_request.title, - metadata: { - state: pull_request.state, - draft: pull_request.draft, - merged: pull_request.merged, - branch: branchName, - author: pull_request.user?.login, - }, - }); + await withIntegrationTask( + task.id, + integration, + async (database, afterCommit) => { + const currentTask = await resolvePullRequestTask({ + integrationId: integration.id, + projectId: integration.projectId, + projectSlug: integration.project.slug, + config, + repositoryUrl: `https://github.com/${repository.owner.login}/${repository.name}`, + pullRequest: pull_request, + database, + }); + if (currentTask?.id !== task.id) return; + if ( + await findExternalLink( + integration.id, + "pull_request", + pull_request.number.toString(), + database, + ) + ) + return; + await createExternalLink( + { + taskId: task.id, + integrationId: integration.id, + resourceType: "pull_request", + externalId: pull_request.number.toString(), + url: pull_request.html_url, + title: pull_request.title, + metadata: { + state: pull_request.state, + draft: pull_request.draft, + merged: pull_request.merged, + branch: branchName, + author: pull_request.user?.login, + }, + }, + database, + ); - const targetStatus = await resolveTargetStatus( - integration.projectId, - "pr_opened", - config.statusTransitions?.onPROpen || "in-review", - ); + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: task.id, + }), + ); - if (task.status !== targetStatus) { - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } - } + const targetStatus = await resolveTargetStatus( + integration.projectId, + "pr_opened", + config.statusTransitions?.onPROpen || "in-review", + database, + ); + + if (currentTask.status !== targetStatus) { + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + database, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + } + }, + ); } diff --git a/apps/api/src/plugins/github/webhooks/push.ts b/apps/api/src/plugins/github/webhooks/push.ts index 6d6333a152..c2a553ebd1 100644 --- a/apps/api/src/plugins/github/webhooks/push.ts +++ b/apps/api/src/plugins/github/webhooks/push.ts @@ -1,3 +1,7 @@ +import { + linkedTaskScope, + withIntegrationTask, +} from "../../github/services/integration-task-scope"; import { publishEvent } from "../../../events"; import type { GitHubConfig } from "../config"; import { createOrUpdateExternalLink } from "../services/link-manager"; @@ -109,59 +113,89 @@ export async function handlePush(payload: PushPayload) { `[Push] Found task: ${task.id}, current status: ${task.status}`, ); - const branchLink = await createOrUpdateExternalLink({ - taskId: task.id, - integrationId: integration.id, - resourceType: "branch", - externalId: branchName, - url: `${repository.html_url}/tree/${branchName}`, - title: branchName, - metadata: { - lastCommit: head_commit - ? { - sha: head_commit.id, - message: head_commit.message, - author: head_commit.author?.name, - timestamp: head_commit.timestamp, - } - : null, + const taskId = task.id; + await withIntegrationTask( + taskId, + integration, + async (database, afterCommit) => { + const current = await database.query.taskTable.findFirst({ + where: linkedTaskScope(taskId, integration.projectId), + }); + if (!current) return; + const task = current; + const branchLink = await createOrUpdateExternalLink( + { + taskId: task.id, + integrationId: integration.id, + resourceType: "branch", + externalId: branchName, + url: `${repository.html_url}/tree/${branchName}`, + title: branchName, + metadata: { + lastCommit: head_commit + ? { + sha: head_commit.id, + message: head_commit.message, + author: head_commit.author?.name, + timestamp: head_commit.timestamp, + } + : null, + }, + }, + database, + ); + + const targetStatus = await resolveTargetStatus( + integration.projectId, + "branch_push", + config.statusTransitions?.onBranchPush || "in-progress", + database, + ); + console.log( + `[Push] Target status: ${targetStatus}, current: ${task.status}`, + ); + + const canMove = + branchLink.created || !(await isTaskInFinalState(task, database)); + + if (task.status !== targetStatus && canMove) { + console.log( + `[Push] Updating task ${task.id} status from ${task.status} to ${targetStatus}`, + ); + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + database, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + } else { + console.log(`[Push] Skipping status update - already ${task.status}`); + } + + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: task.id, + }), + ); }, - }); - - const targetStatus = await resolveTargetStatus( - integration.projectId, - "branch_push", - config.statusTransitions?.onBranchPush || "in-progress", ); - console.log( - `[Push] Target status: ${targetStatus}, current: ${task.status}`, - ); - - const canMove = branchLink.created || !(await isTaskInFinalState(task)); - - if (task.status !== targetStatus && canMove) { - console.log( - `[Push] Updating task ${task.id} status from ${task.status} to ${targetStatus}`, - ); - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } - } else { - console.log(`[Push] Skipping status update - already ${task.status}`); - } return; } diff --git a/apps/api/src/plugins/gitlab/services/resolve-merge-request-task.ts b/apps/api/src/plugins/gitlab/services/resolve-merge-request-task.ts index caae838ff8..8caa59ea57 100644 --- a/apps/api/src/plugins/gitlab/services/resolve-merge-request-task.ts +++ b/apps/api/src/plugins/gitlab/services/resolve-merge-request-task.ts @@ -1,3 +1,4 @@ +import db from "../../../database"; import { findTaskByLink, findTaskByNumber, @@ -10,7 +11,9 @@ export async function resolveMergeRequestTask({ projectSlug, config, mergeRequest, + database = db, }: { + database?: Pick; projectId: string; projectSlug: string; config: GitlabConfig; @@ -28,11 +31,15 @@ export async function resolveMergeRequestTask({ projectSlug, ); const numberedTask = taskNumber - ? await findTaskByNumber(projectId, taskNumber) + ? await findTaskByNumber(projectId, taskNumber, database) : undefined; return ( numberedTask ?? - findTaskByLink(projectId, [mergeRequest.title, mergeRequest.description]) + findTaskByLink( + projectId, + [mergeRequest.title, mergeRequest.description], + database, + ) ); } diff --git a/apps/api/src/plugins/gitlab/utils/resolve-column.ts b/apps/api/src/plugins/gitlab/utils/resolve-column.ts index 27e6c74cf3..70cee551ae 100644 --- a/apps/api/src/plugins/gitlab/utils/resolve-column.ts +++ b/apps/api/src/plugins/gitlab/utils/resolve-column.ts @@ -6,8 +6,9 @@ export async function resolveTargetStatus( projectId: string, eventType: string, fallbackStatus: string, + database: Pick = db, ): Promise { - const projectColumns = await db + const projectColumns = await database .select({ id: columnTable.id, slug: columnTable.slug, @@ -20,7 +21,7 @@ export async function resolveTargetStatus( return fallbackStatus; } - const rule = await db.query.workflowRuleTable.findFirst({ + const rule = await database.query.workflowRuleTable.findFirst({ where: and( eq(workflowRuleTable.projectId, projectId), eq(workflowRuleTable.integrationType, "gitlab"), diff --git a/apps/api/src/plugins/gitlab/webhooks/issue-closed.ts b/apps/api/src/plugins/gitlab/webhooks/issue-closed.ts index 328d8c150c..405d794f76 100644 --- a/apps/api/src/plugins/gitlab/webhooks/issue-closed.ts +++ b/apps/api/src/plugins/gitlab/webhooks/issue-closed.ts @@ -1,6 +1,8 @@ +import { withIntegrationLink } from "../../github/services/with-integration-link"; +import { linkedTaskScope } from "../../github/services/integration-task-scope"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; -import { externalLinkTable, taskTable } from "../../../database/schema"; +import { externalLinkTable } from "../../../database/schema"; import { publishEvent } from "../../../events"; import { updateExternalLink } from "../../github/services/link-manager"; import { updateTaskStatus } from "../../github/services/task-service"; @@ -61,75 +63,91 @@ export async function handleGitlabIssueClosed( continue; } - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, externalLink.taskId), - }); + await withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope(externalLink.taskId, integration.projectId), + }); - if (!task) { - continue; - } + if (!task) { + return; + } - let existingMetadata: Record = {}; - if (externalLink.metadata) { - try { - existingMetadata = JSON.parse(externalLink.metadata) as Record< - string, - unknown - >; - } catch (error) { - console.warn("Failed to parse GitLab issue metadata for close sync", { - externalLinkId: externalLink.id, - metadata: externalLink.metadata, - error, - }); - } - } + let existingMetadata: Record = {}; + if (externalLink.metadata) { + try { + existingMetadata = JSON.parse(externalLink.metadata) as Record< + string, + unknown + >; + } catch (error) { + console.warn( + "Failed to parse GitLab issue metadata for close sync", + { + externalLinkId: externalLink.id, + metadata: externalLink.metadata, + error, + }, + ); + } + } - // Only an echo if it reports the state Kaneo last wrote. - const lastOutbound = existingMetadata.lastOutboundStateSyncAt; - if ( - typeof lastOutbound === "number" && - Number.isFinite(lastOutbound) && - existingMetadata.state === "closed" - ) { - const eventMs = parseIssueUpdatedAtMs(issue); - if ( - eventMs !== null && - Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS - ) { - continue; - } - } + // Only an echo if it reports the state Kaneo last wrote. + const lastOutbound = existingMetadata.lastOutboundStateSyncAt; + if ( + typeof lastOutbound === "number" && + Number.isFinite(lastOutbound) && + existingMetadata.state === "closed" + ) { + const eventMs = parseIssueUpdatedAtMs(issue); + if ( + eventMs !== null && + Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS + ) { + return; + } + } - const targetStatus = await resolveTargetStatus( - task.projectId, - "issue_closed", - "done", - ); + const targetStatus = await resolveTargetStatus( + task.projectId, + "issue_closed", + "done", + db, + ); - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - sourceIntegrationId: integration.id, - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } + const statusResult = await updateTaskStatus(task.id, targetStatus, db); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + sourceIntegrationId: integration.id, + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } - await updateExternalLink(externalLink.id, { - metadata: { - ...existingMetadata, - state: "closed", + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "closed", + }, + }, + db, + ); }, - }); + ); } } diff --git a/apps/api/src/plugins/gitlab/webhooks/issue-reopened.ts b/apps/api/src/plugins/gitlab/webhooks/issue-reopened.ts index 28b9b5c32d..1bfc698dc5 100644 --- a/apps/api/src/plugins/gitlab/webhooks/issue-reopened.ts +++ b/apps/api/src/plugins/gitlab/webhooks/issue-reopened.ts @@ -1,6 +1,8 @@ +import { withIntegrationLink } from "../../github/services/with-integration-link"; +import { linkedTaskScope } from "../../github/services/integration-task-scope"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; -import { externalLinkTable, taskTable } from "../../../database/schema"; +import { externalLinkTable } from "../../../database/schema"; import { publishEvent } from "../../../events"; import { updateExternalLink } from "../../github/services/link-manager"; import { updateTaskStatus } from "../../github/services/task-service"; @@ -61,74 +63,90 @@ export async function handleGitlabIssueReopened( continue; } - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, externalLink.taskId), - }); + await withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope(externalLink.taskId, integration.projectId), + }); - if (!task) { - continue; - } + if (!task) { + return; + } - let existingMetadata: Record = {}; - if (externalLink.metadata) { - try { - existingMetadata = JSON.parse(externalLink.metadata) as Record< - string, - unknown - >; - } catch (error) { - console.warn("Failed to parse GitLab issue metadata for reopen sync", { - externalLinkId: externalLink.id, - metadata: externalLink.metadata, - error, - }); - } - } + let existingMetadata: Record = {}; + if (externalLink.metadata) { + try { + existingMetadata = JSON.parse(externalLink.metadata) as Record< + string, + unknown + >; + } catch (error) { + console.warn( + "Failed to parse GitLab issue metadata for reopen sync", + { + externalLinkId: externalLink.id, + metadata: externalLink.metadata, + error, + }, + ); + } + } - const lastOutbound = existingMetadata.lastOutboundStateSyncAt; - if ( - typeof lastOutbound === "number" && - Number.isFinite(lastOutbound) && - existingMetadata.state === "opened" - ) { - const eventMs = parseIssueUpdatedAtMs(issue); - if ( - eventMs !== null && - Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS - ) { - continue; - } - } + const lastOutbound = existingMetadata.lastOutboundStateSyncAt; + if ( + typeof lastOutbound === "number" && + Number.isFinite(lastOutbound) && + existingMetadata.state === "opened" + ) { + const eventMs = parseIssueUpdatedAtMs(issue); + if ( + eventMs !== null && + Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS + ) { + return; + } + } - const targetStatus = await resolveTargetStatus( - task.projectId, - "issue_reopened", - "to-do", - ); + const targetStatus = await resolveTargetStatus( + task.projectId, + "issue_reopened", + "to-do", + db, + ); - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - sourceIntegrationId: integration.id, - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } + const statusResult = await updateTaskStatus(task.id, targetStatus, db); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + sourceIntegrationId: integration.id, + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } - await updateExternalLink(externalLink.id, { - metadata: { - ...existingMetadata, - state: "opened", + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "opened", + }, + }, + db, + ); }, - }); + ); } } diff --git a/apps/api/src/plugins/gitlab/webhooks/issue-updated.ts b/apps/api/src/plugins/gitlab/webhooks/issue-updated.ts index ba3375d719..3f1c22072c 100644 --- a/apps/api/src/plugins/gitlab/webhooks/issue-updated.ts +++ b/apps/api/src/plugins/gitlab/webhooks/issue-updated.ts @@ -1,5 +1,9 @@ +import { withIntegrationLink } from "../../github/services/with-integration-link"; +import { + type IntegrationDatabase, + linkedTaskScope, +} from "../../github/services/integration-task-scope"; import { eq, inArray } from "drizzle-orm"; -import db from "../../../database"; import { labelTable, taskTable } from "../../../database/schema"; import { publishEvent } from "../../../events"; import { @@ -63,6 +67,7 @@ async function syncGitlabLabelsToTask( workspaceId: string, gitlabLabels: Array<{ name: string; color: string }>, previousLabels: GitlabWebhookLabel[] | undefined, + db: IntegrationDatabase, ) { const desiredNames = new Set(gitlabLabels.map((l) => l.name)); const existingRows = await db.query.labelTable.findMany({ @@ -159,114 +164,141 @@ export async function handleGitlabIssueUpdated( continue; } - const task = await db.query.taskTable.findFirst({ - where: eq(taskTable.id, externalLink.taskId), - with: { project: true }, - }); + await withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope(externalLink.taskId, integration.projectId), + with: { project: true }, + }); - if (!task) { - continue; - } + if (!task) { + return; + } - let metadata: LinkMetadata = parseLinkSyncMetadata( - externalLink.metadata, - { externalLinkId: externalLink.id, field: "issue" }, - ); + let metadata: LinkMetadata = parseLinkSyncMetadata( + externalLink.metadata, + { externalLinkId: externalLink.id, field: "issue" }, + ); - if (touchedText) { - const updateData: Record = {}; - const lastSync = { ...metadata.lastSync }; - const now = new Date().toISOString(); + if (touchedText) { + const updateData: Record = {}; + const lastSync = { ...metadata.lastSync }; + const now = new Date().toISOString(); - if ( - changes?.title && - !isEchoOf(metadata.lastSync?.title, "kaneo", issue.title) - ) { - updateData.title = issue.title; - lastSync.title = { - timestamp: now, - source: "gitlab", - value: issue.title, - }; - } + if ( + changes?.title && + !isEchoOf(metadata.lastSync?.title, "kaneo", issue.title) + ) { + updateData.title = issue.title; + lastSync.title = { + timestamp: now, + source: "gitlab", + value: issue.title, + }; + } - if (changes?.description) { - // Kaneo recorded the body with its footer, so compare the raw body. - const issueBody = issue.description ?? ""; - if (!isEchoOf(metadata.lastSync?.description, "kaneo", issueBody)) { - const description = taskDescriptionFromIssue(issue.description); - updateData.description = description; - lastSync.description = { - timestamp: now, - source: "gitlab", - value: description, - }; - } - } + if (changes?.description) { + // Kaneo recorded the body with its footer, so compare the raw body. + const issueBody = issue.description ?? ""; + if ( + !isEchoOf(metadata.lastSync?.description, "kaneo", issueBody) + ) { + const description = taskDescriptionFromIssue(issue.description); + updateData.description = description; + lastSync.description = { + timestamp: now, + source: "gitlab", + value: description, + }; + } + } - if (Object.keys(updateData).length > 0) { - await db - .update(taskTable) - .set(updateData) - .where(eq(taskTable.id, task.id)); + if (Object.keys(updateData).length > 0) { + await db + .update(taskTable) + .set(updateData) + .where(linkedTaskScope(task.id, integration.projectId)); - metadata = { ...metadata, lastSync }; + metadata = { ...metadata, lastSync }; - await updateExternalLink(externalLink.id, { - title: issue.title, - metadata, - }); - } - } + await updateExternalLink( + externalLink.id, + { + title: issue.title, + metadata, + }, + db, + ); + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: task.id, + }), + ); + } + } - if (!touchedLabels || !currentLabels) { - continue; - } + if (!touchedLabels || !currentLabels) { + return; + } - const titles = labelTitles(currentLabels); - const priority = extractIssuePriority(titles); - const status = extractIssueStatus(titles); + const titles = labelTitles(currentLabels); + const priority = extractIssuePriority(titles); + const status = extractIssueStatus(titles); - if (priority) { - await db - .update(taskTable) - .set({ priority }) - .where(eq(taskTable.id, task.id)); - } + if (priority) { + await db + .update(taskTable) + .set({ priority }) + .where(linkedTaskScope(task.id, integration.projectId)); + } - // Unrelated label edits also include the full label snapshot. Its status - // can predate a close/reopen, so only apply an actual status-label change. - const previousStatus = extractIssueStatus( - labelTitles(changes?.labels?.previous), - ); - if (status && status !== previousStatus) { - const statusResult = await updateTaskStatus(task.id, status); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - sourceIntegrationId: integration.id, - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } - } + // Unrelated label edits also include the full label snapshot. Its status + // can predate a close/reopen, so only apply an actual status-label change. + const previousStatus = extractIssueStatus( + labelTitles(changes?.labels?.previous), + ); + if (status && status !== previousStatus) { + const statusResult = await updateTaskStatus(task.id, status, db); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + sourceIntegrationId: integration.id, + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + } - if (task.project?.workspaceId) { - await syncGitlabLabelsToTask( - task.id, - task.project.workspaceId, - nonSystemLabels(currentLabels), - changes?.labels?.previous, - ); - } + if (task.project?.workspaceId) { + await syncGitlabLabelsToTask( + task.id, + task.project.workspaceId, + nonSystemLabels(currentLabels), + changes?.labels?.previous, + db, + ); + afterCommit(() => + publishEvent("task.labels_updated", { + projectId: integration.projectId, + taskId: task.id, + }), + ); + } + }, + ); } catch (error) { console.error("GitLab issue update handler failed for integration", { integrationId: integration.id, diff --git a/apps/api/src/plugins/gitlab/webhooks/merge-request-closed.ts b/apps/api/src/plugins/gitlab/webhooks/merge-request-closed.ts index cd7499616a..db9bdb4511 100644 --- a/apps/api/src/plugins/gitlab/webhooks/merge-request-closed.ts +++ b/apps/api/src/plugins/gitlab/webhooks/merge-request-closed.ts @@ -1,3 +1,4 @@ +import { withIntegrationLink } from "../../github/services/with-integration-link"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; import { externalLinkTable } from "../../../database/schema"; @@ -69,87 +70,112 @@ export async function handleGitlabMergeRequestClosed( continue; } - const task = await findTaskById(externalLink.taskId); - - if (!task) { - continue; - } - - let existingMetadata: Record = {}; - if (externalLink.metadata) { - try { - existingMetadata = JSON.parse(externalLink.metadata) as Record< - string, - unknown - >; - } catch (error) { - console.warn("Failed to parse GitLab merge request metadata", { - externalLinkId: externalLink.id, - metadata: externalLink.metadata, - error, + await withIntegrationLink( + externalLink, + integration, + async (database, afterCommit, lockedLink) => { + const task = await findTaskById(externalLink.taskId, database); + + if (!task) { + return; + } + + let existingMetadata: Record = {}; + if (lockedLink.metadata) { + try { + existingMetadata = JSON.parse(lockedLink.metadata) as Record< + string, + unknown + >; + } catch (error) { + console.warn("Failed to parse GitLab merge request metadata", { + externalLinkId: externalLink.id, + metadata: lockedLink.metadata, + error, + }); + } + } + + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: merged ? "merged" : "closed", + draft: false, + merged, + }, + }, + database, + ); + + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: task.id, + }), + ); + + if (!merged) { + return; + } + + const allTaskMergeRequests = + await database.query.externalLinkTable.findMany({ + where: and( + eq(externalLinkTable.taskId, task.id), + eq(externalLinkTable.resourceType, "pull_request"), + ), + }); + + const hasOpenMergeRequests = allTaskMergeRequests.some((link) => { + if (link.id === externalLink.id) return false; + try { + const metadata = link.metadata + ? (JSON.parse(link.metadata) as { state?: string }) + : {}; + return metadata.state === "opened"; + } catch { + return false; + } }); - } - } - await updateExternalLink(externalLink.id, { - metadata: { - ...existingMetadata, - state: merged ? "merged" : "closed", - draft: false, - merged, + if (hasOpenMergeRequests) { + return; + } + + const targetStatus = await resolveTargetStatus( + integration.projectId, + "pr_merged", + config.statusTransitions?.onPRMerge || "done", + database, + ); + + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + database, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } }, - }); - - if (!merged) { - return; - } - - const allTaskMergeRequests = await db.query.externalLinkTable.findMany({ - where: and( - eq(externalLinkTable.taskId, task.id), - eq(externalLinkTable.resourceType, "pull_request"), - ), - }); - - const hasOpenMergeRequests = allTaskMergeRequests.some((link) => { - if (link.id === externalLink.id) return false; - try { - const metadata = link.metadata - ? (JSON.parse(link.metadata) as { state?: string }) - : {}; - return metadata.state === "opened"; - } catch { - return false; - } - }); - - if (hasOpenMergeRequests) { - return; - } - - const targetStatus = await resolveTargetStatus( - integration.projectId, - "pr_merged", - config.statusTransitions?.onPRMerge || "done", ); - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } - return; } } diff --git a/apps/api/src/plugins/gitlab/webhooks/merge-request-opened.ts b/apps/api/src/plugins/gitlab/webhooks/merge-request-opened.ts index 135d28b589..e616ae0461 100644 --- a/apps/api/src/plugins/gitlab/webhooks/merge-request-opened.ts +++ b/apps/api/src/plugins/gitlab/webhooks/merge-request-opened.ts @@ -1,3 +1,8 @@ +import { withIntegrationLink } from "../../github/services/with-integration-link"; +import { + withIntegrationTask, + type IntegrationDatabase, +} from "../../github/services/integration-task-scope"; import { publishEvent } from "../../../events"; import { createExternalLink, @@ -98,69 +103,122 @@ export async function handleGitlabMergeRequestOpened( continue; } - const metadata = { - state: mergeRequest.state, - draft: mergeRequest.draft === true, - merged: false, - branch: branchName, - author: payload.user?.username ?? payload.user?.name, - }; - - if (existingLink) { - await updateExternalLink(existingLink.id, { - title: mergeRequest.title, - url: mergeRequest.url, - metadata, - }); - } else { - await createExternalLink({ - taskId: task.id, - integrationId: integration.id, - resourceType: "pull_request", - externalId: mergeRequest.iid.toString(), - url: mergeRequest.url, - title: mergeRequest.title, - metadata, - }); - } + const apply = async ( + database: IntegrationDatabase, + afterCommit: (effect: () => Promise) => void, + lockedLink?: { metadata: string | null }, + ) => { + const currentTask = await findTaskById(task.id, database); + if (!currentTask) return; + if (!existingLink) { + if ( + await findExternalLink( + integration.id, + "pull_request", + mergeRequest.iid.toString(), + database, + ) + ) + return; + const resolved = await resolveMergeRequestTask({ + projectId: integration.projectId, + projectSlug: integration.project!.slug, + config, + mergeRequest: { ...mergeRequest, source_branch: branchName }, + database, + }); + if (resolved?.id !== task.id) return; + } + const metadata = { + state: mergeRequest.state, + draft: mergeRequest.draft === true, + merged: false, + branch: branchName, + author: payload.user?.username ?? payload.user?.name, + }; + + if (existingLink) { + await updateExternalLink( + existingLink.id, + { + title: mergeRequest.title, + url: mergeRequest.url, + metadata, + }, + database, + ); + } else { + await createExternalLink( + { + taskId: task.id, + integrationId: integration.id, + resourceType: "pull_request", + externalId: mergeRequest.iid.toString(), + url: mergeRequest.url, + title: mergeRequest.title, + metadata, + }, + database, + ); + } - if (!moveTask) { - return; - } + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: task.id, + }), + ); - // On reopen the link already exists, but the task still has to move. - const targetStatus = await resolveTargetStatus( - integration.projectId, - "pr_opened", - config.statusTransitions?.onPROpen || "in-review", - ); + if (!moveTask) { + return; + } - const wasDraft = - parseLinkMetadata<{ draft: boolean }>(existingLink?.metadata, { - externalLinkId: existingLink?.id ?? "", - source: "gitlab-merge-request-opened", - }).draft === true; - const canMove = - !existingLink || wasDraft || !(await isTaskInFinalState(task)); - - if (task.status !== targetStatus && canMove) { - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); + // On reopen the link already exists, but the task still has to move. + const targetStatus = await resolveTargetStatus( + integration.projectId, + "pr_opened", + config.statusTransitions?.onPROpen || "in-review", + database, + ); + + const wasDraft = + parseLinkMetadata<{ draft: boolean }>(lockedLink?.metadata, { + externalLinkId: existingLink?.id ?? "", + source: "gitlab-merge-request-opened", + }).draft === true; + const canMove = + !existingLink || + wasDraft || + !(await isTaskInFinalState(currentTask, database)); + + if (currentTask.status !== targetStatus && canMove) { + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + database, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } } - } + }; + if (existingLink) + await withIntegrationLink(existingLink, integration, apply); + else await withIntegrationTask(task.id, integration, apply); return; } diff --git a/apps/api/src/plugins/gitlab/webhooks/note-created.ts b/apps/api/src/plugins/gitlab/webhooks/note-created.ts index 67308e5eac..b1eb3578d6 100644 --- a/apps/api/src/plugins/gitlab/webhooks/note-created.ts +++ b/apps/api/src/plugins/gitlab/webhooks/note-created.ts @@ -1,4 +1,4 @@ -import db from "../../../database"; +import { withIntegrationLink } from "../../github/services/with-integration-link"; import { activityTable } from "../../../database/schema"; import { findExternalLink } from "../../github/services/link-manager"; import { findAllIntegrationsByGitlabProject } from "../services/integration-lookup"; @@ -67,30 +67,36 @@ export async function handleGitlabNoteCreated( continue; } - if (syncedNoteIds(externalLink.metadata).includes(note.id)) { - continue; - } + await withIntegrationLink( + externalLink, + integration, + async (db, _afterCommit, externalLink) => { + if (syncedNoteIds(externalLink.metadata).includes(note.id)) { + return; + } - await db - .insert(activityTable) - .values({ - taskId: externalLink.taskId, - type: "comment", - content: note.note, - externalUserName: username || "Unknown", - externalUserAvatar: payload.user?.avatar_url ?? null, - externalSource: "gitlab", - externalUrl: note.url, - eventData: { - externalCommentId: note.id, - }, - }) - .onConflictDoNothing({ - target: [ - activityTable.taskId, - activityTable.externalSource, - activityTable.externalUrl, - ], - }); + await db + .insert(activityTable) + .values({ + taskId: externalLink.taskId, + type: "comment", + content: note.note, + externalUserName: username || "Unknown", + externalUserAvatar: payload.user?.avatar_url ?? null, + externalSource: "gitlab", + externalUrl: note.url, + eventData: { + externalCommentId: note.id, + }, + }) + .onConflictDoNothing({ + target: [ + activityTable.taskId, + activityTable.externalSource, + activityTable.externalUrl, + ], + }); + }, + ); } } diff --git a/apps/api/src/plugins/gitlab/webhooks/push.ts b/apps/api/src/plugins/gitlab/webhooks/push.ts index 6388f352d4..4bd093a764 100644 --- a/apps/api/src/plugins/gitlab/webhooks/push.ts +++ b/apps/api/src/plugins/gitlab/webhooks/push.ts @@ -1,3 +1,7 @@ +import { + linkedTaskScope, + withIntegrationTask, +} from "../../github/services/integration-task-scope"; import { publishEvent } from "../../../events"; import { createOrUpdateExternalLink } from "../../github/services/link-manager"; import { @@ -112,50 +116,79 @@ export async function handleGitlabPush( continue; } - const branchLink = await createOrUpdateExternalLink({ - taskId: task.id, - integrationId: integration.id, - resourceType: "branch", - externalId: branchName, - url: `${project.web_url}/-/tree/${treePath(branchName)}`, - title: branchName, - metadata: { - lastCommit: headCommit - ? { - sha: headCommit.id, - message: headCommit.message, - author: headCommit.author?.name, - timestamp: headCommit.timestamp, - } - : null, + const taskId = task.id; + await withIntegrationTask( + taskId, + integration, + async (database, afterCommit) => { + const current = await database.query.taskTable.findFirst({ + where: linkedTaskScope(taskId, integration.projectId), + }); + if (!current) return; + const task = current; + const branchLink = await createOrUpdateExternalLink( + { + taskId: task.id, + integrationId: integration.id, + resourceType: "branch", + externalId: branchName, + url: `${project.web_url}/-/tree/${treePath(branchName)}`, + title: branchName, + metadata: { + lastCommit: headCommit + ? { + sha: headCommit.id, + message: headCommit.message, + author: headCommit.author?.name, + timestamp: headCommit.timestamp, + } + : null, + }, + }, + database, + ); + + const targetStatus = await resolveTargetStatus( + integration.projectId, + "branch_push", + config.statusTransitions?.onBranchPush || "in-progress", + database, + ); + + const canMove = + branchLink.created || !(await isTaskInFinalState(task, database)); + + if (task.status !== targetStatus && canMove) { + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + database, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + } + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: task.id, + }), + ); }, - }); - - const targetStatus = await resolveTargetStatus( - integration.projectId, - "branch_push", - config.statusTransitions?.onBranchPush || "in-progress", ); - - const canMove = branchLink.created || !(await isTaskInFinalState(task)); - - if (task.status !== targetStatus && canMove) { - const statusResult = await updateTaskStatus(task.id, targetStatus); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - await publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }); - } - } } } diff --git a/apps/api/src/project/controllers/move-project.ts b/apps/api/src/project/controllers/move-project.ts index 04fa46f947..43d24b7a27 100644 --- a/apps/api/src/project/controllers/move-project.ts +++ b/apps/api/src/project/controllers/move-project.ts @@ -13,6 +13,8 @@ import createActivities from "../../activity/controllers/create-activities"; import db from "../../database"; import { assetTable, + externalLinkTable, + integrationTable, labelTable, projectTable, taskRelationTable, @@ -200,6 +202,29 @@ async function moveProject( }); } + // Older task moves could leave links owned by a different project. + await tx + .delete(externalLinkTable) + .where( + and( + inArray( + externalLinkTable.taskId, + tx + .select({ id: taskTable.id }) + .from(taskTable) + .where(eq(taskTable.projectId, id)), + ), + isNotNull(externalLinkTable.integrationId), + notInArray( + externalLinkTable.integrationId, + tx + .select({ id: integrationTable.id }) + .from(integrationTable) + .where(eq(integrationTable.projectId, id)), + ), + ), + ); + // Assets and task labels denormalize the project's workspace. await tx .update(assetTable) diff --git a/apps/api/src/task/controllers/move-task.ts b/apps/api/src/task/controllers/move-task.ts index f45ff1f27b..039c58d660 100644 --- a/apps/api/src/task/controllers/move-task.ts +++ b/apps/api/src/task/controllers/move-task.ts @@ -1,9 +1,11 @@ -import { and, asc, eq } from "drizzle-orm"; +import { and, asc, eq, isNotNull, notInArray } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { assetTable, columnTable, + externalLinkTable, + integrationTable, projectTable, taskTable, } from "../../database/schema"; @@ -113,6 +115,22 @@ async function moveTask({ ); const movedTask = await db.transaction(async (tx) => { + for (const projectId of [sourceProject.id, destinationProjectId].sort()) { + const [project] = await tx + .select({ id: projectTable.id }) + .from(projectTable) + .where( + and( + eq(projectTable.id, projectId), + eq(projectTable.workspaceId, sourceProject.workspaceId), + ), + ) + .for("key share"); + if (!project) + throw new HTTPException(409, { + message: "Project was moved to another workspace, please try again", + }); + } const nextTaskNumber = await claimTaskNumber(destinationProjectId, tx); const nextPosition = await nextTaskPosition( tx, @@ -130,15 +148,36 @@ async function moveTask({ number: nextTaskNumber, position: nextPosition, }) - .where(eq(taskTable.id, taskId)) + .where( + and( + eq(taskTable.id, taskId), + eq(taskTable.projectId, sourceProject.id), + ), + ) .returning(); if (!updatedTask) { - throw new HTTPException(500, { - message: "Failed to move task", + throw new HTTPException(409, { + message: "Task was moved concurrently, please try again", }); } + await tx + .delete(externalLinkTable) + .where( + and( + eq(externalLinkTable.taskId, taskId), + isNotNull(externalLinkTable.integrationId), + notInArray( + externalLinkTable.integrationId, + tx + .select({ id: integrationTable.id }) + .from(integrationTable) + .where(eq(integrationTable.projectId, destinationProjectId)), + ), + ), + ); + await tx .update(assetTable) .set({ projectId: destinationProjectId }) diff --git a/apps/api/src/task/index.ts b/apps/api/src/task/index.ts index 53c2e04e93..cf40bbd3fd 100644 --- a/apps/api/src/task/index.ts +++ b/apps/api/src/task/index.ts @@ -274,6 +274,7 @@ const moveTaskRoute = createRoute({ "No workspace access, or missing task:update permission", ), 404: errorResponse("Task or destination project not found"), + 409: errorResponse("Task or project moved concurrently; retry the move"), }, }); diff --git a/apps/api/src/ws/index.ts b/apps/api/src/ws/index.ts index 414db106c1..3f599b36ae 100644 --- a/apps/api/src/ws/index.ts +++ b/apps/api/src/ws/index.ts @@ -381,6 +381,7 @@ const taskUpdateEvents = [ "task.label_assigned", "task.label_unassigned", "task.label_created", + "task.labels_updated", "task.label_deleted", "task-relation.created", "task-relation.deleted", @@ -500,6 +501,7 @@ for (const eventName of taskUpdateEvents) { case "task.label_assigned": case "task.label_unassigned": case "task.label_created": + case "task.labels_updated": case "task.label_deleted": type = "TASK_LABEL_UPDATED"; break; diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index b263bb917c..7bd1e145fa 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -9168,6 +9168,16 @@ } } } + }, + "409": { + "description": "Task or project moved concurrently; retry the move", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } } } } diff --git a/apps/web/src/hooks/mutations/project/use-move-project.ts b/apps/web/src/hooks/mutations/project/use-move-project.ts index aec9fe177c..ea4283503d 100644 --- a/apps/web/src/hooks/mutations/project/use-move-project.ts +++ b/apps/web/src/hooks/mutations/project/use-move-project.ts @@ -12,6 +12,7 @@ function useMoveProject() { onSuccess: async (_data, { id }) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["projects"] }), + queryClient.invalidateQueries({ queryKey: ["external-links"] }), queryClient.invalidateQueries({ queryKey: ["tasks", id] }), ]); }, diff --git a/apps/web/src/hooks/mutations/task/use-move-task.ts b/apps/web/src/hooks/mutations/task/use-move-task.ts index ac1055ccba..801ebf5db4 100644 --- a/apps/web/src/hooks/mutations/task/use-move-task.ts +++ b/apps/web/src/hooks/mutations/task/use-move-task.ts @@ -11,6 +11,9 @@ export function useMoveTask() { mutationFn: moveTask, onSuccess: (result, variables) => { toast.success(t("tasks:move.success")); + queryClient.invalidateQueries({ + queryKey: ["external-links", variables.taskId], + }); queryClient.invalidateQueries({ queryKey: ["task", variables.taskId], }); diff --git a/apps/web/src/hooks/use-project-websocket-lifecycle.test.tsx b/apps/web/src/hooks/use-project-websocket-lifecycle.test.tsx index ae642ab488..e007bdab3a 100644 --- a/apps/web/src/hooks/use-project-websocket-lifecycle.test.tsx +++ b/apps/web/src/hooks/use-project-websocket-lifecycle.test.tsx @@ -132,6 +132,22 @@ describe("project WebSocket lifecycle", () => { } }); + it("refreshes task resources after a task move", () => { + renderHook(() => useProjectWebSocket("project-a")); + act(() => + TestSocket.instances[0].onmessage?.({ + data: JSON.stringify({ + type: "TASK_MOVED", + projectId: "project-a", + taskId: "task-a", + }), + }), + ); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["external-links", "task-a"], + }); + }); + it("preserves bounded exponential reconnects and active message invalidation", () => { const { unmount } = renderHook(() => useProjectWebSocket("project-a")); for (let retry = 0; retry < 5; retry++) { diff --git a/apps/web/src/hooks/use-project-websocket.ts b/apps/web/src/hooks/use-project-websocket.ts index 82033e8b92..b6355f9a2e 100644 --- a/apps/web/src/hooks/use-project-websocket.ts +++ b/apps/web/src/hooks/use-project-websocket.ts @@ -69,6 +69,7 @@ export function useProjectWebSocket(projectId: string) { ["tasks", projectId], ["task"], ["task-relations"], + ["external-links"], ]) { queryClient.invalidateQueries({ queryKey }); } @@ -127,7 +128,11 @@ export function useProjectWebSocket(projectId: string) { }); } - if (message.type === "TASK_UPDATED" && message.taskId) { + if ( + (message.type === "TASK_UPDATED" || + message.type === "TASK_MOVED") && + message.taskId + ) { queryClient.invalidateQueries({ queryKey: ["external-links", message.taskId], }); diff --git a/tests/api-integration/github-description-sync.test.ts b/tests/api-integration/github-description-sync.test.ts index b77b319bbd..f68333206a 100644 --- a/tests/api-integration/github-description-sync.test.ts +++ b/tests/api-integration/github-description-sync.test.ts @@ -13,9 +13,6 @@ const m = vi.hoisted(() => ({ find: vi.fn(), update: vi.fn(async () => undefined), })); -vi.mock("../../apps/api/src/plugins/github/services/task-service", () => ({ - findAllIntegrationsByRepo: async () => [{ id: "verified-integration" }], -})); vi.mock("../../apps/api/src/plugins/github/services/link-manager", () => ({ findExternalLink: m.find, updateExternalLink: m.update, @@ -29,6 +26,21 @@ beforeEach(async () => { async function setup() { const { workspace } = await createWorkspaceMember(); const { project } = await createProjectFixture({ workspaceId: workspace.id }); + const [integration] = await db + .insert(schema.integrationTable) + .values({ + projectId: project.id, + type: "github", + config: JSON.stringify({ + repositoryOwner: "example", + repositoryName: "repo", + installationId: 10, + repositoryId: 20, + verifiedGithubAccountId: "123", + verifiedByUserId: "user", + }), + }) + .returning(); const [task] = await db .insert(schema.taskTable) .values({ @@ -38,8 +50,12 @@ async function setup() { number: 1, }) .returning(); - m.find.mockResolvedValue({ + const link = { id: "link", + integrationId: integration.id, + resourceType: "issue", + externalId: "1", + url: "https://github.com/example/repo/issues/1", taskId: task.id, metadata: JSON.stringify({ lastSync: { @@ -50,7 +66,9 @@ async function setup() { }, }, }), - }); + }; + await db.insert(schema.externalLinkTable).values(link); + m.find.mockResolvedValue(link); return task; } function webhook(body: string) { @@ -103,6 +121,7 @@ describe("GitHub description round trip", () => { }), }), }), + expect.anything(), ); }); diff --git a/tests/api-integration/github-import-bounds.test.ts b/tests/api-integration/github-import-bounds.test.ts index 57cca274ab..c05e9941d1 100644 --- a/tests/api-integration/github-import-bounds.test.ts +++ b/tests/api-integration/github-import-bounds.test.ts @@ -5,6 +5,7 @@ import { importIssues } from "../../apps/api/src/github-integration/controllers/ import { withGithubImportLock } from "../../apps/api/src/github-integration/import-lock"; import { IMPORT_PAGES_PER_REQUEST } from "../../apps/api/src/github-integration/import-pages"; import { createApp } from "../../apps/api/src/index"; +import moveTask from "../../apps/api/src/task/controllers/move-task"; import { handleIssueOpened } from "../../apps/api/src/plugins/github/webhooks/issue-opened"; import { mockAuthenticatedSession } from "./helpers/auth"; import { resetTestDatabase } from "./helpers/database"; @@ -17,6 +18,10 @@ const mocks = vi.hoisted(() => ({ graphql: vi.fn(), verify: vi.fn(), comment: vi.fn(), + publish: vi.fn( + async (_name: string, _payload: { projectId: string; taskId?: string }) => + undefined, + ), })); vi.mock("../../apps/api/src/plugins/github/utils/github-app", () => ({ getVerifiedInstallationOctokit: mocks.verify, @@ -26,6 +31,10 @@ vi.mock("../../apps/api/src/plugins/github/utils/github-app", () => ({ }), }), })); +vi.mock("../../apps/api/src/events", async (original) => ({ + ...(await original()), + publishEvent: mocks.publish, +})); const old = "2020-01-01T00:00:00Z"; function connection( nodes: T[], @@ -106,6 +115,7 @@ beforeEach(async () => { mocks.graphql.mockReset(); mocks.verify.mockReset().mockResolvedValue({ graphql: mocks.graphql }); mocks.comment.mockReset(); + mocks.publish.mockReset().mockResolvedValue(undefined); }); async function setup() { const member = await createWorkspaceMember({ role: "admin" }); @@ -151,6 +161,19 @@ describe("bounded resumable GitHub import", () => { it("bounds each HTTP step, persists resumable state, imports every issue and retries completion without restarting", async () => { const { request, app, project } = await setup(); serveIssues(9); + const observedCounts: number[] = []; + mocks.publish.mockImplementation(async (name, payload) => { + if (name === "project.updated") { + expect(payload).toEqual({ projectId: project.id }); + observedCounts.push((await db.query.taskTable.findMany()).length); + } else { + expect( + await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, payload.taskId!), + }), + ).toBeDefined(); + } + }); const first = await request(); expect(first.status).toBe(202); const progress = await first.json(); @@ -160,6 +183,7 @@ describe("bounded resumable GitHub import", () => { updated: 0, }); expect(mocks.graphql).toHaveBeenCalledTimes(IMPORT_PAGES_PER_REQUEST); + expect(observedCounts).toEqual([1, 2, 3, 4]); const info = await app.request( `/api/github-integration/project/${project.id}`, ); @@ -263,6 +287,24 @@ describe("bounded resumable GitHub import", () => { return emptyPulls(); }, ); + const delivered: Array<{ + name: string; + priority?: string; + labels: number; + comments: number; + }> = []; + mocks.publish.mockImplementation(async (name, payload) => { + if (!payload.taskId) return; + const committed = await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, payload.taskId), + }); + delivered.push({ + name, + priority: committed?.priority, + labels: (await db.query.labelTable.findMany()).length, + comments: (await db.query.activityTable.findMany()).length, + }); + }); let result = await importIssues(project.id); expect(result.pending).toBe(true); expect((await saved())?.state).toMatchObject({ @@ -273,6 +315,16 @@ describe("bounded resumable GitHub import", () => { expect(result.pending).toBe(false); expect(await db.query.labelTable.findMany()).toHaveLength(25); expect(await db.query.activityTable.findMany()).toHaveLength(82); + expect(delivered).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "task.labels_updated", + priority: "urgent", + labels: 25, + }), + expect.objectContaining({ name: "comment.updated", comments: 82 }), + ]), + ); expect(await db.query.taskTable.findFirst()).toMatchObject({ priority: "urgent", status: "in-progress", @@ -592,6 +644,51 @@ describe("bounded resumable GitHub import", () => { expect(mocks.comment).not.toHaveBeenCalled(); }); + it("announces a webhook task only after its task and link commit", async () => { + const { project } = await setup(); + mocks.publish.mockImplementation(async (name, payload) => { + expect(name).toBe("task.created"); + expect(payload).toMatchObject({ + projectId: project.id, + title: "Live issue", + description: "Description", + priority: "low", + status: "to-do", + number: 1, + userId: "", + source: "github", + externalId: "77", + actor: "github-webhook", + }); + const task = await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, payload.taskId!), + }); + const link = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.taskId, payload.taskId!), + }); + expect(task?.title).toBe("Live issue"); + expect(link?.externalId).toBe("77"); + }); + await handleIssueOpened({ + action: "opened", + installation: { id: 1 }, + repository: { + id: 2, + owner: { login: "example" }, + name: "repo", + full_name: "example/repo", + }, + issue: { + number: 77, + title: "Live issue", + body: "Description", + html_url: "https://github.com/example/repo/issues/77", + user: null, + }, + }); + expect(mocks.publish).toHaveBeenCalledTimes(1); + }); + it("continues all pull request pages and links only matching tasks in this project", async () => { const { project, integration } = await setup(); mocks.graphql.mockImplementation( @@ -632,3 +729,85 @@ describe("bounded resumable GitHub import", () => { ).toHaveLength(25); }); }); + +it.each(["labels", "comments"])( + "skips unlinked GitHub %s continuation data after a task moves away and back", + async (phase) => { + const { project, member } = await setup(); + const { project: destination } = await createProjectFixture({ + workspaceId: project.workspaceId, + }); + let movedTaskId: string | undefined; + mocks.graphql.mockImplementation(async (query: string) => { + if (query.includes("query ImportIssues(")) + return issuePage([ + issue( + 1, + phase === "labels" + ? { labels: connection([label(1)], true, "first", 2) } + : { comments: connection([comment(1)], true, "first", 2) }, + ), + ]); + if ( + query.includes( + phase === "labels" + ? "query ImportIssueLabels(" + : "query ImportIssueComments(", + ) + ) { + const task = await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.projectId, project.id), + }); + movedTaskId = task!.id; + await moveTask({ + taskId: task!.id, + destinationProjectId: destination.id, + userId: member.user.id, + }); + await moveTask({ + taskId: task!.id, + destinationProjectId: project.id, + userId: member.user.id, + }); + return { + repository: { + databaseId: 2, + issue: + phase === "labels" + ? { + labels: connection([ + { name: "status:in-progress", color: "ffffff" }, + label(2), + ]), + } + : { comments: connection([comment(2)]) }, + }, + }; + } + return emptyPulls(); + }); + expect(await importIssues(project.id)).toMatchObject({ + pending: false, + skipped: 1, + }); + const saved = await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, movedTaskId!), + }); + expect(saved?.status).toBe("to-do"); + expect( + await db.query.externalLinkTable.findMany({ + where: eq(schema.externalLinkTable.taskId, movedTaskId!), + }), + ).toEqual([]); + const labels = await db.query.labelTable.findMany({ + where: eq(schema.labelTable.taskId, movedTaskId!), + }); + expect(labels.some((row) => row.name === "label-2")).toBe(false); + const comments = await db.query.activityTable.findMany({ + where: eq(schema.activityTable.taskId, movedTaskId!), + }); + expect(comments.some((row) => row.externalUrl === comment(2).url)).toBe( + false, + ); + }, +); diff --git a/tests/api-integration/integration-pr-task-ownership.test.ts b/tests/api-integration/integration-pr-task-ownership.test.ts new file mode 100644 index 0000000000..bf6b5bab56 --- /dev/null +++ b/tests/api-integration/integration-pr-task-ownership.test.ts @@ -0,0 +1,242 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import { publishEvent } from "../../apps/api/src/events"; +import db, { getDatabase, schema } from "../../apps/api/src/database"; +import { handlePullRequestOpened } from "../../apps/api/src/plugins/github/webhooks/pull-request-opened"; +import { handlePullRequestClosed } from "../../apps/api/src/plugins/github/webhooks/pull-request-closed"; +import { handleGiteaPullRequestOpened } from "../../apps/api/src/plugins/gitea/webhooks/pull-request-opened"; +import { handleGiteaPullRequestClosed } from "../../apps/api/src/plugins/gitea/webhooks/pull-request-closed"; +import { handleGitlabMergeRequestOpened } from "../../apps/api/src/plugins/gitlab/webhooks/merge-request-opened"; +import { handleGitlabMergeRequestClosed } from "../../apps/api/src/plugins/gitlab/webhooks/merge-request-closed"; +import moveTask from "../../apps/api/src/task/controllers/move-task"; +import { resetTestDatabase } from "./helpers/database"; +import { + createProjectFixture, + createWorkspaceMember, +} from "./helpers/fixtures"; + +vi.mock("../../apps/api/src/events", () => ({ + publishEvent: vi.fn(async () => undefined), +})); +beforeEach(async () => { + await resetTestDatabase(); + vi.mocked(publishEvent).mockReset(); +}); +const cases = ["github", "gitea", "gitlab"].flatMap((provider) => + ["opened", "merged"].flatMap((action) => + ["ordinary", "moved", "returned"].map((race) => ({ + provider, + action, + race, + sameStatus: false, + })), + ), +); +cases.push( + ...["github", "gitea", "gitlab"].flatMap((provider) => + ["opened", "merged", "closed"].map((action) => ({ + provider, + action, + race: "ordinary", + sameStatus: action !== "closed", + })), + ), +); +it.each(cases)( + "$provider $action webhook respects task ownership ($race)", + async ({ provider, action, race, sameStatus }) => { + const { user, workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ + workspaceId: workspace.id, + slug: "KAN", + }); + const { project: destination } = await createProjectFixture({ + workspaceId: workspace.id, + }); + await db + .update(schema.projectTable) + .set({ lastTaskNumber: 1 }) + .where(eq(schema.projectTable.id, project.id)); + const [task] = await db + .insert(schema.taskTable) + .values({ + projectId: project.id, + number: 1, + title: "card", + status: sameStatus + ? action === "opened" + ? "in-progress" + : "done" + : "to-do", + position: 0, + }) + .returning(); + const [integration] = await db + .insert(schema.integrationTable) + .values({ + projectId: project.id, + type: provider, + config: JSON.stringify({ + baseUrl: "https://git.example", + accessToken: "test-only", + repositoryOwner: "owner", + repositoryName: "repo", + projectPath: "owner/repo", + installationId: 10, + repositoryId: 20, + verifiedGithubAccountId: "123", + verifiedByUserId: user.id, + branchPattern: "{slug}-{number}", + statusTransitions: { onPROpen: "in-progress", onPRMerge: "done" }, + }), + }) + .returning(); + if (action !== "opened") + await db.insert(schema.externalLinkTable).values({ + taskId: task.id, + integrationId: integration.id, + resourceType: "pull_request", + externalId: "1", + url: "https://git.example/owner/repo/pull/1", + metadata: JSON.stringify({ + state: provider === "gitlab" ? "opened" : "open", + }), + }); + const request = { + action: action === "opened" ? "opened" : "closed", + installation: { id: 10 }, + repository: { + id: 20, + owner: { login: "owner" }, + name: "repo", + html_url: "https://git.example/owner/repo", + }, + pull_request: { + number: 1, + title: "change", + body: null, + html_url: "https://git.example/owner/repo/pull/1", + state: action === "opened" ? "open" : "closed", + draft: false, + merged: action === "merged", + merged_at: action === "merged" ? new Date().toISOString() : null, + head: { ref: "KAN-1" }, + user: { login: "author" }, + }, + }; + const mergeRequest = { + project: { + id: 20, + name: "repo", + path_with_namespace: "owner/repo", + web_url: "https://git.example/owner/repo", + }, + object_attributes: { + iid: 1, + title: "change", + description: null, + url: "https://git.example/owner/repo/-/merge_requests/1", + state: + action === "opened" + ? "opened" + : action === "merged" + ? "merged" + : "closed", + action: + action === "opened" + ? "open" + : action === "merged" + ? "merge" + : "close", + source_branch: "KAN-1", + draft: false, + }, + }; + vi.mocked(publishEvent).mockImplementation(async (type) => { + if (type !== "task.updated") return; + const committed = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.taskId, task.id), + }); + expect(committed).toBeDefined(); + expect(JSON.parse(committed!.metadata!).state).toBe( + action === "opened" + ? provider === "gitlab" + ? "opened" + : "open" + : action === "merged" && provider === "gitlab" + ? "merged" + : "closed", + ); + }); + const transaction = getDatabase().transaction.bind(getDatabase()); + const intercepted = + race === "ordinary" + ? undefined + : vi + .spyOn(getDatabase(), "transaction") + .mockImplementationOnce(async (apply, config) => { + await moveTask({ + taskId: task.id, + destinationProjectId: destination.id, + userId: user.id, + }); + if (race === "returned") + await moveTask({ + taskId: task.id, + destinationProjectId: project.id, + userId: user.id, + }); + return transaction(apply, config); + }); + try { + if (provider === "github") + await ( + action === "opened" + ? handlePullRequestOpened + : handlePullRequestClosed + )(request); + else if (provider === "gitea") + await ( + action === "opened" + ? handleGiteaPullRequestOpened + : handleGiteaPullRequestClosed + )(request, integration.id); + else + await ( + action === "opened" + ? handleGitlabMergeRequestOpened + : handleGitlabMergeRequestClosed + )(mergeRequest, integration.id); + const saved = await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, task.id), + }); + expect(saved?.status).toBe( + race === "ordinary" + ? action === "opened" + ? "in-progress" + : action === "merged" + ? "done" + : "to-do" + : "to-do", + ); + expect(saved?.projectId).toBe( + race === "moved" ? destination.id : project.id, + ); + const links = await db.query.externalLinkTable.findMany({ + where: eq(schema.externalLinkTable.taskId, task.id), + }); + expect(links).toHaveLength(race === "ordinary" ? 1 : 0); + expect( + vi + .mocked(publishEvent) + .mock.calls.filter(([type]) => type === "task.updated"), + ).toEqual( + race === "ordinary" + ? [["task.updated", { projectId: project.id, taskId: task.id }]] + : [], + ); + } finally { + intercepted?.mockRestore(); + } + }, +); diff --git a/tests/api-integration/integration-push-task-ownership.test.ts b/tests/api-integration/integration-push-task-ownership.test.ts new file mode 100644 index 0000000000..3a5bd2eb6e --- /dev/null +++ b/tests/api-integration/integration-push-task-ownership.test.ts @@ -0,0 +1,142 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import db, { getDatabase, schema } from "../../apps/api/src/database"; +import { publishEvent } from "../../apps/api/src/events"; +import { handlePush } from "../../apps/api/src/plugins/github/webhooks/push"; +import { handleGiteaPush } from "../../apps/api/src/plugins/gitea/webhooks/push"; +import { handleGitlabPush } from "../../apps/api/src/plugins/gitlab/webhooks/push"; +import moveTask from "../../apps/api/src/task/controllers/move-task"; +import { resetTestDatabase } from "./helpers/database"; +import { + createProjectFixture, + createWorkspaceMember, +} from "./helpers/fixtures"; +vi.mock("../../apps/api/src/events", () => ({ + publishEvent: vi.fn(async () => undefined), +})); +beforeEach(async () => { + await resetTestDatabase(); + vi.mocked(publishEvent).mockReset(); +}); +it.each( + ["github", "gitea", "gitlab"].flatMap((provider) => + ["new", "existing", "moved"].map((race) => ({ provider, race })), + ), +)( + "$provider branch push preserves ownership and final tasks ($race)", + async ({ provider, race }) => { + const { user, workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ + workspaceId: workspace.id, + slug: "KAN", + }); + const { project: destination } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const [task] = await db + .insert(schema.taskTable) + .values({ + projectId: project.id, + number: 1, + title: "card", + status: "done", + }) + .returning(); + const [integration] = await db + .insert(schema.integrationTable) + .values({ + projectId: project.id, + type: provider, + config: JSON.stringify({ + baseUrl: "https://git.example", + accessToken: "test-only", + repositoryOwner: "owner", + repositoryName: "repo", + projectPath: "owner/repo", + installationId: 10, + repositoryId: 20, + verifiedGithubAccountId: "123", + verifiedByUserId: user.id, + branchPattern: "{slug}-{number}", + statusTransitions: { onBranchPush: "in-progress" }, + }), + }) + .returning(); + if (race === "existing") + await db.insert(schema.externalLinkTable).values({ + taskId: task.id, + integrationId: integration.id, + resourceType: "branch", + externalId: "KAN-1", + url: "https://git.example/owner/repo/tree/KAN-1", + }); + const transaction = getDatabase().transaction.bind(getDatabase()); + const intercepted = + race === "moved" + ? vi + .spyOn(getDatabase(), "transaction") + .mockImplementationOnce(async (apply, config) => { + await moveTask({ + taskId: task.id, + destinationProjectId: destination.id, + userId: user.id, + }); + return transaction(apply, config); + }) + : undefined; + const request = { + ref: "refs/heads/KAN-1", + after: "abc", + installation: { id: 10 }, + repository: { + id: 20, + owner: { login: "owner" }, + name: "repo", + html_url: "https://git.example/owner/repo", + }, + }; + vi.mocked(publishEvent).mockImplementation(async (type) => { + if (type !== "task.updated") return; + const committed = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.taskId, task.id), + }); + expect(committed?.resourceType).toBe("branch"); + }); + try { + if (provider === "github") await handlePush(request); + else if (provider === "gitea") + await handleGiteaPush(request, integration.id); + else + await handleGitlabPush( + { + ref: request.ref, + after: "abc", + project: { + id: 20, + path_with_namespace: "owner/repo", + web_url: "https://git.example/owner/repo", + }, + }, + integration.id, + ); + const saved = await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, task.id), + }); + expect(saved?.projectId).toBe( + race === "moved" ? destination.id : project.id, + ); + expect(saved?.status).toBe(race === "new" ? "in-progress" : "done"); + const links = await db.query.externalLinkTable.findMany({ + where: eq(schema.externalLinkTable.taskId, task.id), + }); + expect(links).toHaveLength(race === "moved" ? 0 : 1); + expect( + vi + .mocked(publishEvent) + .mock.calls.filter(([type]) => type === "task.updated"), + ).toHaveLength(race === "moved" ? 0 : 1); + } finally { + intercepted?.mockRestore(); + } + }, +); diff --git a/tests/api-integration/integration-task-ownership.test.ts b/tests/api-integration/integration-task-ownership.test.ts new file mode 100644 index 0000000000..0ce685d84e --- /dev/null +++ b/tests/api-integration/integration-task-ownership.test.ts @@ -0,0 +1,527 @@ +import { eq } from "drizzle-orm"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; +import db, { getDatabase, schema } from "../../apps/api/src/database"; +import { importGiteaIssues } from "../../apps/api/src/gitea-integration/controllers/import-gitea-issues"; +import { importGitlabIssues } from "../../apps/api/src/gitlab-integration/controllers/import-gitlab-issues"; +import { handleGiteaIssueEdited } from "../../apps/api/src/plugins/gitea/webhooks/issue-edited"; +import { handleGiteaIssueLabeled } from "../../apps/api/src/plugins/gitea/webhooks/issue-labeled"; +import { handleGiteaIssueCommentCreated } from "../../apps/api/src/plugins/gitea/webhooks/issue-comment-created"; +import { withIntegrationTask } from "../../apps/api/src/plugins/github/services/integration-task-scope"; +import moveTask from "../../apps/api/src/task/controllers/move-task"; +import moveProject from "../../apps/api/src/project/controllers/move-project"; +import { resetTestDatabase } from "./helpers/database"; +import { + createProjectFixture, + createWorkspaceMember, +} from "./helpers/fixtures"; + +const m = vi.hoisted(() => ({ + publish: vi.fn(async (_type: string, _data: unknown) => undefined), + listIssues: vi.fn(), + listIssueComments: vi.fn(async () => []), + listIssueNotes: vi.fn(async () => []), + listPulls: vi.fn(async () => []), + listMergeRequests: vi.fn(async () => []), +})); +vi.mock( + "../../apps/api/src/plugins/gitea/utils/gitea-api", + async (original) => ({ + ...(await original< + typeof import("../../apps/api/src/plugins/gitea/utils/gitea-api") + >()), + createGiteaClient: () => m, + }), +); +vi.mock( + "../../apps/api/src/plugins/gitlab/utils/gitlab-api", + async (original) => ({ + ...(await original< + typeof import("../../apps/api/src/plugins/gitlab/utils/gitlab-api") + >()), + createGitlabClient: () => m, + }), +); +vi.mock("../../apps/api/src/events", async (original) => ({ + ...(await original()), + publishEvent: m.publish, +})); +const remoteIssue = { + number: 1, + iid: 1, + title: "Remote title", + body: "Remote description", + description: "Remote description", + html_url: "https://gitea.example/owner/repo/issues/1", + web_url: "https://gitlab.example/group/repo/-/issues/1", + state: "open", + labels: [], +}; +const repository = { + owner: { login: "owner" }, + name: "repo", + html_url: "https://gitea.example/owner/repo", +}; +beforeEach(async () => { + await resetTestDatabase(); + vi.clearAllMocks(); + m.publish.mockReset().mockResolvedValue(undefined); + m.listIssues.mockResolvedValue([remoteIssue]); + m.listIssueComments.mockResolvedValue([]); + m.listIssueNotes.mockResolvedValue([]); +}); +async function setup(type = "gitea") { + const source = await createWorkspaceMember({ role: "owner" }); + const privateWorkspace = await createWorkspaceMember({ role: "owner" }); + const { project } = await createProjectFixture({ + workspaceId: source.workspace.id, + }); + const { project: destination } = await createProjectFixture({ + workspaceId: source.workspace.id, + }); + const [task] = await db + .insert(schema.taskTable) + .values({ + projectId: project.id, + title: "Private title", + description: "Private description", + number: 1, + }) + .returning(); + const [integration] = await db + .insert(schema.integrationTable) + .values({ + projectId: project.id, + type, + config: JSON.stringify({ + baseUrl: `https://${type}.example`, + accessToken: "fake-test-token", + repositoryOwner: "owner", + repositoryName: "repo", + projectPath: "group/repo", + }), + }) + .returning(); + const [link] = await db + .insert(schema.externalLinkTable) + .values({ + taskId: task.id, + integrationId: integration.id, + resourceType: "issue", + externalId: "1", + url: remoteIssue.html_url, + }) + .returning(); + return { + source, + privateWorkspace, + project, + destination, + task, + integration, + link, + }; +} +async function moveWithoutCleanup(fixture: Awaited>) { + // Reproduce links left by earlier releases, even after their new project + // has moved into a private workspace. + await db + .update(schema.taskTable) + .set({ projectId: fixture.destination.id }) + .where(eq(schema.taskTable.id, fixture.task.id)); + await db + .update(schema.projectTable) + .set({ workspaceId: fixture.privateWorkspace.workspace.id }) + .where(eq(schema.projectTable.id, fixture.destination.id)); +} +async function expectPrivateTask(taskId: string) { + expect( + await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, taskId), + }), + ).toMatchObject({ + title: "Private title", + description: "Private description", + }); + expect( + await db.query.labelTable.findMany({ + where: eq(schema.labelTable.taskId, taskId), + }), + ).toEqual([]); + expect( + await db.query.activityTable.findMany({ + where: eq(schema.activityTable.taskId, taskId), + }), + ).toEqual([]); +} +describe("integration task ownership", () => { + it.each(["gitea", "gitlab"])( + "%s import cannot modify a moved task through an old link", + async (type) => { + const fixture = await setup(type); + await moveWithoutCleanup(fixture); + const result = await ( + type === "gitea" ? importGiteaIssues : importGitlabIssues + )(fixture.project.id); + expect(result.updated).toBe(0); + await expectPrivateTask(fixture.task.id); + }, + ); + it.each(["gitea", "gitlab"])( + "%s comment fetching leaves task moves unlocked and rechecks scope afterwards", + async (type) => { + const f = await setup(type); + const fetchComments = + type === "gitea" ? m.listIssueComments : m.listIssueNotes; + fetchComments.mockImplementationOnce(async () => { + await moveTask({ + taskId: f.task.id, + destinationProjectId: f.destination.id, + currentUserId: f.source.user.id, + }); + return []; + }); + const result = await ( + type === "gitea" ? importGiteaIssues : importGitlabIssues + )(f.project.id); + expect(result.updated).toBe(0); + await expectPrivateTask(f.task.id); + expect( + ( + await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, f.task.id), + }) + )?.projectId, + ).toBe(f.destination.id); + }, + ); + + it.each(["gitea", "gitlab"])( + "%s import cannot revive a removed link after a task moves away and back", + async (type) => { + const f = await setup(type); + const fetchComments = + type === "gitea" ? m.listIssueComments : m.listIssueNotes; + fetchComments.mockImplementationOnce(async () => { + await moveTask({ + taskId: f.task.id, + destinationProjectId: f.destination.id, + currentUserId: f.source.user.id, + }); + await moveTask({ + taskId: f.task.id, + destinationProjectId: f.project.id, + currentUserId: f.source.user.id, + }); + return []; + }); + const result = await ( + type === "gitea" ? importGiteaIssues : importGitlabIssues + )(f.project.id); + expect(result).toMatchObject({ updated: 0, skipped: 1 }); + await expectPrivateTask(f.task.id); + expect( + await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.id, f.link.id), + }), + ).toBeUndefined(); + }, + ); + + it("gitea edits, labels and comments cannot follow stale links", async () => { + const f = await setup(); + await moveWithoutCleanup(f); + await handleGiteaIssueEdited( + { + action: "edited", + issue: remoteIssue, + repository, + changes: { + title: { from: "Private title" }, + body: { from: "Private description" }, + }, + }, + f.integration.id, + ); + await handleGiteaIssueLabeled( + { + action: "labeled", + issue: { + number: 1, + labels: [{ name: "priority:high" }, { name: "bug" }], + }, + label: { name: "bug", color: "ff0000" }, + repository, + }, + f.integration.id, + ); + await handleGiteaIssueCommentCreated( + { + action: "created", + issue: { number: 1 }, + repository, + comment: { + id: 4, + body: "Wrong workspace", + html_url: `${remoteIssue.html_url}#comment-4`, + user: { login: "author", avatar_url: "" }, + created_at: new Date().toISOString(), + }, + }, + f.integration.id, + ); + await expectPrivateTask(f.task.id); + }); + it("moving a task removes links to its previous project's integration atomically", async () => { + const f = await setup(); + const [manual] = await db + .insert(schema.externalLinkTable) + .values({ + taskId: f.task.id, + resourceType: "url", + integrationId: null, + url: "https://example.test/manual", + externalId: "manual", + }) + .returning(); + await moveTask({ + taskId: f.task.id, + destinationProjectId: f.destination.id, + currentUserId: f.source.user.id, + }); + expect( + await db.query.externalLinkTable.findMany({ + where: eq(schema.externalLinkTable.taskId, f.task.id), + }), + ).toEqual([manual]); + }); + it("moving a project removes legacy links owned by another project", async () => { + const f = await setup(); + await db + .update(schema.taskTable) + .set({ projectId: f.destination.id }) + .where(eq(schema.taskTable.id, f.task.id)); + await moveProject( + f.destination.id, + f.source.workspace.id, + f.privateWorkspace.workspace.id, + f.source.user.id, + ); + expect( + await db.query.externalLinkTable.findMany({ + where: eq(schema.externalLinkTable.taskId, f.task.id), + }), + ).toEqual([]); + }); + it("a concurrent task move waits for scoped integration writes to commit", async () => { + const f = await setup(); + let release!: () => void; + let started!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + const ready = new Promise((resolve) => { + started = resolve; + }); + const sync = withIntegrationTask( + f.task.id, + { ...f.integration, project: f.project }, + async (tx) => { + started(); + await barrier; + await tx + .update(schema.taskTable) + .set({ title: "Synced before moving" }) + .where(eq(schema.taskTable.id, f.task.id)); + }, + ); + await ready; + let moved = false; + const move = moveTask({ + taskId: f.task.id, + destinationProjectId: f.destination.id, + currentUserId: f.source.user.id, + }).then(() => { + moved = true; + }); + try { + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(moved).toBe(false); + } finally { + release(); + } + await Promise.all([sync, move]); + expect( + await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, f.task.id), + }), + ).toMatchObject({ + projectId: f.destination.id, + title: "Synced before moving", + }); + expect( + await db.query.externalLinkTable.findMany({ + where: eq(schema.externalLinkTable.taskId, f.task.id), + }), + ).toEqual([]); + }); +}); + +afterEach(() => vi.restoreAllMocks()); +it("reports a concurrent move as a conflict without deleting links", async () => { + const f = await setup(); + const transaction = db.transaction.bind(db); + vi.spyOn(getDatabase(), "transaction").mockImplementationOnce( + async (apply, config) => { + await db + .update(schema.taskTable) + .set({ projectId: f.destination.id }) + .where(eq(schema.taskTable.id, f.task.id)); + return transaction(apply, config); + }, + ); + await expect( + moveTask({ + taskId: f.task.id, + destinationProjectId: f.destination.id, + userId: f.source.user.id, + }), + ).rejects.toMatchObject({ status: 409 }); + expect(await db.query.externalLinkTable.findMany()).toHaveLength(1); +}); + +it("preserves legacy links belonging to the destination integration when moving back", async () => { + const f = await setup(); + const [destinationIntegration] = await db + .insert(schema.integrationTable) + .values({ projectId: f.destination.id, type: "gitea", config: "{}" }) + .returning(); + const [compatible] = await db + .insert(schema.externalLinkTable) + .values({ + taskId: f.task.id, + integrationId: destinationIntegration.id, + resourceType: "issue", + externalId: "legacy", + url: "https://gitea.example/legacy", + }) + .returning(); + await moveTask({ + taskId: f.task.id, + destinationProjectId: f.destination.id, + userId: f.source.user.id, + }); + expect(await db.query.externalLinkTable.findMany()).toEqual([compatible]); +}); + +it.each(["edit", "labels", "comment"])( + "ignores a stale %s webhook after its link is removed during a move and return", + async (kind) => { + const f = await setup(); + const transaction = getDatabase().transaction.bind(getDatabase()); + const intercepted = vi + .spyOn(getDatabase(), "transaction") + .mockImplementationOnce(async (apply, config) => { + await moveTask({ + taskId: f.task.id, + destinationProjectId: f.destination.id, + userId: f.source.user.id, + }); + await moveTask({ + taskId: f.task.id, + destinationProjectId: f.project.id, + userId: f.source.user.id, + }); + return transaction(apply, config); + }); + try { + if (kind === "edit") + await handleGiteaIssueEdited( + { + action: "edited", + issue: remoteIssue, + repository, + changes: { + title: { from: "Private title" }, + body: { from: "Private description" }, + }, + }, + f.integration.id, + ); + if (kind === "labels") + await handleGiteaIssueLabeled( + { + action: "labeled", + issue: { + number: 1, + labels: [{ name: "priority:high" }, { name: "bug" }], + }, + label: { name: "bug", color: "ff0000" }, + repository, + }, + f.integration.id, + ); + if (kind === "comment") + await handleGiteaIssueCommentCreated( + { + action: "created", + issue: { number: 1 }, + repository, + comment: { + id: 4, + body: "Wrong workspace", + html_url: `${remoteIssue.html_url}#comment-4`, + user: { login: "author", avatar_url: "" }, + created_at: new Date().toISOString(), + }, + }, + f.integration.id, + ); + await expectPrivateTask(f.task.id); + } finally { + intercepted.mockRestore(); + } + }, +); + +it.each(["gitea", "gitlab"])( + "announces committed %s reimport updates to task and resource caches", + async (provider) => { + const fixture = await setup(provider); + m.publish.mockImplementation(async (type, data) => { + if ( + !["task.updated", "task.labels_updated", "comment.updated"].includes( + type, + ) + ) + return; + expect(data).toEqual({ + projectId: fixture.project.id, + taskId: fixture.task.id, + }); + expect( + await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, fixture.task.id), + }), + ).toMatchObject({ + title: "Remote title", + description: "Remote description", + }); + }); + const result = await ( + provider === "gitea" ? importGiteaIssues : importGitlabIssues + )(fixture.project.id); + expect(result).toMatchObject({ imported: 0, updated: 1 }); + expect(m.publish.mock.calls.map(([type]) => type)).toEqual([ + "task.updated", + "task.labels_updated", + "comment.updated", + ]); + }, +); diff --git a/tests/api-integration/provider-board-live-updates.test.ts b/tests/api-integration/provider-board-live-updates.test.ts new file mode 100644 index 0000000000..053d620ed9 --- /dev/null +++ b/tests/api-integration/provider-board-live-updates.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import { eq } from "drizzle-orm"; +import db, { schema } from "../../apps/api/src/database"; +import { handleIssueEdited } from "../../apps/api/src/plugins/github/webhooks/issue-edited"; +import { handleIssueLabeled } from "../../apps/api/src/plugins/github/webhooks/issue-labeled"; +import { handleGiteaIssueEdited } from "../../apps/api/src/plugins/gitea/webhooks/issue-edited"; +import { handleGiteaIssueLabeled } from "../../apps/api/src/plugins/gitea/webhooks/issue-labeled"; +import { handleGitlabIssueUpdated } from "../../apps/api/src/plugins/gitlab/webhooks/issue-updated"; +import { resetTestDatabase } from "./helpers/database"; +import { + createWorkspaceMember, + createProjectFixture, +} from "./helpers/fixtures"; +const m = vi.hoisted(() => ({ + integrations: [] as unknown[], + publish: vi.fn( + async ( + _name: string, + _payload: { projectId: string; taskId: string }, + ) => {}, + ), +})); +vi.mock("../../apps/api/src/events", () => ({ publishEvent: m.publish })); +vi.mock( + "../../apps/api/src/plugins/github/services/task-service", + async (original) => ({ + ...(await original< + typeof import("../../apps/api/src/plugins/github/services/task-service") + >()), + findAllIntegrationsByRepo: async () => m.integrations, + }), +); +vi.mock( + "../../apps/api/src/plugins/gitea/services/integration-lookup", + async (original) => ({ + ...(await original< + typeof import("../../apps/api/src/plugins/gitea/services/integration-lookup") + >()), + findAllIntegrationsByGiteaRepo: async () => m.integrations, + }), +); +vi.mock( + "../../apps/api/src/plugins/gitlab/services/integration-lookup", + async (original) => ({ + ...(await original< + typeof import("../../apps/api/src/plugins/gitlab/services/integration-lookup") + >()), + findAllIntegrationsByGitlabProject: async () => m.integrations, + }), +); +beforeEach(async () => { + await resetTestDatabase(); + m.publish.mockReset(); +}); +it.each([ + ["github", "text"], + ["gitea", "text"], + ["gitlab", "text"], + ["github", "priority"], + ["gitea", "priority"], + ["gitlab", "priority"], + ["github", "label"], + ["gitea", "label"], + ["gitlab", "label"], + ["github", "label-delete"], + ["gitea", "label-delete"], + ["gitlab", "label-delete"], +])( + "announces committed %s %s edits to observing boards", + async (provider, kind) => { + const { workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const [task] = await db + .insert(schema.taskTable) + .values({ + projectId: project.id, + number: 1, + title: "Before", + description: "Before body", + priority: "low", + }) + .returning(); + const [integration] = await db + .insert(schema.integrationTable) + .values({ + projectId: project.id, + type: provider, + config: "{}", + isActive: true, + }) + .returning(); + m.integrations = [integration]; + await db.insert(schema.externalLinkTable).values({ + taskId: task.id, + integrationId: integration.id, + resourceType: "issue", + externalId: "1", + url: "https://provider.example/issues/1", + metadata: "{}", + }); + if (kind === "label-delete") + await db.insert(schema.labelTable).values({ + taskId: task.id, + workspaceId: workspace.id, + name: "bug", + color: "#ff0000", + }); + m.publish.mockImplementation(async () => { + const committed = await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, task.id), + }); + if (kind === "text") { + expect(committed?.title).toBe("After"); + expect(committed?.description).toBe("After body"); + } else if (kind === "priority") expect(committed?.priority).toBe("high"); + else { + const labels = await db.query.labelTable.findMany({ + where: eq(schema.labelTable.taskId, task.id), + }); + expect(labels.map((label) => label.name)).toEqual( + kind === "label-delete" ? [] : ["bug"], + ); + } + }); + const issue = { + number: 1, + title: "After", + body: "After body", + html_url: "https://gitea.example/owner/repo/issues/1", + }; + const repository = { + id: 2, + name: "repo", + full_name: "owner/repo", + owner: { login: "owner" }, + html_url: "https://gitea.example/owner/repo", + }; + if (provider === "github" || provider === "gitea") { + if (kind === "text") { + const payload = { + action: "edited", + issue, + repository, + changes: { title: { from: "Before" }, body: { from: "Before body" } }, + }; + if (provider === "github") await handleIssueEdited(payload); + else await handleGiteaIssueEdited(payload); + } else { + const label = { + name: kind === "priority" ? "priority:high" : "bug", + color: "ff0000", + }; + const payload = { + action: kind === "label-delete" ? "unlabeled" : "labeled", + issue: { ...issue, labels: kind === "label-delete" ? [] : [label] }, + label, + repository, + }; + if (provider === "github") await handleIssueLabeled(payload); + else await handleGiteaIssueLabeled(payload); + } + } else { + const label = { + title: kind === "priority" ? "priority:high" : "bug", + color: "#ff0000", + }; + await handleGitlabIssueUpdated({ + object_attributes: { + iid: 1, + title: "After", + description: "After body", + url: "https://gitlab.example/owner/repo/-/issues/1", + action: "update", + }, + changes: + kind === "text" + ? { + title: { previous: "Before", current: "After" }, + description: { previous: "Before body", current: "After body" }, + } + : { + labels: { + previous: kind === "label-delete" ? [label] : [], + current: kind === "label-delete" ? [] : [label], + }, + }, + project: { + name: "repo", + web_url: "https://gitlab.example/owner/repo", + path_with_namespace: "owner/repo", + }, + }); + } + const saved = await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, task.id), + }); + if (kind === "text") { + expect(saved?.title).toBe("After"); + expect(saved?.description).toBe("After body"); + } else if (kind === "priority") expect(saved?.priority).toBe("high"); + else { + const labels = await db.query.labelTable.findMany({ + where: eq(schema.labelTable.taskId, task.id), + }); + expect(labels.map((label) => label.name)).toEqual( + kind === "label-delete" ? [] : ["bug"], + ); + } + expect(m.publish.mock.calls).toEqual([ + [ + kind === "text" || (kind === "priority" && provider !== "gitlab") + ? "task.updated" + : "task.labels_updated", + { projectId: project.id, taskId: task.id }, + ], + ]); + }, +); diff --git a/tests/api/gitlab-integration/import-gitlab-issues.test.ts b/tests/api/gitlab-integration/import-gitlab-issues.test.ts index c75cc9ec35..018b145249 100644 --- a/tests/api/gitlab-integration/import-gitlab-issues.test.ts +++ b/tests/api/gitlab-integration/import-gitlab-issues.test.ts @@ -8,6 +8,11 @@ const mocks = vi.hoisted(() => ({ vi.mock("../../../apps/api/src/database", () => ({ default: { + select: () => ({ + from: () => ({ + where: () => ({ for: async () => [{ id: "link-1" }] }), + }), + }), query: { projectTable: { findFirst: async () => ({ @@ -103,3 +108,33 @@ describe("importGitlabIssues labels on an already linked task", () => { ); }); }); + +// Ownership locking is covered by integration-task-scope.test.ts. These cases +// exercise provider behavior with the transaction's existing database mock. +vi.mock( + "../../../apps/api/src/plugins/github/services/integration-task-scope", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../apps/api/src/plugins/github/services/integration-task-scope") + >(); + return { + ...actual, + withIntegrationTask: async ( + _taskId: string, + _integration: unknown, + apply: ( + database: unknown, + afterCommit: (effect: () => Promise) => void, + ) => Promise, + ) => { + const database = (await import("../../../apps/api/src/database")) + .default; + const effects: Array<() => Promise> = []; + const result = await apply(database, (effect) => effects.push(effect)); + for (const effect of effects) await effect(); + return result; + }, + }; + }, +); diff --git a/tests/api/plugins/gitea/comment-sync.test.ts b/tests/api/plugins/gitea/comment-sync.test.ts index be86d4211c..2e16fa6974 100644 --- a/tests/api/plugins/gitea/comment-sync.test.ts +++ b/tests/api/plugins/gitea/comment-sync.test.ts @@ -17,6 +17,11 @@ const config = { vi.mock("../../../../apps/api/src/database", () => ({ default: { + select: () => ({ + from: () => ({ + where: () => ({ for: async () => [{ id: "link-1" }] }), + }), + }), insert: () => ({ values: mocks.values }), update: () => ({ set: () => ({ where: vi.fn() }) }), delete: () => ({ where: vi.fn() }), @@ -165,3 +170,56 @@ describe("Gitea comment sync", () => { ); }); }); + +// Ownership locking is covered by integration-task-scope.test.ts. These cases +// exercise provider behavior with the transaction's existing database mock. +vi.mock( + "../../../../apps/api/src/plugins/github/services/integration-task-scope", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../../apps/api/src/plugins/github/services/integration-task-scope") + >(); + return { + ...actual, + withIntegrationTask: async ( + _taskId: string, + _integration: unknown, + apply: ( + database: unknown, + afterCommit: (effect: () => Promise) => void, + ) => Promise, + ) => { + const database = (await import("../../../../apps/api/src/database")) + .default; + const effects: Array<() => Promise> = []; + const result = await apply(database, (effect) => effects.push(effect)); + for (const effect of effects) await effect(); + return result; + }, + }; + }, +); + +vi.mock( + "../../../../apps/api/src/plugins/github/services/with-integration-link", + async () => ({ + withIntegrationLink: async ( + link: unknown, + integration: unknown, + apply: ( + database: unknown, + afterCommit: (effect: () => Promise) => void, + link: unknown, + ) => Promise, + ) => { + const { withIntegrationTask } = + await import("../../../../apps/api/src/plugins/github/services/integration-task-scope"); + return withIntegrationTask( + (link as { taskId: string }).taskId, + integration as Parameters[1], + (database, afterCommit) => apply(database, afterCommit, link), + ); + }, + }), +); diff --git a/tests/api/plugins/github/integration-task-scope.test.ts b/tests/api/plugins/github/integration-task-scope.test.ts new file mode 100644 index 0000000000..701540fefa --- /dev/null +++ b/tests/api/plugins/github/integration-task-scope.test.ts @@ -0,0 +1,113 @@ +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const m = vi.hoisted(() => ({ + locks: vi.fn(), + predicates: vi.fn(), + commit: vi.fn(), +})); +vi.mock("../../../../apps/api/src/database", () => ({ + default: { + transaction: async (apply: (database: unknown) => Promise) => { + const builder = { + innerJoin: () => builder, + where: (predicate: unknown) => { + m.predicates(predicate); + return builder; + }, + for: m.locks, + }; + const result = await apply({ select: () => ({ from: () => builder }) }); + m.commit(); + return result; + }, + }, +})); +const { withIntegrationTask } = + await import("../../../../apps/api/src/plugins/github/services/integration-task-scope"); +const integration = { + id: "integration", + projectId: "project", + project: { workspaceId: "workspace" }, +}; +beforeEach(() => vi.clearAllMocks()); +describe("integration task ownership", () => { + it("locks and checks the integration project, workspace and task before writing", async () => { + m.locks + .mockResolvedValueOnce([{ id: "project" }]) + .mockResolvedValueOnce([{ id: "task" }]); + const apply = vi.fn().mockResolvedValue("updated"); + expect(await withIntegrationTask("task", integration, apply)).toBe( + "updated", + ); + expect(m.locks.mock.calls.map(([mode]) => mode)).toEqual([ + "key share", + "no key update", + ]); + const predicates = m.predicates.mock.calls.map(([predicate]) => + new PgDialect().sqlToQuery(predicate), + ); + expect(predicates[0].params).toEqual([ + "integration", + "project", + "workspace", + ]); + expect(predicates[1].params).toEqual(["task", "project"]); + expect(predicates[1].sql).toContain('"task"."project_id"'); + expect(apply).toHaveBeenCalledTimes(1); + }); + it("ignores a project moved out of the authorized workspace", async () => { + m.locks.mockResolvedValueOnce([]); + const apply = vi.fn(); + expect( + await withIntegrationTask("task", integration, apply), + ).toBeUndefined(); + expect(apply).not.toHaveBeenCalled(); + expect(m.locks).toHaveBeenCalledTimes(1); + }); + it("ignores a stale link to a task moved into a different project", async () => { + m.locks + .mockResolvedValueOnce([{ id: "project" }]) + .mockResolvedValueOnce([]); + const apply = vi.fn(); + expect( + await withIntegrationTask("task", integration, apply), + ).toBeUndefined(); + expect(apply).not.toHaveBeenCalled(); + }); + it("publishes effects only after committing the scoped writes", async () => { + m.locks + .mockResolvedValueOnce([{ id: "project" }]) + .mockResolvedValueOnce([{ id: "task" }]); + const effect = vi.fn(async () => { + expect(m.commit).toHaveBeenCalledTimes(1); + }); + await withIntegrationTask( + "task", + integration, + async (_database, afterCommit) => { + afterCommit(effect); + expect(effect).not.toHaveBeenCalled(); + }, + ); + expect(effect).toHaveBeenCalledTimes(1); + }); + it("does not publish an event when related writes fail", async () => { + m.locks + .mockResolvedValueOnce([{ id: "project" }]) + .mockResolvedValueOnce([{ id: "task" }]); + const effect = vi.fn(); + await expect( + withIntegrationTask( + "task", + integration, + async (_database, afterCommit) => { + afterCommit(effect); + throw new Error("write failed"); + }, + ), + ).rejects.toThrow("write failed"); + expect(m.commit).not.toHaveBeenCalled(); + expect(effect).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/api/plugins/github/issue-reopened.test.ts b/tests/api/plugins/github/issue-reopened.test.ts index a65535a447..fde8fb3044 100644 --- a/tests/api/plugins/github/issue-reopened.test.ts +++ b/tests/api/plugins/github/issue-reopened.test.ts @@ -68,12 +68,20 @@ it.each(["null", "[]", "42", '"text"', "invalid JSON"])( }); await handleIssueReopened(payload); expect(m.find).toHaveBeenCalledWith(payload); - expect(m.update).toHaveBeenCalledWith("first", { - metadata: { state: "open" }, - }); - expect(m.update).toHaveBeenCalledWith("second", { - metadata: { custom: "keep", state: "open" }, - }); + expect(m.update).toHaveBeenCalledWith( + "first", + { + metadata: { state: "open" }, + }, + expect.anything(), + ); + expect(m.update).toHaveBeenCalledWith( + "second", + { + metadata: { custom: "keep", state: "open" }, + }, + expect.anything(), + ); expect(m.status).toHaveBeenCalledTimes(2); }, ); @@ -88,6 +96,36 @@ it("retains the Kaneo-origin skip rule for valid metadata", async () => { expect(m.update).not.toHaveBeenCalled(); expect(m.status).not.toHaveBeenCalled(); }); + +// Ownership locking is covered by integration-task-scope.test.ts. These cases +// exercise provider behavior with the transaction's existing database mock. +vi.mock( + "../../../../apps/api/src/plugins/github/services/integration-task-scope", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../../apps/api/src/plugins/github/services/integration-task-scope") + >(); + return { + ...actual, + withIntegrationTask: async ( + _taskId: string, + _integration: unknown, + apply: ( + database: unknown, + afterCommit: (effect: () => Promise) => void, + ) => Promise, + ) => { + const database = (await import("../../../../apps/api/src/database")) + .default; + const effects: Array<() => Promise> = []; + const result = await apply(database, (effect) => effects.push(effect)); + for (const effect of effects) await effect(); + return result; + }, + }; + }, +); it("skips a reopen that Kaneo already applied", async () => { m.find.mockResolvedValue([{ id: "integration-1" }]); m.links.mockResolvedValue({ @@ -99,3 +137,26 @@ it("skips a reopen that Kaneo already applied", async () => { expect(m.update).not.toHaveBeenCalled(); expect(m.status).not.toHaveBeenCalled(); }); + +vi.mock( + "../../../../apps/api/src/plugins/github/services/with-integration-link", + async () => ({ + withIntegrationLink: async ( + link: unknown, + integration: unknown, + apply: ( + database: unknown, + afterCommit: (effect: () => Promise) => void, + link: unknown, + ) => Promise, + ) => { + const { withIntegrationTask } = + await import("../../../../apps/api/src/plugins/github/services/integration-task-scope"); + return withIntegrationTask( + (link as { taskId: string }).taskId, + integration as Parameters[1], + (database, afterCommit) => apply(database, afterCommit, link), + ); + }, + }), +); diff --git a/tests/api/plugins/gitlab/webhooks/issue-reopened.test.ts b/tests/api/plugins/gitlab/webhooks/issue-reopened.test.ts index 9cc336e5d2..a9ac8c3f3e 100644 --- a/tests/api/plugins/gitlab/webhooks/issue-reopened.test.ts +++ b/tests/api/plugins/gitlab/webhooks/issue-reopened.test.ts @@ -103,9 +103,13 @@ describe("handleGitlabIssueReopened failures", () => { await handleGitlabIssueReopened(payload, "integration-1"); - expect(mocks.updateExternalLink).toHaveBeenCalledWith("link-1", { - metadata: { state: "opened" }, - }); + expect(mocks.updateExternalLink).toHaveBeenCalledWith( + "link-1", + { + metadata: { state: "opened" }, + }, + expect.anything(), + ); expect(mocks.publishEvent).toHaveBeenCalledWith( "task.status_changed", expect.objectContaining({ @@ -115,3 +119,56 @@ describe("handleGitlabIssueReopened failures", () => { ); }); }); + +// Ownership locking is covered by integration-task-scope.test.ts. These cases +// exercise provider behavior with the transaction's existing database mock. +vi.mock( + "../../../../../apps/api/src/plugins/github/services/integration-task-scope", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../../../apps/api/src/plugins/github/services/integration-task-scope") + >(); + return { + ...actual, + withIntegrationTask: async ( + _taskId: string, + _integration: unknown, + apply: ( + database: unknown, + afterCommit: (effect: () => Promise) => void, + ) => Promise, + ) => { + const database = (await import("../../../../../apps/api/src/database")) + .default; + const effects: Array<() => Promise> = []; + const result = await apply(database, (effect) => effects.push(effect)); + for (const effect of effects) await effect(); + return result; + }, + }; + }, +); + +vi.mock( + "../../../../../apps/api/src/plugins/github/services/with-integration-link", + async () => ({ + withIntegrationLink: async ( + link: unknown, + integration: unknown, + apply: ( + database: unknown, + afterCommit: (effect: () => Promise) => void, + link: unknown, + ) => Promise, + ) => { + const { withIntegrationTask } = + await import("../../../../../apps/api/src/plugins/github/services/integration-task-scope"); + return withIntegrationTask( + (link as { taskId: string }).taskId, + integration as Parameters[1], + (database, afterCommit) => apply(database, afterCommit, link), + ); + }, + }), +); diff --git a/tests/api/plugins/gitlab/webhooks/issue-updated.test.ts b/tests/api/plugins/gitlab/webhooks/issue-updated.test.ts index 78a80643a9..60ac195401 100644 --- a/tests/api/plugins/gitlab/webhooks/issue-updated.test.ts +++ b/tests/api/plugins/gitlab/webhooks/issue-updated.test.ts @@ -170,3 +170,56 @@ describe("handleGitlabIssueUpdated", () => { expect(mocks.taskUpdates).toHaveLength(0); }); }); + +// Ownership locking is covered by integration-task-scope.test.ts. These cases +// exercise provider behavior with the transaction's existing database mock. +vi.mock( + "../../../../../apps/api/src/plugins/github/services/integration-task-scope", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../../../apps/api/src/plugins/github/services/integration-task-scope") + >(); + return { + ...actual, + withIntegrationTask: async ( + _taskId: string, + _integration: unknown, + apply: ( + database: unknown, + afterCommit: (effect: () => Promise) => void, + ) => Promise, + ) => { + const database = (await import("../../../../../apps/api/src/database")) + .default; + const effects: Array<() => Promise> = []; + const result = await apply(database, (effect) => effects.push(effect)); + for (const effect of effects) await effect(); + return result; + }, + }; + }, +); + +vi.mock( + "../../../../../apps/api/src/plugins/github/services/with-integration-link", + async () => ({ + withIntegrationLink: async ( + link: unknown, + integration: unknown, + apply: ( + database: unknown, + afterCommit: (effect: () => Promise) => void, + link: unknown, + ) => Promise, + ) => { + const { withIntegrationTask } = + await import("../../../../../apps/api/src/plugins/github/services/integration-task-scope"); + return withIntegrationTask( + (link as { taskId: string }).taskId, + integration as Parameters[1], + (database, afterCommit) => apply(database, afterCommit, link), + ); + }, + }), +); diff --git a/tests/api/plugins/gitlab/webhooks/note-created.test.ts b/tests/api/plugins/gitlab/webhooks/note-created.test.ts index ec4184a753..996a24c685 100644 --- a/tests/api/plugins/gitlab/webhooks/note-created.test.ts +++ b/tests/api/plugins/gitlab/webhooks/note-created.test.ts @@ -121,3 +121,56 @@ describe("handleGitlabNoteCreated", () => { expect(mocks.insertedActivities).toHaveLength(0); }); }); + +// Ownership locking is covered by integration-task-scope.test.ts. These cases +// exercise provider behavior with the transaction's existing database mock. +vi.mock( + "../../../../../apps/api/src/plugins/github/services/integration-task-scope", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../../../apps/api/src/plugins/github/services/integration-task-scope") + >(); + return { + ...actual, + withIntegrationTask: async ( + _taskId: string, + _integration: unknown, + apply: ( + database: unknown, + afterCommit: (effect: () => Promise) => void, + ) => Promise, + ) => { + const database = (await import("../../../../../apps/api/src/database")) + .default; + const effects: Array<() => Promise> = []; + const result = await apply(database, (effect) => effects.push(effect)); + for (const effect of effects) await effect(); + return result; + }, + }; + }, +); + +vi.mock( + "../../../../../apps/api/src/plugins/github/services/with-integration-link", + async () => ({ + withIntegrationLink: async ( + link: unknown, + integration: unknown, + apply: ( + database: unknown, + afterCommit: (effect: () => Promise) => void, + link: unknown, + ) => Promise, + ) => { + const { withIntegrationTask } = + await import("../../../../../apps/api/src/plugins/github/services/integration-task-scope"); + return withIntegrationTask( + (link as { taskId: string }).taskId, + integration as Parameters[1], + (database, afterCommit) => apply(database, afterCommit, link), + ); + }, + }), +);