mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
* public-gh/master: (46 commits) chore(lockfile): refresh pnpm-lock.yaml (#1377) fix: manage codex home per company by default Ensure agent home directories exist before use Handle directory entries in imported zip archives Fix portability import and org chart test blockers Fix PR verify failures after merge fix: address greptile follow-up feedback Address remaining Greptile portability feedback docs: clarify quickstart npx usage Add guarded dev restart handling Fix PAP-576 settings toggles and transcript default Add username log censor setting fix: use standard toggle component for permission controls fix: add missing setPrincipalPermission mock in portability tests fix: use fixed 1280x640 dimensions for org chart export image Adjust default CEO onboarding task copy fix: link Agent Company to agentcompanies.io in export README fix: strip agents and projects sections from COMPANY.md export body fix: default company export page to README.md instead of first file Add default agent instructions bundle ... # Conflicts: # packages/adapters/pi-local/src/server/execute.ts # packages/db/src/migrations/meta/0039_snapshot.json # packages/db/src/migrations/meta/_journal.json # server/src/__tests__/agent-permissions-routes.test.ts # server/src/__tests__/agent-skills-routes.test.ts # server/src/services/company-portability.ts # skills/paperclip/references/company-skills.md # ui/src/api/agents.ts
1637 lines
58 KiB
TypeScript
1637 lines
58 KiB
TypeScript
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
assets,
|
|
companies,
|
|
companyMemberships,
|
|
documents,
|
|
goals,
|
|
heartbeatRuns,
|
|
executionWorkspaces,
|
|
issueAttachments,
|
|
issueLabels,
|
|
issueComments,
|
|
issueDocuments,
|
|
issueReadStates,
|
|
issues,
|
|
labels,
|
|
projectWorkspaces,
|
|
projects,
|
|
} from "@paperclipai/db";
|
|
import { extractProjectMentionIds } from "@paperclipai/shared";
|
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
|
import {
|
|
defaultIssueExecutionWorkspaceSettingsForProject,
|
|
gateProjectExecutionWorkspacePolicy,
|
|
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;
|
|
assigneeUserId?: string;
|
|
touchedByUserId?: string;
|
|
unreadForUserId?: string;
|
|
projectId?: 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 IssueUserContextInput = {
|
|
createdByUserId: string | null;
|
|
assigneeUserId: string | null;
|
|
createdAt: Date | string;
|
|
updatedAt: Date | string;
|
|
};
|
|
|
|
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) {
|
|
const instanceSettings = instanceSettingsService(db);
|
|
|
|
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) {
|
|
const workspace = await db
|
|
.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) {
|
|
const workspace = await db
|
|
.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 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?.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 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),
|
|
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;
|
|
},
|
|
|
|
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;
|
|
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.projectWorkspaceId) {
|
|
await assertValidProjectWorkspace(companyId, data.projectId, data.projectWorkspaceId);
|
|
}
|
|
if (data.executionWorkspaceId) {
|
|
await assertValidExecutionWorkspace(companyId, data.projectId, data.executionWorkspaceId);
|
|
}
|
|
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(
|
|
gateProjectExecutionWorkspacePolicy(
|
|
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
|
|
isolatedWorkspacesEnabled,
|
|
),
|
|
) as Record<string, unknown> | null;
|
|
}
|
|
let projectWorkspaceId = issueData.projectWorkspaceId ?? 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);
|
|
}
|
|
}
|
|
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,
|
|
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
|
}),
|
|
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
|
...(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);
|
|
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 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 }) => {
|
|
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,
|
|
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) 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,
|
|
}));
|
|
},
|
|
};
|
|
}
|