Files
paperclip/server/src/services/issues.ts
Dotta a393db78b4 fix: address greptile follow-up
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 14:53:30 -05:00

1519 lines
52 KiB
TypeScript

import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
assets,
companies,
companyMemberships,
goals,
heartbeatRuns,
issueAttachments,
issueLabels,
issueComments,
issueReadStates,
issues,
labels,
projectWorkspaces,
projects,
} from "@paperclipai/db";
import { extractProjectMentionIds } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.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;
assigneeUserId?: string;
touchedByUserId?: string;
unreadForUserId?: string;
projectId?: string;
parentId?: string;
labelId?: string;
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 IssueUserContextInput = {
createdByUserId: string | null;
assigneeUserId: string | null;
createdAt: Date | string;
updatedAt: Date | string;
};
function redactIssueComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
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, "\\$&");
}
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 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 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}
)
)
`;
}
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,
};
}
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) {
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 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 unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
const contextUserId = unreadForUserId ?? touchedByUserId;
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?.assigneeUserId) {
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
}
if (touchedByUserId) {
conditions.push(touchedByUserCondition(companyId, touchedByUserId));
}
if (unreadForUserId) {
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
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,
)!,
);
}
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 rows = await db
.select()
.from(issues)
.where(and(...conditions))
.orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
const withLabels = await withIssueLabels(db, rows);
const runMap = await activeRunMapForIssues(db, withLabels);
const withRuns = withActiveRuns(withLabels, runMap);
if (!contextUserId || withRuns.length === 0) {
return withRuns;
}
const issueIds = withRuns.map((row) => row.id);
const statsRows = await 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);
const readRows = await db
.select({
issueId: issueReadStates.issueId,
myLastReadAt: issueReadStates.lastReadAt,
})
.from(issueReadStates)
.where(
and(
eq(issueReadStates.companyId, companyId),
eq(issueReadStates.userId, contextUserId),
inArray(issueReadStates.issueId, issueIds),
),
);
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
return withRuns.map((row) => ({
...row,
...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),
];
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;
},
getById: async (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;
},
getByIdentifier: async (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;
},
create: async (
companyId: string,
data: Omit<typeof issues.$inferInsert, "companyId"> & { labelIds?: string[] },
) => {
const { labelIds: inputLabelIds, ...issueData } = data;
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);
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
if (executionWorkspaceSettings == 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(
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
) as Record<string, unknown> | null;
}
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,
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
goalId: issueData.goalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
}),
...(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;
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);
}
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);
patch.goalId = resolveNextIssueGoalId({
currentProjectId: existing.projectId,
currentGoalId: existing.goalId,
projectId: issueData.projectId,
goalId: issueData.goalId,
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 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) 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;
return comments.map(redactIssueComment);
},
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) =>
db
.select()
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows) => {
const comment = rows[0] ?? null;
return comment ? redactIssueComment(comment) : null;
}),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
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 redactedBody = redactCurrentUserText(body);
const [comment] = await db
.insert(issueComments)
.values({
companyId: issue.companyId,
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? 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);
},
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) tokens.add(m[1].toLowerCase());
if (tokens.size === 0) return [];
const rows = await db.select({ id: agents.id, name: agents.name })
.from(agents).where(eq(agents.companyId, companyId));
return rows.filter(a => tokens.has(a.name.toLowerCase())).map(a => a.id);
},
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,
}));
},
};
}