Spaces:
Paused
Paused
| import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; | |
| import type { Db } from "@paperclipai/db"; | |
| import { | |
| activityLog, | |
| agents, | |
| assets, | |
| companies, | |
| companyMemberships, | |
| documents, | |
| goals, | |
| heartbeatRuns, | |
| executionWorkspaces, | |
| issueAttachments, | |
| issueInboxArchives, | |
| issueLabels, | |
| issueComments, | |
| issueDocuments, | |
| issueReadStates, | |
| issues, | |
| labels, | |
| projectWorkspaces, | |
| projects, | |
| } from "@paperclipai/db"; | |
| import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared"; | |
| import { conflict, notFound, unprocessable } from "../errors.js"; | |
| import { | |
| defaultIssueExecutionWorkspaceSettingsForProject, | |
| gateProjectExecutionWorkspacePolicy, | |
| issueExecutionWorkspaceModeForPersistedWorkspace, | |
| parseProjectExecutionWorkspacePolicy, | |
| } from "./execution-workspace-policy.js"; | |
| import { instanceSettingsService } from "./instance-settings.js"; | |
| import { redactCurrentUserText } from "../log-redaction.js"; | |
| import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js"; | |
| import { getDefaultCompanyGoal } from "./goals.js"; | |
| const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; | |
| const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; | |
| function assertTransition(from: string, to: string) { | |
| if (from === to) return; | |
| if (!ALL_ISSUE_STATUSES.includes(to)) { | |
| throw conflict(`Unknown issue status: ${to}`); | |
| } | |
| } | |
| function applyStatusSideEffects( | |
| status: string | undefined, | |
| patch: Partial<typeof issues.$inferInsert>, | |
| ): Partial<typeof issues.$inferInsert> { | |
| if (!status) return patch; | |
| if (status === "in_progress" && !patch.startedAt) { | |
| patch.startedAt = new Date(); | |
| } | |
| if (status === "done") { | |
| patch.completedAt = new Date(); | |
| } | |
| if (status === "cancelled") { | |
| patch.cancelledAt = new Date(); | |
| } | |
| return patch; | |
| } | |
| export interface IssueFilters { | |
| status?: string; | |
| assigneeAgentId?: string; | |
| participantAgentId?: string; | |
| assigneeUserId?: string; | |
| touchedByUserId?: string; | |
| inboxArchivedByUserId?: string; | |
| unreadForUserId?: string; | |
| projectId?: string; | |
| executionWorkspaceId?: string; | |
| parentId?: string; | |
| labelId?: string; | |
| originKind?: string; | |
| originId?: string; | |
| includeRoutineExecutions?: boolean; | |
| q?: string; | |
| } | |
| type IssueRow = typeof issues.$inferSelect; | |
| type IssueLabelRow = typeof labels.$inferSelect; | |
| type IssueActiveRunRow = { | |
| id: string; | |
| status: string; | |
| agentId: string; | |
| invocationSource: string; | |
| triggerDetail: string | null; | |
| startedAt: Date | null; | |
| finishedAt: Date | null; | |
| createdAt: Date; | |
| }; | |
| type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] }; | |
| type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null }; | |
| type IssueUserCommentStats = { | |
| issueId: string; | |
| myLastCommentAt: Date | null; | |
| lastExternalCommentAt: Date | null; | |
| }; | |
| type IssueLastActivityStat = { | |
| issueId: string; | |
| latestCommentAt: Date | null; | |
| latestLogAt: Date | null; | |
| }; | |
| type IssueUserContextInput = { | |
| createdByUserId: string | null; | |
| assigneeUserId: string | null; | |
| createdAt: Date | string; | |
| updatedAt: Date | string; | |
| }; | |
| type ProjectGoalReader = Pick<Db, "select">; | |
| type DbReader = Pick<Db, "select">; | |
| type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & { | |
| labelIds?: string[]; | |
| inheritExecutionWorkspaceFromIssueId?: string | null; | |
| }; | |
| function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { | |
| if (actorRunId) return checkoutRunId === actorRunId; | |
| return checkoutRunId == null; | |
| } | |
| const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); | |
| function escapeLikePattern(value: string): string { | |
| return value.replace(/[\\%_]/g, "\\$&"); | |
| } | |
| async function getProjectDefaultGoalId( | |
| db: ProjectGoalReader, | |
| companyId: string, | |
| projectId: string | null | undefined, | |
| ) { | |
| if (!projectId) return null; | |
| const row = await db | |
| .select({ goalId: projects.goalId }) | |
| .from(projects) | |
| .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId))) | |
| .then((rows) => rows[0] ?? null); | |
| return row?.goalId ?? null; | |
| } | |
| async function getWorkspaceInheritanceIssue( | |
| db: DbReader, | |
| companyId: string, | |
| issueId: string, | |
| ) { | |
| const issue = await db | |
| .select({ | |
| id: issues.id, | |
| projectId: issues.projectId, | |
| projectWorkspaceId: issues.projectWorkspaceId, | |
| executionWorkspaceId: issues.executionWorkspaceId, | |
| executionWorkspaceSettings: issues.executionWorkspaceSettings, | |
| }) | |
| .from(issues) | |
| .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) | |
| .then((rows) => rows[0] ?? null); | |
| if (!issue) { | |
| throw notFound("Workspace inheritance issue not found"); | |
| } | |
| return issue; | |
| } | |
| function touchedByUserCondition(companyId: string, userId: string) { | |
| return sql<boolean>` | |
| ( | |
| ${issues.createdByUserId} = ${userId} | |
| OR ${issues.assigneeUserId} = ${userId} | |
| OR EXISTS ( | |
| SELECT 1 | |
| FROM ${issueReadStates} | |
| WHERE ${issueReadStates.issueId} = ${issues.id} | |
| AND ${issueReadStates.companyId} = ${companyId} | |
| AND ${issueReadStates.userId} = ${userId} | |
| ) | |
| OR EXISTS ( | |
| SELECT 1 | |
| FROM ${issueComments} | |
| WHERE ${issueComments.issueId} = ${issues.id} | |
| AND ${issueComments.companyId} = ${companyId} | |
| AND ${issueComments.authorUserId} = ${userId} | |
| ) | |
| ) | |
| `; | |
| } | |
| function participatedByAgentCondition(companyId: string, agentId: string) { | |
| return sql<boolean>` | |
| ( | |
| ${issues.createdByAgentId} = ${agentId} | |
| OR ${issues.assigneeAgentId} = ${agentId} | |
| OR EXISTS ( | |
| SELECT 1 | |
| FROM ${issueComments} | |
| WHERE ${issueComments.issueId} = ${issues.id} | |
| AND ${issueComments.companyId} = ${companyId} | |
| AND ${issueComments.authorAgentId} = ${agentId} | |
| ) | |
| OR EXISTS ( | |
| SELECT 1 | |
| FROM ${activityLog} | |
| WHERE ${activityLog.companyId} = ${companyId} | |
| AND ${activityLog.entityType} = 'issue' | |
| AND ${activityLog.entityId} = ${issues.id}::text | |
| AND ${activityLog.agentId} = ${agentId} | |
| ) | |
| ) | |
| `; | |
| } | |
| function myLastCommentAtExpr(companyId: string, userId: string) { | |
| return sql<Date | null>` | |
| ( | |
| SELECT MAX(${issueComments.createdAt}) | |
| FROM ${issueComments} | |
| WHERE ${issueComments.issueId} = ${issues.id} | |
| AND ${issueComments.companyId} = ${companyId} | |
| AND ${issueComments.authorUserId} = ${userId} | |
| ) | |
| `; | |
| } | |
| function myLastReadAtExpr(companyId: string, userId: string) { | |
| return sql<Date | null>` | |
| ( | |
| SELECT MAX(${issueReadStates.lastReadAt}) | |
| FROM ${issueReadStates} | |
| WHERE ${issueReadStates.issueId} = ${issues.id} | |
| AND ${issueReadStates.companyId} = ${companyId} | |
| AND ${issueReadStates.userId} = ${userId} | |
| ) | |
| `; | |
| } | |
| function myLastTouchAtExpr(companyId: string, userId: string) { | |
| const myLastCommentAt = myLastCommentAtExpr(companyId, userId); | |
| const myLastReadAt = myLastReadAtExpr(companyId, userId); | |
| return sql<Date | null>` | |
| GREATEST( | |
| COALESCE(${myLastCommentAt}, to_timestamp(0)), | |
| COALESCE(${myLastReadAt}, to_timestamp(0)), | |
| COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)), | |
| COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0)) | |
| ) | |
| `; | |
| } | |
| function lastExternalCommentAtExpr(companyId: string, userId: string) { | |
| return sql<Date | null>` | |
| ( | |
| SELECT MAX(${issueComments.createdAt}) | |
| FROM ${issueComments} | |
| WHERE ${issueComments.issueId} = ${issues.id} | |
| AND ${issueComments.companyId} = ${companyId} | |
| AND ( | |
| ${issueComments.authorUserId} IS NULL | |
| OR ${issueComments.authorUserId} <> ${userId} | |
| ) | |
| ) | |
| `; | |
| } | |
| function issueLastActivityAtExpr(companyId: string, userId: string) { | |
| const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId); | |
| const myLastTouchAt = myLastTouchAtExpr(companyId, userId); | |
| return sql<Date>` | |
| GREATEST( | |
| COALESCE(${lastExternalCommentAt}, to_timestamp(0)), | |
| CASE | |
| WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0)) | |
| THEN ${issues.updatedAt} | |
| ELSE to_timestamp(0) | |
| END | |
| ) | |
| `; | |
| } | |
| const ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS = [ | |
| "issue.read_marked", | |
| "issue.read_unmarked", | |
| "issue.inbox_archived", | |
| "issue.inbox_unarchived", | |
| ] as const; | |
| function issueLatestCommentAtExpr(companyId: string) { | |
| return sql<Date | null>` | |
| ( | |
| SELECT MAX(${issueComments.createdAt}) | |
| FROM ${issueComments} | |
| WHERE ${issueComments.issueId} = ${issues.id} | |
| AND ${issueComments.companyId} = ${companyId} | |
| ) | |
| `; | |
| } | |
| function issueLatestLogAtExpr(companyId: string) { | |
| return sql<Date | null>` | |
| ( | |
| SELECT MAX(${activityLog.createdAt}) | |
| FROM ${activityLog} | |
| WHERE ${activityLog.companyId} = ${companyId} | |
| AND ${activityLog.entityType} = 'issue' | |
| AND ${activityLog.entityId} = ${issues.id}::text | |
| AND ${activityLog.action} NOT IN (${sql.join( | |
| ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`), | |
| sql`, `, | |
| )}) | |
| ) | |
| `; | |
| } | |
| function issueCanonicalLastActivityAtExpr(companyId: string) { | |
| const latestCommentAt = issueLatestCommentAtExpr(companyId); | |
| const latestLogAt = issueLatestLogAtExpr(companyId); | |
| return sql<Date>` | |
| GREATEST( | |
| ${issues.updatedAt}, | |
| COALESCE(${latestCommentAt}, to_timestamp(0)), | |
| COALESCE(${latestLogAt}, to_timestamp(0)) | |
| ) | |
| `; | |
| } | |
| function unreadForUserCondition(companyId: string, userId: string) { | |
| const touchedCondition = touchedByUserCondition(companyId, userId); | |
| const myLastTouchAt = myLastTouchAtExpr(companyId, userId); | |
| return sql<boolean>` | |
| ( | |
| ${touchedCondition} | |
| AND EXISTS ( | |
| SELECT 1 | |
| FROM ${issueComments} | |
| WHERE ${issueComments.issueId} = ${issues.id} | |
| AND ${issueComments.companyId} = ${companyId} | |
| AND ( | |
| ${issueComments.authorUserId} IS NULL | |
| OR ${issueComments.authorUserId} <> ${userId} | |
| ) | |
| AND ${issueComments.createdAt} > ${myLastTouchAt} | |
| ) | |
| ) | |
| `; | |
| } | |
| function inboxVisibleForUserCondition(companyId: string, userId: string) { | |
| const issueLastActivityAt = issueLastActivityAtExpr(companyId, userId); | |
| return sql<boolean>` | |
| NOT EXISTS ( | |
| SELECT 1 | |
| FROM ${issueInboxArchives} | |
| WHERE ${issueInboxArchives.issueId} = ${issues.id} | |
| AND ${issueInboxArchives.companyId} = ${companyId} | |
| AND ${issueInboxArchives.userId} = ${userId} | |
| AND ${issueInboxArchives.archivedAt} >= ${issueLastActivityAt} | |
| ) | |
| `; | |
| } | |
| /** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */ | |
| const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = { | |
| amp: "&", | |
| apos: "'", | |
| copy: "\u00A9", | |
| gt: ">", | |
| lt: "<", | |
| nbsp: "\u00A0", | |
| quot: '"', | |
| ensp: "\u2002", | |
| emsp: "\u2003", | |
| thinsp: "\u2009", | |
| }; | |
| function decodeNumericHtmlEntity(digits: string, radix: 16 | 10): string | null { | |
| const n = Number.parseInt(digits, radix); | |
| if (Number.isNaN(n) || n < 0 || n > 0x10ffff) return null; | |
| try { | |
| return String.fromCodePoint(n); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| /** Decodes HTML character references in a raw @mention capture so UI-encoded bodies match agent names. */ | |
| export function normalizeAgentMentionToken(raw: string): string { | |
| let s = raw.replace(/&#x([0-9a-fA-F]+);/gi, (full, hex: string) => decodeNumericHtmlEntity(hex, 16) ?? full); | |
| s = s.replace(/&#([0-9]+);/g, (full, dec: string) => decodeNumericHtmlEntity(dec, 10) ?? full); | |
| s = s.replace(/&([a-z][a-z0-9]*);/gi, (full, name: string) => { | |
| const decoded = WELL_KNOWN_NAMED_HTML_ENTITIES[name.toLowerCase()]; | |
| return decoded !== undefined ? decoded : full; | |
| }); | |
| return s.trim(); | |
| } | |
| export function deriveIssueUserContext( | |
| issue: IssueUserContextInput, | |
| userId: string, | |
| stats: | |
| | { | |
| myLastCommentAt: Date | string | null; | |
| myLastReadAt: Date | string | null; | |
| lastExternalCommentAt: Date | string | null; | |
| } | |
| | null | |
| | undefined, | |
| ) { | |
| const normalizeDate = (value: Date | string | null | undefined) => { | |
| if (!value) return null; | |
| if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; | |
| const parsed = new Date(value); | |
| return Number.isNaN(parsed.getTime()) ? null : parsed; | |
| }; | |
| const myLastCommentAt = normalizeDate(stats?.myLastCommentAt); | |
| const myLastReadAt = normalizeDate(stats?.myLastReadAt); | |
| const createdTouchAt = issue.createdByUserId === userId ? normalizeDate(issue.createdAt) : null; | |
| const assignedTouchAt = issue.assigneeUserId === userId ? normalizeDate(issue.updatedAt) : null; | |
| const myLastTouchAt = [myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt] | |
| .filter((value): value is Date => value instanceof Date) | |
| .sort((a, b) => b.getTime() - a.getTime())[0] ?? null; | |
| const lastExternalCommentAt = normalizeDate(stats?.lastExternalCommentAt); | |
| const isUnreadForMe = Boolean( | |
| myLastTouchAt && | |
| lastExternalCommentAt && | |
| lastExternalCommentAt.getTime() > myLastTouchAt.getTime(), | |
| ); | |
| return { | |
| myLastTouchAt, | |
| lastExternalCommentAt, | |
| isUnreadForMe, | |
| }; | |
| } | |
| function latestIssueActivityAt(...values: Array<Date | string | null | undefined>): Date | null { | |
| const normalized = values | |
| .map((value) => { | |
| if (!value) return null; | |
| if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; | |
| const parsed = new Date(value); | |
| return Number.isNaN(parsed.getTime()) ? null : parsed; | |
| }) | |
| .filter((value): value is Date => value instanceof Date) | |
| .sort((a, b) => b.getTime() - a.getTime()); | |
| return normalized[0] ?? null; | |
| } | |
| async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> { | |
| const map = new Map<string, IssueLabelRow[]>(); | |
| if (issueIds.length === 0) return map; | |
| const rows = await dbOrTx | |
| .select({ | |
| issueId: issueLabels.issueId, | |
| label: labels, | |
| }) | |
| .from(issueLabels) | |
| .innerJoin(labels, eq(issueLabels.labelId, labels.id)) | |
| .where(inArray(issueLabels.issueId, issueIds)) | |
| .orderBy(asc(labels.name), asc(labels.id)); | |
| for (const row of rows) { | |
| const existing = map.get(row.issueId); | |
| if (existing) existing.push(row.label); | |
| else map.set(row.issueId, [row.label]); | |
| } | |
| return map; | |
| } | |
| async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWithLabels[]> { | |
| if (rows.length === 0) return []; | |
| const labelsByIssueId = await labelMapForIssues(dbOrTx, rows.map((row) => row.id)); | |
| return rows.map((row) => { | |
| const issueLabels = labelsByIssueId.get(row.id) ?? []; | |
| return { | |
| ...row, | |
| labels: issueLabels, | |
| labelIds: issueLabels.map((label) => label.id), | |
| }; | |
| }); | |
| } | |
| const ACTIVE_RUN_STATUSES = ["queued", "running"]; | |
| async function activeRunMapForIssues( | |
| dbOrTx: any, | |
| issueRows: IssueWithLabels[], | |
| ): Promise<Map<string, IssueActiveRunRow>> { | |
| const map = new Map<string, IssueActiveRunRow>(); | |
| const runIds = issueRows | |
| .map((row) => row.executionRunId) | |
| .filter((id): id is string => id != null); | |
| if (runIds.length === 0) return map; | |
| const rows = await dbOrTx | |
| .select({ | |
| id: heartbeatRuns.id, | |
| status: heartbeatRuns.status, | |
| agentId: heartbeatRuns.agentId, | |
| invocationSource: heartbeatRuns.invocationSource, | |
| triggerDetail: heartbeatRuns.triggerDetail, | |
| startedAt: heartbeatRuns.startedAt, | |
| finishedAt: heartbeatRuns.finishedAt, | |
| createdAt: heartbeatRuns.createdAt, | |
| }) | |
| .from(heartbeatRuns) | |
| .where( | |
| and( | |
| inArray(heartbeatRuns.id, runIds), | |
| inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES), | |
| ), | |
| ); | |
| for (const row of rows) { | |
| map.set(row.id, row); | |
| } | |
| return map; | |
| } | |
| function withActiveRuns( | |
| issueRows: IssueWithLabels[], | |
| runMap: Map<string, IssueActiveRunRow>, | |
| ): IssueWithLabelsAndRun[] { | |
| return issueRows.map((row) => ({ | |
| ...row, | |
| activeRun: row.executionRunId ? (runMap.get(row.executionRunId) ?? null) : null, | |
| })); | |
| } | |
| export function issueService(db: Db) { | |
| const instanceSettings = instanceSettingsService(db); | |
| async function getIssueByUuid(id: string) { | |
| const row = await db | |
| .select() | |
| .from(issues) | |
| .where(eq(issues.id, id)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!row) return null; | |
| const [enriched] = await withIssueLabels(db, [row]); | |
| return enriched; | |
| } | |
| async function getIssueByIdentifier(identifier: string) { | |
| const row = await db | |
| .select() | |
| .from(issues) | |
| .where(eq(issues.identifier, identifier.toUpperCase())) | |
| .then((rows) => rows[0] ?? null); | |
| if (!row) return null; | |
| const [enriched] = await withIssueLabels(db, [row]); | |
| return enriched; | |
| } | |
| function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T { | |
| return { | |
| ...comment, | |
| body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }), | |
| }; | |
| } | |
| async function assertAssignableAgent(companyId: string, agentId: string) { | |
| const assignee = await db | |
| .select({ | |
| id: agents.id, | |
| companyId: agents.companyId, | |
| status: agents.status, | |
| }) | |
| .from(agents) | |
| .where(eq(agents.id, agentId)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!assignee) throw notFound("Assignee agent not found"); | |
| if (assignee.companyId !== companyId) { | |
| throw unprocessable("Assignee must belong to same company"); | |
| } | |
| if (assignee.status === "pending_approval") { | |
| throw conflict("Cannot assign work to pending approval agents"); | |
| } | |
| if (assignee.status === "terminated") { | |
| throw conflict("Cannot assign work to terminated agents"); | |
| } | |
| } | |
| async function assertAssignableUser(companyId: string, userId: string) { | |
| const membership = await db | |
| .select({ id: companyMemberships.id }) | |
| .from(companyMemberships) | |
| .where( | |
| and( | |
| eq(companyMemberships.companyId, companyId), | |
| eq(companyMemberships.principalType, "user"), | |
| eq(companyMemberships.principalId, userId), | |
| eq(companyMemberships.status, "active"), | |
| ), | |
| ) | |
| .then((rows) => rows[0] ?? null); | |
| if (!membership) { | |
| throw notFound("Assignee user not found"); | |
| } | |
| } | |
| async function assertValidProjectWorkspace( | |
| companyId: string, | |
| projectId: string | null | undefined, | |
| projectWorkspaceId: string, | |
| dbOrTx: DbReader = db, | |
| ) { | |
| const workspace = await dbOrTx | |
| .select({ | |
| id: projectWorkspaces.id, | |
| companyId: projectWorkspaces.companyId, | |
| projectId: projectWorkspaces.projectId, | |
| }) | |
| .from(projectWorkspaces) | |
| .where(eq(projectWorkspaces.id, projectWorkspaceId)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!workspace) throw notFound("Project workspace not found"); | |
| if (workspace.companyId !== companyId) throw unprocessable("Project workspace must belong to same company"); | |
| if (projectId && workspace.projectId !== projectId) { | |
| throw unprocessable("Project workspace must belong to the selected project"); | |
| } | |
| } | |
| async function assertValidExecutionWorkspace( | |
| companyId: string, | |
| projectId: string | null | undefined, | |
| executionWorkspaceId: string, | |
| dbOrTx: DbReader = db, | |
| ) { | |
| const workspace = await dbOrTx | |
| .select({ | |
| id: executionWorkspaces.id, | |
| companyId: executionWorkspaces.companyId, | |
| projectId: executionWorkspaces.projectId, | |
| }) | |
| .from(executionWorkspaces) | |
| .where(eq(executionWorkspaces.id, executionWorkspaceId)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!workspace) throw notFound("Execution workspace not found"); | |
| if (workspace.companyId !== companyId) throw unprocessable("Execution workspace must belong to same company"); | |
| if (projectId && workspace.projectId !== projectId) { | |
| throw unprocessable("Execution workspace must belong to the selected project"); | |
| } | |
| } | |
| async function assertValidLabelIds(companyId: string, labelIds: string[], dbOrTx: any = db) { | |
| if (labelIds.length === 0) return; | |
| const existing = await dbOrTx | |
| .select({ id: labels.id }) | |
| .from(labels) | |
| .where(and(eq(labels.companyId, companyId), inArray(labels.id, labelIds))); | |
| if (existing.length !== new Set(labelIds).size) { | |
| throw unprocessable("One or more labels are invalid for this company"); | |
| } | |
| } | |
| async function syncIssueLabels( | |
| issueId: string, | |
| companyId: string, | |
| labelIds: string[], | |
| dbOrTx: any = db, | |
| ) { | |
| const deduped = [...new Set(labelIds)]; | |
| await assertValidLabelIds(companyId, deduped, dbOrTx); | |
| await dbOrTx.delete(issueLabels).where(eq(issueLabels.issueId, issueId)); | |
| if (deduped.length === 0) return; | |
| await dbOrTx.insert(issueLabels).values( | |
| deduped.map((labelId) => ({ | |
| issueId, | |
| labelId, | |
| companyId, | |
| })), | |
| ); | |
| } | |
| async function isTerminalOrMissingHeartbeatRun(runId: string) { | |
| const run = await db | |
| .select({ status: heartbeatRuns.status }) | |
| .from(heartbeatRuns) | |
| .where(eq(heartbeatRuns.id, runId)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!run) return true; | |
| return TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status); | |
| } | |
| async function adoptStaleCheckoutRun(input: { | |
| issueId: string; | |
| actorAgentId: string; | |
| actorRunId: string; | |
| expectedCheckoutRunId: string; | |
| }) { | |
| const stale = await isTerminalOrMissingHeartbeatRun(input.expectedCheckoutRunId); | |
| if (!stale) return null; | |
| const now = new Date(); | |
| const adopted = await db | |
| .update(issues) | |
| .set({ | |
| checkoutRunId: input.actorRunId, | |
| executionRunId: input.actorRunId, | |
| executionLockedAt: now, | |
| updatedAt: now, | |
| }) | |
| .where( | |
| and( | |
| eq(issues.id, input.issueId), | |
| eq(issues.status, "in_progress"), | |
| eq(issues.assigneeAgentId, input.actorAgentId), | |
| eq(issues.checkoutRunId, input.expectedCheckoutRunId), | |
| ), | |
| ) | |
| .returning({ | |
| id: issues.id, | |
| status: issues.status, | |
| assigneeAgentId: issues.assigneeAgentId, | |
| checkoutRunId: issues.checkoutRunId, | |
| executionRunId: issues.executionRunId, | |
| }) | |
| .then((rows) => rows[0] ?? null); | |
| return adopted; | |
| } | |
| return { | |
| list: async (companyId: string, filters?: IssueFilters) => { | |
| const conditions = [eq(issues.companyId, companyId)]; | |
| const touchedByUserId = filters?.touchedByUserId?.trim() || undefined; | |
| const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; | |
| const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; | |
| const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId; | |
| const rawSearch = filters?.q?.trim() ?? ""; | |
| const hasSearch = rawSearch.length > 0; | |
| const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : ""; | |
| const startsWithPattern = `${escapedSearch}%`; | |
| const containsPattern = `%${escapedSearch}%`; | |
| const titleStartsWithMatch = sql<boolean>`${issues.title} ILIKE ${startsWithPattern} ESCAPE '\\'`; | |
| const titleContainsMatch = sql<boolean>`${issues.title} ILIKE ${containsPattern} ESCAPE '\\'`; | |
| const identifierStartsWithMatch = sql<boolean>`${issues.identifier} ILIKE ${startsWithPattern} ESCAPE '\\'`; | |
| const identifierContainsMatch = sql<boolean>`${issues.identifier} ILIKE ${containsPattern} ESCAPE '\\'`; | |
| const descriptionContainsMatch = sql<boolean>`${issues.description} ILIKE ${containsPattern} ESCAPE '\\'`; | |
| const commentContainsMatch = sql<boolean>` | |
| EXISTS ( | |
| SELECT 1 | |
| FROM ${issueComments} | |
| WHERE ${issueComments.issueId} = ${issues.id} | |
| AND ${issueComments.companyId} = ${companyId} | |
| AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\' | |
| ) | |
| `; | |
| if (filters?.status) { | |
| const statuses = filters.status.split(",").map((s) => s.trim()); | |
| conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses)); | |
| } | |
| if (filters?.assigneeAgentId) { | |
| conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); | |
| } | |
| if (filters?.participantAgentId) { | |
| conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId)); | |
| } | |
| if (filters?.assigneeUserId) { | |
| conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); | |
| } | |
| if (touchedByUserId) { | |
| conditions.push(touchedByUserCondition(companyId, touchedByUserId)); | |
| } | |
| if (inboxArchivedByUserId) { | |
| conditions.push(inboxVisibleForUserCondition(companyId, inboxArchivedByUserId)); | |
| } | |
| if (unreadForUserId) { | |
| conditions.push(unreadForUserCondition(companyId, unreadForUserId)); | |
| } | |
| if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); | |
| if (filters?.executionWorkspaceId) { | |
| conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId)); | |
| } | |
| if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); | |
| if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); | |
| if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); | |
| if (filters?.labelId) { | |
| const labeledIssueIds = await db | |
| .select({ issueId: issueLabels.issueId }) | |
| .from(issueLabels) | |
| .where(and(eq(issueLabels.companyId, companyId), eq(issueLabels.labelId, filters.labelId))); | |
| if (labeledIssueIds.length === 0) return []; | |
| conditions.push(inArray(issues.id, labeledIssueIds.map((row) => row.issueId))); | |
| } | |
| if (hasSearch) { | |
| conditions.push( | |
| or( | |
| titleContainsMatch, | |
| identifierContainsMatch, | |
| descriptionContainsMatch, | |
| commentContainsMatch, | |
| )!, | |
| ); | |
| } | |
| if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) { | |
| conditions.push(ne(issues.originKind, "routine_execution")); | |
| } | |
| conditions.push(isNull(issues.hiddenAt)); | |
| const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`; | |
| const searchOrder = sql<number>` | |
| CASE | |
| WHEN ${titleStartsWithMatch} THEN 0 | |
| WHEN ${titleContainsMatch} THEN 1 | |
| WHEN ${identifierStartsWithMatch} THEN 2 | |
| WHEN ${identifierContainsMatch} THEN 3 | |
| WHEN ${descriptionContainsMatch} THEN 4 | |
| WHEN ${commentContainsMatch} THEN 5 | |
| ELSE 6 | |
| END | |
| `; | |
| const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); | |
| const rows = await db | |
| .select() | |
| .from(issues) | |
| .where(and(...conditions)) | |
| .orderBy( | |
| hasSearch ? asc(searchOrder) : asc(priorityOrder), | |
| asc(priorityOrder), | |
| desc(canonicalLastActivityAt), | |
| desc(issues.updatedAt), | |
| ); | |
| const withLabels = await withIssueLabels(db, rows); | |
| const runMap = await activeRunMapForIssues(db, withLabels); | |
| const withRuns = withActiveRuns(withLabels, runMap); | |
| if (withRuns.length === 0) { | |
| return withRuns; | |
| } | |
| const issueIds = withRuns.map((row) => row.id); | |
| const [statsRows, readRows, lastActivityRows] = await Promise.all([ | |
| contextUserId | |
| ? db | |
| .select({ | |
| issueId: issueComments.issueId, | |
| myLastCommentAt: sql<Date | null>` | |
| MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END) | |
| `, | |
| lastExternalCommentAt: sql<Date | null>` | |
| MAX( | |
| CASE | |
| WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId} | |
| THEN ${issueComments.createdAt} | |
| END | |
| ) | |
| `, | |
| }) | |
| .from(issueComments) | |
| .where( | |
| and( | |
| eq(issueComments.companyId, companyId), | |
| inArray(issueComments.issueId, issueIds), | |
| ), | |
| ) | |
| .groupBy(issueComments.issueId) | |
| : Promise.resolve([]), | |
| contextUserId | |
| ? db | |
| .select({ | |
| issueId: issueReadStates.issueId, | |
| myLastReadAt: issueReadStates.lastReadAt, | |
| }) | |
| .from(issueReadStates) | |
| .where( | |
| and( | |
| eq(issueReadStates.companyId, companyId), | |
| eq(issueReadStates.userId, contextUserId), | |
| inArray(issueReadStates.issueId, issueIds), | |
| ), | |
| ) | |
| : Promise.resolve([]), | |
| Promise.all([ | |
| db | |
| .select({ | |
| issueId: issueComments.issueId, | |
| latestCommentAt: sql<Date | null>`MAX(${issueComments.createdAt})`, | |
| }) | |
| .from(issueComments) | |
| .where( | |
| and( | |
| eq(issueComments.companyId, companyId), | |
| inArray(issueComments.issueId, issueIds), | |
| ), | |
| ) | |
| .groupBy(issueComments.issueId), | |
| db | |
| .select({ | |
| issueId: activityLog.entityId, | |
| latestLogAt: sql<Date | null>`MAX(${activityLog.createdAt})`, | |
| }) | |
| .from(activityLog) | |
| .where( | |
| and( | |
| eq(activityLog.companyId, companyId), | |
| eq(activityLog.entityType, "issue"), | |
| inArray(activityLog.entityId, issueIds), | |
| sql`${activityLog.action} NOT IN (${sql.join( | |
| ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`), | |
| sql`, `, | |
| )})`, | |
| ), | |
| ) | |
| .groupBy(activityLog.entityId), | |
| ]).then(([commentRows, logRows]) => { | |
| const byIssueId = new Map<string, IssueLastActivityStat>(); | |
| for (const row of commentRows) { | |
| byIssueId.set(row.issueId, { | |
| issueId: row.issueId, | |
| latestCommentAt: row.latestCommentAt, | |
| latestLogAt: null, | |
| }); | |
| } | |
| for (const row of logRows) { | |
| const existing = byIssueId.get(row.issueId); | |
| if (existing) existing.latestLogAt = row.latestLogAt; | |
| else { | |
| byIssueId.set(row.issueId, { | |
| issueId: row.issueId, | |
| latestCommentAt: null, | |
| latestLogAt: row.latestLogAt, | |
| }); | |
| } | |
| } | |
| return [...byIssueId.values()]; | |
| }), | |
| ]); | |
| const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); | |
| const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); | |
| if (!contextUserId) { | |
| return withRuns.map((row) => { | |
| const activity = lastActivityByIssueId.get(row.id); | |
| const lastActivityAt = latestIssueActivityAt( | |
| row.updatedAt, | |
| activity?.latestCommentAt ?? null, | |
| activity?.latestLogAt ?? null, | |
| ) ?? row.updatedAt; | |
| return { | |
| ...row, | |
| lastActivityAt, | |
| }; | |
| }); | |
| } | |
| const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt])); | |
| return withRuns.map((row) => { | |
| const activity = lastActivityByIssueId.get(row.id); | |
| const lastActivityAt = latestIssueActivityAt( | |
| row.updatedAt, | |
| activity?.latestCommentAt ?? null, | |
| activity?.latestLogAt ?? null, | |
| ) ?? row.updatedAt; | |
| return { | |
| ...row, | |
| lastActivityAt, | |
| ...deriveIssueUserContext(row, contextUserId, { | |
| myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null, | |
| myLastReadAt: readByIssueId.get(row.id) ?? null, | |
| lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null, | |
| }), | |
| }; | |
| }); | |
| }, | |
| countUnreadTouchedByUser: async (companyId: string, userId: string, status?: string) => { | |
| const conditions = [ | |
| eq(issues.companyId, companyId), | |
| isNull(issues.hiddenAt), | |
| unreadForUserCondition(companyId, userId), | |
| ne(issues.originKind, "routine_execution"), | |
| ]; | |
| if (status) { | |
| const statuses = status.split(",").map((s) => s.trim()).filter(Boolean); | |
| if (statuses.length === 1) { | |
| conditions.push(eq(issues.status, statuses[0])); | |
| } else if (statuses.length > 1) { | |
| conditions.push(inArray(issues.status, statuses)); | |
| } | |
| } | |
| const [row] = await db | |
| .select({ count: sql<number>`count(*)` }) | |
| .from(issues) | |
| .where(and(...conditions)); | |
| return Number(row?.count ?? 0); | |
| }, | |
| markRead: async (companyId: string, issueId: string, userId: string, readAt: Date = new Date()) => { | |
| const now = new Date(); | |
| const [row] = await db | |
| .insert(issueReadStates) | |
| .values({ | |
| companyId, | |
| issueId, | |
| userId, | |
| lastReadAt: readAt, | |
| updatedAt: now, | |
| }) | |
| .onConflictDoUpdate({ | |
| target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId], | |
| set: { | |
| lastReadAt: readAt, | |
| updatedAt: now, | |
| }, | |
| }) | |
| .returning(); | |
| return row; | |
| }, | |
| markUnread: async (companyId: string, issueId: string, userId: string) => { | |
| const deleted = await db | |
| .delete(issueReadStates) | |
| .where( | |
| and( | |
| eq(issueReadStates.companyId, companyId), | |
| eq(issueReadStates.issueId, issueId), | |
| eq(issueReadStates.userId, userId), | |
| ), | |
| ) | |
| .returning(); | |
| return deleted.length > 0; | |
| }, | |
| archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => { | |
| const now = new Date(); | |
| const [row] = await db | |
| .insert(issueInboxArchives) | |
| .values({ | |
| companyId, | |
| issueId, | |
| userId, | |
| archivedAt, | |
| updatedAt: now, | |
| }) | |
| .onConflictDoUpdate({ | |
| target: [issueInboxArchives.companyId, issueInboxArchives.issueId, issueInboxArchives.userId], | |
| set: { | |
| archivedAt, | |
| updatedAt: now, | |
| }, | |
| }) | |
| .returning(); | |
| return row; | |
| }, | |
| unarchiveInbox: async (companyId: string, issueId: string, userId: string) => { | |
| const [row] = await db | |
| .delete(issueInboxArchives) | |
| .where( | |
| and( | |
| eq(issueInboxArchives.companyId, companyId), | |
| eq(issueInboxArchives.issueId, issueId), | |
| eq(issueInboxArchives.userId, userId), | |
| ), | |
| ) | |
| .returning(); | |
| return row ?? null; | |
| }, | |
| getById: async (raw: string) => { | |
| const id = raw.trim(); | |
| if (/^[A-Z]+-\d+$/i.test(id)) { | |
| return getIssueByIdentifier(id); | |
| } | |
| if (!isUuidLike(id)) { | |
| return null; | |
| } | |
| return getIssueByUuid(id); | |
| }, | |
| getByIdentifier: async (identifier: string) => { | |
| return getIssueByIdentifier(identifier); | |
| }, | |
| create: async ( | |
| companyId: string, | |
| data: IssueCreateInput, | |
| ) => { | |
| const { labelIds: inputLabelIds, inheritExecutionWorkspaceFromIssueId, ...issueData } = data; | |
| const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; | |
| if (!isolatedWorkspacesEnabled) { | |
| delete issueData.executionWorkspaceId; | |
| delete issueData.executionWorkspacePreference; | |
| delete issueData.executionWorkspaceSettings; | |
| } | |
| if (data.assigneeAgentId && data.assigneeUserId) { | |
| throw unprocessable("Issue can only have one assignee"); | |
| } | |
| if (data.assigneeAgentId) { | |
| await assertAssignableAgent(companyId, data.assigneeAgentId); | |
| } | |
| if (data.assigneeUserId) { | |
| await assertAssignableUser(companyId, data.assigneeUserId); | |
| } | |
| if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) { | |
| throw unprocessable("in_progress issues require an assignee"); | |
| } | |
| return db.transaction(async (tx) => { | |
| const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId); | |
| const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId); | |
| let projectWorkspaceId = issueData.projectWorkspaceId ?? null; | |
| let executionWorkspaceId = issueData.executionWorkspaceId ?? null; | |
| let executionWorkspacePreference = issueData.executionWorkspacePreference ?? null; | |
| let executionWorkspaceSettings = | |
| (issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null; | |
| const workspaceInheritanceIssueId = inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null; | |
| const hasExplicitExecutionWorkspaceOverride = | |
| issueData.executionWorkspaceId !== undefined || | |
| issueData.executionWorkspacePreference !== undefined || | |
| issueData.executionWorkspaceSettings !== undefined; | |
| if (workspaceInheritanceIssueId) { | |
| const workspaceSource = await getWorkspaceInheritanceIssue(tx, companyId, workspaceInheritanceIssueId); | |
| if (projectWorkspaceId == null && workspaceSource.projectWorkspaceId) { | |
| projectWorkspaceId = workspaceSource.projectWorkspaceId; | |
| } | |
| if ( | |
| isolatedWorkspacesEnabled && | |
| !hasExplicitExecutionWorkspaceOverride && | |
| workspaceSource.executionWorkspaceId | |
| ) { | |
| const sourceWorkspace = await tx | |
| .select({ | |
| id: executionWorkspaces.id, | |
| mode: executionWorkspaces.mode, | |
| }) | |
| .from(executionWorkspaces) | |
| .where(eq(executionWorkspaces.id, workspaceSource.executionWorkspaceId)) | |
| .then((rows) => rows[0] ?? null); | |
| if (sourceWorkspace) { | |
| executionWorkspaceId = sourceWorkspace.id; | |
| executionWorkspacePreference = "reuse_existing"; | |
| executionWorkspaceSettings = { | |
| ...((workspaceSource.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? {}), | |
| mode: issueExecutionWorkspaceModeForPersistedWorkspace(sourceWorkspace.mode), | |
| }; | |
| } | |
| } | |
| } | |
| if ( | |
| executionWorkspaceSettings == null && | |
| executionWorkspaceId == null && | |
| issueData.projectId | |
| ) { | |
| const project = await tx | |
| .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) | |
| .from(projects) | |
| .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId))) | |
| .then((rows) => rows[0] ?? null); | |
| executionWorkspaceSettings = | |
| defaultIssueExecutionWorkspaceSettingsForProject( | |
| gateProjectExecutionWorkspacePolicy( | |
| parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy), | |
| isolatedWorkspacesEnabled, | |
| ), | |
| ) as Record<string, unknown> | null; | |
| } | |
| if (!projectWorkspaceId && issueData.projectId) { | |
| const project = await tx | |
| .select({ | |
| executionWorkspacePolicy: projects.executionWorkspacePolicy, | |
| }) | |
| .from(projects) | |
| .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId))) | |
| .then((rows) => rows[0] ?? null); | |
| const projectPolicy = parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy); | |
| projectWorkspaceId = projectPolicy?.defaultProjectWorkspaceId ?? null; | |
| if (!projectWorkspaceId) { | |
| projectWorkspaceId = await tx | |
| .select({ id: projectWorkspaces.id }) | |
| .from(projectWorkspaces) | |
| .where(and(eq(projectWorkspaces.projectId, issueData.projectId), eq(projectWorkspaces.companyId, companyId))) | |
| .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)) | |
| .then((rows) => rows[0]?.id ?? null); | |
| } | |
| } | |
| if (projectWorkspaceId) { | |
| await assertValidProjectWorkspace(companyId, issueData.projectId, projectWorkspaceId, tx); | |
| } | |
| if (executionWorkspaceId) { | |
| await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx); | |
| } | |
| const [company] = await tx | |
| .update(companies) | |
| .set({ issueCounter: sql`${companies.issueCounter} + 1` }) | |
| .where(eq(companies.id, companyId)) | |
| .returning({ issueCounter: companies.issueCounter, issuePrefix: companies.issuePrefix }); | |
| const issueNumber = company.issueCounter; | |
| const identifier = `${company.issuePrefix}-${issueNumber}`; | |
| const values = { | |
| ...issueData, | |
| originKind: issueData.originKind ?? "manual", | |
| goalId: resolveIssueGoalId({ | |
| projectId: issueData.projectId, | |
| goalId: issueData.goalId, | |
| projectGoalId, | |
| defaultGoalId: defaultCompanyGoal?.id ?? null, | |
| }), | |
| ...(projectWorkspaceId ? { projectWorkspaceId } : {}), | |
| ...(executionWorkspaceId ? { executionWorkspaceId } : {}), | |
| ...(executionWorkspacePreference ? { executionWorkspacePreference } : {}), | |
| ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), | |
| companyId, | |
| issueNumber, | |
| identifier, | |
| } as typeof issues.$inferInsert; | |
| if (values.status === "in_progress" && !values.startedAt) { | |
| values.startedAt = new Date(); | |
| } | |
| if (values.status === "done") { | |
| values.completedAt = new Date(); | |
| } | |
| if (values.status === "cancelled") { | |
| values.cancelledAt = new Date(); | |
| } | |
| const [issue] = await tx.insert(issues).values(values).returning(); | |
| if (inputLabelIds) { | |
| await syncIssueLabels(issue.id, companyId, inputLabelIds, tx); | |
| } | |
| const [enriched] = await withIssueLabels(tx, [issue]); | |
| return enriched; | |
| }); | |
| }, | |
| update: async (id: string, data: Partial<typeof issues.$inferInsert> & { labelIds?: string[] }) => { | |
| const existing = await db | |
| .select() | |
| .from(issues) | |
| .where(eq(issues.id, id)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!existing) return null; | |
| const { labelIds: nextLabelIds, ...issueData } = data; | |
| const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; | |
| if (!isolatedWorkspacesEnabled) { | |
| delete issueData.executionWorkspaceId; | |
| delete issueData.executionWorkspacePreference; | |
| delete issueData.executionWorkspaceSettings; | |
| } | |
| if (issueData.status) { | |
| assertTransition(existing.status, issueData.status); | |
| } | |
| const patch: Partial<typeof issues.$inferInsert> = { | |
| ...issueData, | |
| updatedAt: new Date(), | |
| }; | |
| const nextAssigneeAgentId = | |
| issueData.assigneeAgentId !== undefined ? issueData.assigneeAgentId : existing.assigneeAgentId; | |
| const nextAssigneeUserId = | |
| issueData.assigneeUserId !== undefined ? issueData.assigneeUserId : existing.assigneeUserId; | |
| if (nextAssigneeAgentId && nextAssigneeUserId) { | |
| throw unprocessable("Issue can only have one assignee"); | |
| } | |
| if (patch.status === "in_progress" && !nextAssigneeAgentId && !nextAssigneeUserId) { | |
| throw unprocessable("in_progress issues require an assignee"); | |
| } | |
| if (issueData.assigneeAgentId) { | |
| await assertAssignableAgent(existing.companyId, issueData.assigneeAgentId); | |
| } | |
| if (issueData.assigneeUserId) { | |
| await assertAssignableUser(existing.companyId, issueData.assigneeUserId); | |
| } | |
| const nextProjectId = issueData.projectId !== undefined ? issueData.projectId : existing.projectId; | |
| const nextProjectWorkspaceId = | |
| issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId; | |
| const nextExecutionWorkspaceId = | |
| issueData.executionWorkspaceId !== undefined ? issueData.executionWorkspaceId : existing.executionWorkspaceId; | |
| if (nextProjectWorkspaceId) { | |
| await assertValidProjectWorkspace(existing.companyId, nextProjectId, nextProjectWorkspaceId); | |
| } | |
| if (nextExecutionWorkspaceId) { | |
| await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId); | |
| } | |
| applyStatusSideEffects(issueData.status, patch); | |
| if (issueData.status && issueData.status !== "done") { | |
| patch.completedAt = null; | |
| } | |
| if (issueData.status && issueData.status !== "cancelled") { | |
| patch.cancelledAt = null; | |
| } | |
| if (issueData.status && issueData.status !== "in_progress") { | |
| patch.checkoutRunId = null; | |
| } | |
| if ( | |
| (issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== existing.assigneeAgentId) || | |
| (issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== existing.assigneeUserId) | |
| ) { | |
| patch.checkoutRunId = null; | |
| } | |
| return db.transaction(async (tx) => { | |
| const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId); | |
| const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([ | |
| getProjectDefaultGoalId(tx, existing.companyId, existing.projectId), | |
| getProjectDefaultGoalId( | |
| tx, | |
| existing.companyId, | |
| issueData.projectId !== undefined ? issueData.projectId : existing.projectId, | |
| ), | |
| ]); | |
| patch.goalId = resolveNextIssueGoalId({ | |
| currentProjectId: existing.projectId, | |
| currentGoalId: existing.goalId, | |
| currentProjectGoalId, | |
| projectId: issueData.projectId, | |
| goalId: issueData.goalId, | |
| projectGoalId: nextProjectGoalId, | |
| defaultGoalId: defaultCompanyGoal?.id ?? null, | |
| }); | |
| const updated = await tx | |
| .update(issues) | |
| .set(patch) | |
| .where(eq(issues.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| if (!updated) return null; | |
| if (nextLabelIds !== undefined) { | |
| await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx); | |
| } | |
| const [enriched] = await withIssueLabels(tx, [updated]); | |
| return enriched; | |
| }); | |
| }, | |
| remove: (id: string) => | |
| db.transaction(async (tx) => { | |
| const attachmentAssetIds = await tx | |
| .select({ assetId: issueAttachments.assetId }) | |
| .from(issueAttachments) | |
| .where(eq(issueAttachments.issueId, id)); | |
| const issueDocumentIds = await tx | |
| .select({ documentId: issueDocuments.documentId }) | |
| .from(issueDocuments) | |
| .where(eq(issueDocuments.issueId, id)); | |
| const removedIssue = await tx | |
| .delete(issues) | |
| .where(eq(issues.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| if (removedIssue && attachmentAssetIds.length > 0) { | |
| await tx | |
| .delete(assets) | |
| .where(inArray(assets.id, attachmentAssetIds.map((row) => row.assetId))); | |
| } | |
| if (removedIssue && issueDocumentIds.length > 0) { | |
| await tx | |
| .delete(documents) | |
| .where(inArray(documents.id, issueDocumentIds.map((row) => row.documentId))); | |
| } | |
| if (!removedIssue) return null; | |
| const [enriched] = await withIssueLabels(tx, [removedIssue]); | |
| return enriched; | |
| }), | |
| checkout: async (id: string, agentId: string, expectedStatuses: string[], checkoutRunId: string | null) => { | |
| const issueCompany = await db | |
| .select({ companyId: issues.companyId }) | |
| .from(issues) | |
| .where(eq(issues.id, id)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!issueCompany) throw notFound("Issue not found"); | |
| await assertAssignableAgent(issueCompany.companyId, agentId); | |
| const now = new Date(); | |
| const sameRunAssigneeCondition = checkoutRunId | |
| ? and( | |
| eq(issues.assigneeAgentId, agentId), | |
| or(isNull(issues.checkoutRunId), eq(issues.checkoutRunId, checkoutRunId)), | |
| ) | |
| : and(eq(issues.assigneeAgentId, agentId), isNull(issues.checkoutRunId)); | |
| const executionLockCondition = checkoutRunId | |
| ? or(isNull(issues.executionRunId), eq(issues.executionRunId, checkoutRunId)) | |
| : isNull(issues.executionRunId); | |
| const updated = await db | |
| .update(issues) | |
| .set({ | |
| assigneeAgentId: agentId, | |
| assigneeUserId: null, | |
| checkoutRunId, | |
| executionRunId: checkoutRunId, | |
| status: "in_progress", | |
| startedAt: now, | |
| updatedAt: now, | |
| }) | |
| .where( | |
| and( | |
| eq(issues.id, id), | |
| inArray(issues.status, expectedStatuses), | |
| or(isNull(issues.assigneeAgentId), sameRunAssigneeCondition), | |
| executionLockCondition, | |
| ), | |
| ) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| if (updated) { | |
| const [enriched] = await withIssueLabels(db, [updated]); | |
| return enriched; | |
| } | |
| const current = await db | |
| .select({ | |
| id: issues.id, | |
| status: issues.status, | |
| assigneeAgentId: issues.assigneeAgentId, | |
| checkoutRunId: issues.checkoutRunId, | |
| executionRunId: issues.executionRunId, | |
| }) | |
| .from(issues) | |
| .where(eq(issues.id, id)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!current) throw notFound("Issue not found"); | |
| if ( | |
| current.assigneeAgentId === agentId && | |
| current.status === "in_progress" && | |
| current.checkoutRunId == null && | |
| (current.executionRunId == null || current.executionRunId === checkoutRunId) && | |
| checkoutRunId | |
| ) { | |
| const adopted = await db | |
| .update(issues) | |
| .set({ | |
| checkoutRunId, | |
| executionRunId: checkoutRunId, | |
| updatedAt: new Date(), | |
| }) | |
| .where( | |
| and( | |
| eq(issues.id, id), | |
| eq(issues.status, "in_progress"), | |
| eq(issues.assigneeAgentId, agentId), | |
| isNull(issues.checkoutRunId), | |
| or(isNull(issues.executionRunId), eq(issues.executionRunId, checkoutRunId)), | |
| ), | |
| ) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| if (adopted) return adopted; | |
| } | |
| if ( | |
| checkoutRunId && | |
| current.assigneeAgentId === agentId && | |
| current.status === "in_progress" && | |
| current.checkoutRunId && | |
| current.checkoutRunId !== checkoutRunId | |
| ) { | |
| const adopted = await adoptStaleCheckoutRun({ | |
| issueId: id, | |
| actorAgentId: agentId, | |
| actorRunId: checkoutRunId, | |
| expectedCheckoutRunId: current.checkoutRunId, | |
| }); | |
| if (adopted) { | |
| const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!); | |
| const [enriched] = await withIssueLabels(db, [row]); | |
| return enriched; | |
| } | |
| } | |
| // If this run already owns it and it's in_progress, return it (no self-409) | |
| if ( | |
| current.assigneeAgentId === agentId && | |
| current.status === "in_progress" && | |
| sameRunLock(current.checkoutRunId, checkoutRunId) | |
| ) { | |
| const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!); | |
| const [enriched] = await withIssueLabels(db, [row]); | |
| return enriched; | |
| } | |
| throw conflict("Issue checkout conflict", { | |
| issueId: current.id, | |
| status: current.status, | |
| assigneeAgentId: current.assigneeAgentId, | |
| checkoutRunId: current.checkoutRunId, | |
| executionRunId: current.executionRunId, | |
| }); | |
| }, | |
| assertCheckoutOwner: async (id: string, actorAgentId: string, actorRunId: string | null) => { | |
| const current = await db | |
| .select({ | |
| id: issues.id, | |
| status: issues.status, | |
| assigneeAgentId: issues.assigneeAgentId, | |
| checkoutRunId: issues.checkoutRunId, | |
| }) | |
| .from(issues) | |
| .where(eq(issues.id, id)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!current) throw notFound("Issue not found"); | |
| if ( | |
| current.status === "in_progress" && | |
| current.assigneeAgentId === actorAgentId && | |
| sameRunLock(current.checkoutRunId, actorRunId) | |
| ) { | |
| return { ...current, adoptedFromRunId: null as string | null }; | |
| } | |
| if ( | |
| actorRunId && | |
| current.status === "in_progress" && | |
| current.assigneeAgentId === actorAgentId && | |
| current.checkoutRunId && | |
| current.checkoutRunId !== actorRunId | |
| ) { | |
| const adopted = await adoptStaleCheckoutRun({ | |
| issueId: id, | |
| actorAgentId, | |
| actorRunId, | |
| expectedCheckoutRunId: current.checkoutRunId, | |
| }); | |
| if (adopted) { | |
| return { | |
| ...adopted, | |
| adoptedFromRunId: current.checkoutRunId, | |
| }; | |
| } | |
| } | |
| throw conflict("Issue run ownership conflict", { | |
| issueId: current.id, | |
| status: current.status, | |
| assigneeAgentId: current.assigneeAgentId, | |
| checkoutRunId: current.checkoutRunId, | |
| actorAgentId, | |
| actorRunId, | |
| }); | |
| }, | |
| release: async (id: string, actorAgentId?: string, actorRunId?: string | null) => { | |
| const existing = await db | |
| .select() | |
| .from(issues) | |
| .where(eq(issues.id, id)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!existing) return null; | |
| if (actorAgentId && existing.assigneeAgentId && existing.assigneeAgentId !== actorAgentId) { | |
| throw conflict("Only assignee can release issue"); | |
| } | |
| if ( | |
| actorAgentId && | |
| existing.status === "in_progress" && | |
| existing.assigneeAgentId === actorAgentId && | |
| existing.checkoutRunId && | |
| !sameRunLock(existing.checkoutRunId, actorRunId ?? null) | |
| ) { | |
| throw conflict("Only checkout run can release issue", { | |
| issueId: existing.id, | |
| assigneeAgentId: existing.assigneeAgentId, | |
| checkoutRunId: existing.checkoutRunId, | |
| actorRunId: actorRunId ?? null, | |
| }); | |
| } | |
| const updated = await db | |
| .update(issues) | |
| .set({ | |
| status: "todo", | |
| assigneeAgentId: null, | |
| checkoutRunId: null, | |
| updatedAt: new Date(), | |
| }) | |
| .where(eq(issues.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| if (!updated) return null; | |
| const [enriched] = await withIssueLabels(db, [updated]); | |
| return enriched; | |
| }, | |
| listLabels: (companyId: string) => | |
| db.select().from(labels).where(eq(labels.companyId, companyId)).orderBy(asc(labels.name), asc(labels.id)), | |
| getLabelById: (id: string) => | |
| db | |
| .select() | |
| .from(labels) | |
| .where(eq(labels.id, id)) | |
| .then((rows) => rows[0] ?? null), | |
| createLabel: async (companyId: string, data: Pick<typeof labels.$inferInsert, "name" | "color">) => { | |
| const [created] = await db | |
| .insert(labels) | |
| .values({ | |
| companyId, | |
| name: data.name.trim(), | |
| color: data.color, | |
| }) | |
| .returning(); | |
| return created; | |
| }, | |
| deleteLabel: async (id: string) => | |
| db | |
| .delete(labels) | |
| .where(eq(labels.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null), | |
| listComments: async ( | |
| issueId: string, | |
| opts?: { | |
| afterCommentId?: string | null; | |
| order?: "asc" | "desc"; | |
| limit?: number | null; | |
| }, | |
| ) => { | |
| const order = opts?.order === "asc" ? "asc" : "desc"; | |
| const afterCommentId = opts?.afterCommentId?.trim() || null; | |
| const limit = | |
| opts?.limit && opts.limit > 0 | |
| ? Math.min(Math.floor(opts.limit), MAX_ISSUE_COMMENT_PAGE_LIMIT) | |
| : null; | |
| const conditions = [eq(issueComments.issueId, issueId)]; | |
| if (afterCommentId) { | |
| const anchor = await db | |
| .select({ | |
| id: issueComments.id, | |
| createdAt: issueComments.createdAt, | |
| }) | |
| .from(issueComments) | |
| .where(and(eq(issueComments.issueId, issueId), eq(issueComments.id, afterCommentId))) | |
| .then((rows) => rows[0] ?? null); | |
| if (!anchor) return []; | |
| conditions.push( | |
| order === "asc" | |
| ? sql<boolean>`( | |
| ${issueComments.createdAt} > ${anchor.createdAt} | |
| OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} > ${anchor.id}) | |
| )` | |
| : sql<boolean>`( | |
| ${issueComments.createdAt} < ${anchor.createdAt} | |
| OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} < ${anchor.id}) | |
| )`, | |
| ); | |
| } | |
| const query = db | |
| .select() | |
| .from(issueComments) | |
| .where(and(...conditions)) | |
| .orderBy( | |
| order === "asc" ? asc(issueComments.createdAt) : desc(issueComments.createdAt), | |
| order === "asc" ? asc(issueComments.id) : desc(issueComments.id), | |
| ); | |
| const comments = limit ? await query.limit(limit) : await query; | |
| const { censorUsernameInLogs } = await instanceSettings.getGeneral(); | |
| return comments.map((comment) => redactIssueComment(comment, censorUsernameInLogs)); | |
| }, | |
| getCommentCursor: async (issueId: string) => { | |
| const [latest, countRow] = await Promise.all([ | |
| db | |
| .select({ | |
| latestCommentId: issueComments.id, | |
| latestCommentAt: issueComments.createdAt, | |
| }) | |
| .from(issueComments) | |
| .where(eq(issueComments.issueId, issueId)) | |
| .orderBy(desc(issueComments.createdAt), desc(issueComments.id)) | |
| .limit(1) | |
| .then((rows) => rows[0] ?? null), | |
| db | |
| .select({ | |
| totalComments: sql<number>`count(*)::int`, | |
| }) | |
| .from(issueComments) | |
| .where(eq(issueComments.issueId, issueId)) | |
| .then((rows) => rows[0] ?? null), | |
| ]); | |
| return { | |
| totalComments: Number(countRow?.totalComments ?? 0), | |
| latestCommentId: latest?.latestCommentId ?? null, | |
| latestCommentAt: latest?.latestCommentAt ?? null, | |
| }; | |
| }, | |
| getComment: (commentId: string) => | |
| instanceSettings.getGeneral().then(({ censorUsernameInLogs }) => | |
| db | |
| .select() | |
| .from(issueComments) | |
| .where(eq(issueComments.id, commentId)) | |
| .then((rows) => { | |
| const comment = rows[0] ?? null; | |
| return comment ? redactIssueComment(comment, censorUsernameInLogs) : null; | |
| })), | |
| addComment: async ( | |
| issueId: string, | |
| body: string, | |
| actor: { agentId?: string; userId?: string; runId?: string | null }, | |
| ) => { | |
| const issue = await db | |
| .select({ companyId: issues.companyId }) | |
| .from(issues) | |
| .where(eq(issues.id, issueId)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!issue) throw notFound("Issue not found"); | |
| const currentUserRedactionOptions = { | |
| enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, | |
| }; | |
| const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions); | |
| const [comment] = await db | |
| .insert(issueComments) | |
| .values({ | |
| companyId: issue.companyId, | |
| issueId, | |
| authorAgentId: actor.agentId ?? null, | |
| authorUserId: actor.userId ?? null, | |
| createdByRunId: actor.runId ?? null, | |
| body: redactedBody, | |
| }) | |
| .returning(); | |
| // Update issue's updatedAt so comment activity is reflected in recency sorting | |
| await db | |
| .update(issues) | |
| .set({ updatedAt: new Date() }) | |
| .where(eq(issues.id, issueId)); | |
| return redactIssueComment(comment, currentUserRedactionOptions.enabled); | |
| }, | |
| createAttachment: async (input: { | |
| issueId: string; | |
| issueCommentId?: string | null; | |
| provider: string; | |
| objectKey: string; | |
| contentType: string; | |
| byteSize: number; | |
| sha256: string; | |
| originalFilename?: string | null; | |
| createdByAgentId?: string | null; | |
| createdByUserId?: string | null; | |
| }) => { | |
| const issue = await db | |
| .select({ id: issues.id, companyId: issues.companyId }) | |
| .from(issues) | |
| .where(eq(issues.id, input.issueId)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!issue) throw notFound("Issue not found"); | |
| if (input.issueCommentId) { | |
| const comment = await db | |
| .select({ id: issueComments.id, companyId: issueComments.companyId, issueId: issueComments.issueId }) | |
| .from(issueComments) | |
| .where(eq(issueComments.id, input.issueCommentId)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!comment) throw notFound("Issue comment not found"); | |
| if (comment.companyId !== issue.companyId || comment.issueId !== issue.id) { | |
| throw unprocessable("Attachment comment must belong to same issue and company"); | |
| } | |
| } | |
| return db.transaction(async (tx) => { | |
| const [asset] = await tx | |
| .insert(assets) | |
| .values({ | |
| companyId: issue.companyId, | |
| provider: input.provider, | |
| objectKey: input.objectKey, | |
| contentType: input.contentType, | |
| byteSize: input.byteSize, | |
| sha256: input.sha256, | |
| originalFilename: input.originalFilename ?? null, | |
| createdByAgentId: input.createdByAgentId ?? null, | |
| createdByUserId: input.createdByUserId ?? null, | |
| }) | |
| .returning(); | |
| const [attachment] = await tx | |
| .insert(issueAttachments) | |
| .values({ | |
| companyId: issue.companyId, | |
| issueId: issue.id, | |
| assetId: asset.id, | |
| issueCommentId: input.issueCommentId ?? null, | |
| }) | |
| .returning(); | |
| return { | |
| id: attachment.id, | |
| companyId: attachment.companyId, | |
| issueId: attachment.issueId, | |
| issueCommentId: attachment.issueCommentId, | |
| assetId: attachment.assetId, | |
| provider: asset.provider, | |
| objectKey: asset.objectKey, | |
| contentType: asset.contentType, | |
| byteSize: asset.byteSize, | |
| sha256: asset.sha256, | |
| originalFilename: asset.originalFilename, | |
| createdByAgentId: asset.createdByAgentId, | |
| createdByUserId: asset.createdByUserId, | |
| createdAt: attachment.createdAt, | |
| updatedAt: attachment.updatedAt, | |
| }; | |
| }); | |
| }, | |
| listAttachments: async (issueId: string) => | |
| db | |
| .select({ | |
| id: issueAttachments.id, | |
| companyId: issueAttachments.companyId, | |
| issueId: issueAttachments.issueId, | |
| issueCommentId: issueAttachments.issueCommentId, | |
| assetId: issueAttachments.assetId, | |
| provider: assets.provider, | |
| objectKey: assets.objectKey, | |
| contentType: assets.contentType, | |
| byteSize: assets.byteSize, | |
| sha256: assets.sha256, | |
| originalFilename: assets.originalFilename, | |
| createdByAgentId: assets.createdByAgentId, | |
| createdByUserId: assets.createdByUserId, | |
| createdAt: issueAttachments.createdAt, | |
| updatedAt: issueAttachments.updatedAt, | |
| }) | |
| .from(issueAttachments) | |
| .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) | |
| .where(eq(issueAttachments.issueId, issueId)) | |
| .orderBy(desc(issueAttachments.createdAt)), | |
| getAttachmentById: async (id: string) => | |
| db | |
| .select({ | |
| id: issueAttachments.id, | |
| companyId: issueAttachments.companyId, | |
| issueId: issueAttachments.issueId, | |
| issueCommentId: issueAttachments.issueCommentId, | |
| assetId: issueAttachments.assetId, | |
| provider: assets.provider, | |
| objectKey: assets.objectKey, | |
| contentType: assets.contentType, | |
| byteSize: assets.byteSize, | |
| sha256: assets.sha256, | |
| originalFilename: assets.originalFilename, | |
| createdByAgentId: assets.createdByAgentId, | |
| createdByUserId: assets.createdByUserId, | |
| createdAt: issueAttachments.createdAt, | |
| updatedAt: issueAttachments.updatedAt, | |
| }) | |
| .from(issueAttachments) | |
| .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) | |
| .where(eq(issueAttachments.id, id)) | |
| .then((rows) => rows[0] ?? null), | |
| removeAttachment: async (id: string) => | |
| db.transaction(async (tx) => { | |
| const existing = await tx | |
| .select({ | |
| id: issueAttachments.id, | |
| companyId: issueAttachments.companyId, | |
| issueId: issueAttachments.issueId, | |
| issueCommentId: issueAttachments.issueCommentId, | |
| assetId: issueAttachments.assetId, | |
| provider: assets.provider, | |
| objectKey: assets.objectKey, | |
| contentType: assets.contentType, | |
| byteSize: assets.byteSize, | |
| sha256: assets.sha256, | |
| originalFilename: assets.originalFilename, | |
| createdByAgentId: assets.createdByAgentId, | |
| createdByUserId: assets.createdByUserId, | |
| createdAt: issueAttachments.createdAt, | |
| updatedAt: issueAttachments.updatedAt, | |
| }) | |
| .from(issueAttachments) | |
| .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) | |
| .where(eq(issueAttachments.id, id)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!existing) return null; | |
| await tx.delete(issueAttachments).where(eq(issueAttachments.id, id)); | |
| await tx.delete(assets).where(eq(assets.id, existing.assetId)); | |
| return existing; | |
| }), | |
| findMentionedAgents: async (companyId: string, body: string) => { | |
| const re = /\B@([^\s@,!?.]+)/g; | |
| const tokens = new Set<string>(); | |
| let m: RegExpExecArray | null; | |
| while ((m = re.exec(body)) !== null) { | |
| const normalized = normalizeAgentMentionToken(m[1]); | |
| if (normalized) tokens.add(normalized.toLowerCase()); | |
| } | |
| const explicitAgentMentionIds = extractAgentMentionIds(body); | |
| if (tokens.size === 0 && explicitAgentMentionIds.length === 0) return []; | |
| const rows = await db.select({ id: agents.id, name: agents.name }) | |
| .from(agents).where(eq(agents.companyId, companyId)); | |
| const resolved = new Set<string>(explicitAgentMentionIds); | |
| for (const agent of rows) { | |
| if (tokens.has(agent.name.toLowerCase())) { | |
| resolved.add(agent.id); | |
| } | |
| } | |
| return [...resolved]; | |
| }, | |
| findMentionedProjectIds: async (issueId: string) => { | |
| const issue = await db | |
| .select({ | |
| companyId: issues.companyId, | |
| title: issues.title, | |
| description: issues.description, | |
| }) | |
| .from(issues) | |
| .where(eq(issues.id, issueId)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!issue) return []; | |
| const comments = await db | |
| .select({ body: issueComments.body }) | |
| .from(issueComments) | |
| .where(eq(issueComments.issueId, issueId)); | |
| const mentionedIds = new Set<string>(); | |
| for (const source of [ | |
| issue.title, | |
| issue.description ?? "", | |
| ...comments.map((comment) => comment.body), | |
| ]) { | |
| for (const projectId of extractProjectMentionIds(source)) { | |
| mentionedIds.add(projectId); | |
| } | |
| } | |
| if (mentionedIds.size === 0) return []; | |
| const rows = await db | |
| .select({ id: projects.id }) | |
| .from(projects) | |
| .where( | |
| and( | |
| eq(projects.companyId, issue.companyId), | |
| inArray(projects.id, [...mentionedIds]), | |
| ), | |
| ); | |
| const valid = new Set(rows.map((row) => row.id)); | |
| return [...mentionedIds].filter((projectId) => valid.has(projectId)); | |
| }, | |
| getAncestors: async (issueId: string) => { | |
| const raw: Array<{ | |
| id: string; identifier: string | null; title: string; description: string | null; | |
| status: string; priority: string; | |
| assigneeAgentId: string | null; projectId: string | null; goalId: string | null; | |
| }> = []; | |
| const visited = new Set<string>([issueId]); | |
| const start = await db.select().from(issues).where(eq(issues.id, issueId)).then(r => r[0] ?? null); | |
| let currentId = start?.parentId ?? null; | |
| while (currentId && !visited.has(currentId) && raw.length < 50) { | |
| visited.add(currentId); | |
| const parent = await db.select({ | |
| id: issues.id, identifier: issues.identifier, title: issues.title, description: issues.description, | |
| status: issues.status, priority: issues.priority, | |
| assigneeAgentId: issues.assigneeAgentId, projectId: issues.projectId, | |
| goalId: issues.goalId, parentId: issues.parentId, | |
| }).from(issues).where(eq(issues.id, currentId)).then(r => r[0] ?? null); | |
| if (!parent) break; | |
| raw.push({ | |
| id: parent.id, identifier: parent.identifier ?? null, title: parent.title, description: parent.description ?? null, | |
| status: parent.status, priority: parent.priority, | |
| assigneeAgentId: parent.assigneeAgentId ?? null, | |
| projectId: parent.projectId ?? null, goalId: parent.goalId ?? null, | |
| }); | |
| currentId = parent.parentId ?? null; | |
| } | |
| // Batch-fetch referenced projects and goals | |
| const projectIds = [...new Set(raw.map(a => a.projectId).filter((id): id is string => id != null))]; | |
| const goalIds = [...new Set(raw.map(a => a.goalId).filter((id): id is string => id != null))]; | |
| const projectMap = new Map<string, { | |
| id: string; | |
| name: string; | |
| description: string | null; | |
| status: string; | |
| goalId: string | null; | |
| workspaces: Array<{ | |
| id: string; | |
| companyId: string; | |
| projectId: string; | |
| name: string; | |
| cwd: string | null; | |
| repoUrl: string | null; | |
| repoRef: string | null; | |
| metadata: Record<string, unknown> | null; | |
| isPrimary: boolean; | |
| createdAt: Date; | |
| updatedAt: Date; | |
| }>; | |
| primaryWorkspace: { | |
| id: string; | |
| companyId: string; | |
| projectId: string; | |
| name: string; | |
| cwd: string | null; | |
| repoUrl: string | null; | |
| repoRef: string | null; | |
| metadata: Record<string, unknown> | null; | |
| isPrimary: boolean; | |
| createdAt: Date; | |
| updatedAt: Date; | |
| } | null; | |
| }>(); | |
| const goalMap = new Map<string, { id: string; title: string; description: string | null; level: string; status: string }>(); | |
| if (projectIds.length > 0) { | |
| const workspaceRows = await db | |
| .select() | |
| .from(projectWorkspaces) | |
| .where(inArray(projectWorkspaces.projectId, projectIds)) | |
| .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); | |
| const workspaceMap = new Map<string, Array<(typeof workspaceRows)[number]>>(); | |
| for (const workspace of workspaceRows) { | |
| const existing = workspaceMap.get(workspace.projectId); | |
| if (existing) existing.push(workspace); | |
| else workspaceMap.set(workspace.projectId, [workspace]); | |
| } | |
| const rows = await db.select({ | |
| id: projects.id, name: projects.name, description: projects.description, | |
| status: projects.status, goalId: projects.goalId, | |
| }).from(projects).where(inArray(projects.id, projectIds)); | |
| for (const r of rows) { | |
| const projectWorkspaceRows = workspaceMap.get(r.id) ?? []; | |
| const workspaces = projectWorkspaceRows.map((workspace) => ({ | |
| id: workspace.id, | |
| companyId: workspace.companyId, | |
| projectId: workspace.projectId, | |
| name: workspace.name, | |
| cwd: workspace.cwd, | |
| repoUrl: workspace.repoUrl ?? null, | |
| repoRef: workspace.repoRef ?? null, | |
| metadata: (workspace.metadata as Record<string, unknown> | null) ?? null, | |
| isPrimary: workspace.isPrimary, | |
| createdAt: workspace.createdAt, | |
| updatedAt: workspace.updatedAt, | |
| })); | |
| const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null; | |
| projectMap.set(r.id, { | |
| ...r, | |
| workspaces, | |
| primaryWorkspace, | |
| }); | |
| // Also collect goalIds from projects | |
| if (r.goalId && !goalIds.includes(r.goalId)) goalIds.push(r.goalId); | |
| } | |
| } | |
| if (goalIds.length > 0) { | |
| const rows = await db.select({ | |
| id: goals.id, title: goals.title, description: goals.description, | |
| level: goals.level, status: goals.status, | |
| }).from(goals).where(inArray(goals.id, goalIds)); | |
| for (const r of rows) goalMap.set(r.id, r); | |
| } | |
| return raw.map(a => ({ | |
| ...a, | |
| project: a.projectId ? projectMap.get(a.projectId) ?? null : null, | |
| goal: a.goalId ? goalMap.get(a.goalId) ?? null : null, | |
| })); | |
| }, | |
| }; | |
| } | |