mirror of
https://github.com/paperclipai/paperclip
synced 2026-03-25 11:21:48 +00:00
Use issue participation for agent history
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
284
server/src/__tests__/issues-service.test.ts
Normal file
284
server/src/__tests__/issues-service.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
applyPendingMigrations,
|
||||
companies,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
return { connectionString, dataDir, instance };
|
||||
}
|
||||
|
||||
describe("issueService.list participantAgentId", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
let dataDir = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startTempDatabase();
|
||||
db = createDb(started.connectionString);
|
||||
svc = issueService(db);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns issues an agent participated in across the supported signals", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const otherAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: otherAgentId,
|
||||
companyId,
|
||||
name: "OtherAgent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const assignedIssueId = randomUUID();
|
||||
const createdIssueId = randomUUID();
|
||||
const commentedIssueId = randomUUID();
|
||||
const activityIssueId = randomUUID();
|
||||
const excludedIssueId = randomUUID();
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: assignedIssueId,
|
||||
companyId,
|
||||
title: "Assigned issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
createdByAgentId: otherAgentId,
|
||||
},
|
||||
{
|
||||
id: createdIssueId,
|
||||
companyId,
|
||||
title: "Created issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: commentedIssueId,
|
||||
companyId,
|
||||
title: "Commented issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: otherAgentId,
|
||||
},
|
||||
{
|
||||
id: activityIssueId,
|
||||
companyId,
|
||||
title: "Activity issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: otherAgentId,
|
||||
},
|
||||
{
|
||||
id: excludedIssueId,
|
||||
companyId,
|
||||
title: "Excluded issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: otherAgentId,
|
||||
assigneeAgentId: otherAgentId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: commentedIssueId,
|
||||
authorAgentId: agentId,
|
||||
body: "Investigating this issue.",
|
||||
});
|
||||
|
||||
await db.insert(activityLog).values({
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: agentId,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: activityIssueId,
|
||||
agentId,
|
||||
details: { changed: true },
|
||||
});
|
||||
|
||||
const result = await svc.list(companyId, { participantAgentId: agentId });
|
||||
const resultIds = new Set(result.map((issue) => issue.id));
|
||||
|
||||
expect(resultIds).toEqual(new Set([
|
||||
assignedIssueId,
|
||||
createdIssueId,
|
||||
commentedIssueId,
|
||||
activityIssueId,
|
||||
]));
|
||||
expect(resultIds.has(excludedIssueId)).toBe(false);
|
||||
});
|
||||
|
||||
it("combines participation filtering with search", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const matchedIssueId = randomUUID();
|
||||
const otherIssueId = randomUUID();
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: matchedIssueId,
|
||||
companyId,
|
||||
title: "Invoice reconciliation",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: otherIssueId,
|
||||
companyId,
|
||||
title: "Weekly planning",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: agentId,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
participantAgentId: agentId,
|
||||
q: "invoice",
|
||||
});
|
||||
|
||||
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
||||
});
|
||||
});
|
||||
@@ -233,6 +233,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const result = await svc.list(companyId, {
|
||||
status: req.query.status as string | undefined,
|
||||
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
||||
participantAgentId: req.query.participantAgentId as string | undefined,
|
||||
assigneeUserId,
|
||||
touchedByUserId,
|
||||
unreadForUserId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
assets,
|
||||
companies,
|
||||
@@ -62,6 +63,7 @@ function applyStatusSideEffects(
|
||||
export interface IssueFilters {
|
||||
status?: string;
|
||||
assigneeAgentId?: string;
|
||||
participantAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
touchedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
@@ -134,6 +136,30 @@ function touchedByUserCondition(companyId: string, userId: string) {
|
||||
`;
|
||||
}
|
||||
|
||||
function participatedByAgentCondition(companyId: string, agentId: string) {
|
||||
return sql<boolean>`
|
||||
(
|
||||
${issues.createdByAgentId} = ${agentId}
|
||||
OR ${issues.assigneeAgentId} = ${agentId}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.companyId} = ${companyId}
|
||||
AND ${issueComments.authorAgentId} = ${agentId}
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${activityLog}
|
||||
WHERE ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.entityType} = 'issue'
|
||||
AND ${activityLog.entityId} = ${issues.id}::text
|
||||
AND ${activityLog.agentId} = ${agentId}
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function myLastCommentAtExpr(companyId: string, userId: string) {
|
||||
return sql<Date | null>`
|
||||
(
|
||||
@@ -508,6 +534,9 @@ export function issueService(db: Db) {
|
||||
if (filters?.assigneeAgentId) {
|
||||
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
|
||||
}
|
||||
if (filters?.participantAgentId) {
|
||||
conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
|
||||
}
|
||||
if (filters?.assigneeUserId) {
|
||||
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const issuesApi = {
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
assigneeAgentId?: string;
|
||||
participantAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
touchedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
@@ -32,6 +33,7 @@ export const issuesApi = {
|
||||
if (filters?.status) params.set("status", filters.status);
|
||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
|
||||
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
|
||||
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
||||
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
|
||||
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
||||
|
||||
@@ -166,6 +166,9 @@ interface IssuesListProps {
|
||||
issueLinkState?: unknown;
|
||||
initialAssignees?: string[];
|
||||
initialSearch?: string;
|
||||
searchFilters?: {
|
||||
participantAgentId?: string;
|
||||
};
|
||||
onSearchChange?: (search: string) => void;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -182,6 +185,7 @@ export function IssuesList({
|
||||
issueLinkState,
|
||||
initialAssignees,
|
||||
initialSearch,
|
||||
searchFilters,
|
||||
onSearchChange,
|
||||
onUpdateIssue,
|
||||
}: IssuesListProps) {
|
||||
@@ -239,8 +243,11 @@ export function IssuesList({
|
||||
}, [scopedKey]);
|
||||
|
||||
const { data: searchedIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }),
|
||||
queryKey: [
|
||||
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
||||
searchFilters ?? {},
|
||||
],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
|
||||
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
||||
});
|
||||
|
||||
|
||||
@@ -572,9 +572,9 @@ export function AgentDetail() {
|
||||
});
|
||||
|
||||
const { data: allIssues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(resolvedCompanyId!),
|
||||
queryFn: () => issuesApi.list(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId && needsDashboardData,
|
||||
queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"],
|
||||
queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }),
|
||||
enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData,
|
||||
});
|
||||
|
||||
const { data: allAgents } = useQuery({
|
||||
@@ -592,7 +592,6 @@ export function AgentDetail() {
|
||||
});
|
||||
|
||||
const assignedIssues = (allIssues ?? [])
|
||||
.filter((i) => i.assigneeAgentId === agent?.id)
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
|
||||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
|
||||
@@ -1174,12 +1173,15 @@ function AgentOverview({
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Recent Issues</h3>
|
||||
<Link to={`/issues?assignee=${agentId}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Link
|
||||
to={`/issues?participantAgentId=${agentId}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
See All →
|
||||
</Link>
|
||||
</div>
|
||||
{assignedIssues.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No assigned issues.</p>
|
||||
<p className="text-sm text-muted-foreground">No recent issues.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg">
|
||||
{assignedIssues.slice(0, 10).map((issue) => (
|
||||
|
||||
@@ -21,6 +21,7 @@ export function Issues() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const initialSearch = searchParams.get("q") ?? "";
|
||||
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const handleSearchChange = useCallback((search: string) => {
|
||||
clearTimeout(debounceRef.current);
|
||||
@@ -86,8 +87,8 @@ export function Issues() {
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
@@ -117,6 +118,7 @@ export function Issues() {
|
||||
initialSearch={initialSearch}
|
||||
onSearchChange={handleSearchChange}
|
||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||
searchFilters={participantAgentId ? { participantAgentId } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user