forked from farhoodlabs/paperclip
Add first-class issue references (#4214)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Operators and agents coordinate through company-scoped issues, comments, documents, and task relationships. > - Issue text can mention other tickets, but those references were previously plain markdown/text without durable relationship data. > - That made it harder to understand related work, surface backlinks, and keep cross-ticket context visible in the board. > - This pull request adds first-class issue reference extraction, storage, API responses, and UI surfaces. > - The benefit is that issue references become queryable, navigable, and visible without relying on ad hoc text scanning. ## What Changed - Added shared issue-reference parsing utilities and exported reference-related types/constants. - Added an `issue_reference_mentions` table, idempotent migration DDL, schema exports, and database documentation. - Added server-side issue reference services, route integration, activity summaries, and a backfill command for existing issue content. - Added UI reference pills, related-work panels, markdown/editor mention handling, and issue detail/property rendering updates. - Added focused shared, server, and UI tests for parsing, persistence, display, and related-work behavior. - Rebased `PAP-735-first-class-task-references` cleanly onto `public-gh/master`; no `pnpm-lock.yaml` changes are included. ## Verification - `pnpm -r typecheck` - `pnpm test:run packages/shared/src/issue-references.test.ts server/src/__tests__/issue-references-service.test.ts ui/src/components/IssueRelatedWorkPanel.test.tsx ui/src/components/IssueProperties.test.tsx ui/src/components/MarkdownBody.test.tsx` ## Risks - Medium risk because this adds a new issue-reference persistence path that touches shared parsing, database schema, server routes, and UI rendering. - Migration risk is mitigated by `CREATE TABLE IF NOT EXISTS`, guarded foreign-key creation, and `CREATE INDEX IF NOT EXISTS` statements so users who have applied an older local version of the numbered migration can re-run safely. - UI risk is limited by focused component coverage, but reviewers should still manually inspect issue detail pages containing ticket references before merge. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent, tool-using shell workflow with repository inspection, git rebase/push, typecheck, and focused Vitest verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: dotta <dotta@example.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -49,6 +49,19 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -92,6 +92,19 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(async () => [companyId]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -56,6 +56,19 @@ function registerRouteMocks() {
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -74,6 +74,19 @@ function registerServiceMocks() {
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
|
||||
@@ -52,6 +52,19 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -76,6 +76,19 @@ vi.mock("../services/index.js", () => ({
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
@@ -103,6 +116,19 @@ function registerModuleMocks() {
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -45,6 +45,19 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({
|
||||
|
||||
@@ -42,6 +42,19 @@ vi.mock("../services/index.js", () => ({
|
||||
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
@@ -68,6 +81,19 @@ function registerModuleMocks() {
|
||||
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -50,6 +50,19 @@ function registerModuleMocks() {
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -70,6 +70,19 @@ function registerModuleMocks() {
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
createDb,
|
||||
documents,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issueReferenceMentions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { issueReferenceService } from "../services/issue-references.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
async function ensureIssueReferenceMentionsTable(db: ReturnType<typeof createDb>) {
|
||||
await db.execute(sql.raw(`
|
||||
CREATE TABLE IF NOT EXISTS "issue_reference_mentions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"company_id" uuid NOT NULL,
|
||||
"source_issue_id" uuid NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE,
|
||||
"target_issue_id" uuid NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE,
|
||||
"source_kind" text NOT NULL,
|
||||
"source_record_id" uuid,
|
||||
"document_key" text,
|
||||
"matched_text" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_issue_idx"
|
||||
ON "issue_reference_mentions" ("company_id", "source_issue_id");
|
||||
CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_target_issue_idx"
|
||||
ON "issue_reference_mentions" ("company_id", "target_issue_id");
|
||||
CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_issue_pair_idx"
|
||||
ON "issue_reference_mentions" ("company_id", "source_issue_id", "target_issue_id");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_mention_uq"
|
||||
ON "issue_reference_mentions" ("company_id", "source_issue_id", "target_issue_id", "source_kind", "source_record_id");
|
||||
`));
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres issue reference tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("issueReferenceService", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let refs!: ReturnType<typeof issueReferenceService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-refs-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
refs = issueReferenceService(db);
|
||||
await ensureIssueReferenceMentionsTable(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueReferenceMentions);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documents);
|
||||
await db.delete(issues);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("tracks outbound and inbound references across issue fields, comments, and documents", async () => {
|
||||
const companyId = randomUUID();
|
||||
const sourceIssueId = randomUUID();
|
||||
const targetTwoId = randomUUID();
|
||||
const targetThreeId = randomUUID();
|
||||
const inboundIssueId = randomUUID();
|
||||
const commentId = randomUUID();
|
||||
const documentId = randomUUID();
|
||||
const issueDocumentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: sourceIssueId,
|
||||
companyId,
|
||||
title: "Coordinate PAP-2",
|
||||
description: "Review /issues/pap-3 and ignore PAP-1 self references.",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
identifier: "PAP-1",
|
||||
},
|
||||
{
|
||||
id: targetTwoId,
|
||||
companyId,
|
||||
title: "Target two",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
identifier: "PAP-2",
|
||||
},
|
||||
{
|
||||
id: targetThreeId,
|
||||
companyId,
|
||||
title: "Target three",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
identifier: "PAP-3",
|
||||
},
|
||||
{
|
||||
id: inboundIssueId,
|
||||
companyId,
|
||||
title: "Inbound reference",
|
||||
description: "This one depends on PAP-1.",
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
identifier: "PAP-4",
|
||||
},
|
||||
]);
|
||||
|
||||
await refs.syncIssue(sourceIssueId);
|
||||
await refs.syncIssue(inboundIssueId);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId,
|
||||
companyId,
|
||||
issueId: sourceIssueId,
|
||||
body: "Follow up in https://paperclip.test/issues/pap-2 after the document lands.",
|
||||
});
|
||||
await refs.syncComment(commentId);
|
||||
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
latestBody: "Spec note: /PAP/issues/PAP-3",
|
||||
latestRevisionNumber: 1,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
id: issueDocumentId,
|
||||
companyId,
|
||||
issueId: sourceIssueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
});
|
||||
await refs.syncDocument(documentId);
|
||||
|
||||
const summary = await refs.listIssueReferenceSummary(sourceIssueId);
|
||||
|
||||
expect(summary.outbound.map((item) => item.issue.identifier)).toEqual(["PAP-2", "PAP-3"]);
|
||||
expect(summary.outbound[0]?.mentionCount).toBe(2);
|
||||
expect(summary.outbound[0]?.sources.map((source) => source.label)).toEqual(["title", "comment"]);
|
||||
expect(summary.outbound[1]?.mentionCount).toBe(2);
|
||||
expect(summary.outbound[1]?.sources.map((source) => source.label)).toEqual(["description", "plan"]);
|
||||
expect(summary.inbound.map((item) => item.issue.identifier)).toEqual(["PAP-4"]);
|
||||
|
||||
await refs.deleteDocumentSource(documentId);
|
||||
|
||||
const withoutDocument = await refs.listIssueReferenceSummary(sourceIssueId);
|
||||
const pap3 = withoutDocument.outbound.find((item) => item.issue.identifier === "PAP-3");
|
||||
|
||||
expect(pap3?.mentionCount).toBe(1);
|
||||
expect(pap3?.sources.map((source) => source.label)).toEqual(["description"]);
|
||||
});
|
||||
|
||||
it("backfills existing references for a company without requiring write-time sync", async () => {
|
||||
const companyId = randomUUID();
|
||||
const sourceIssueId = randomUUID();
|
||||
const targetIssueId = randomUUID();
|
||||
const commentId = randomUUID();
|
||||
const documentId = randomUUID();
|
||||
const issueDocumentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip Backfill",
|
||||
issuePrefix: `B${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: sourceIssueId,
|
||||
companyId,
|
||||
title: "Legacy issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
identifier: "PAP-10",
|
||||
},
|
||||
{
|
||||
id: targetIssueId,
|
||||
companyId,
|
||||
title: "Referenced legacy issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
identifier: "PAP-20",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId,
|
||||
companyId,
|
||||
issueId: sourceIssueId,
|
||||
body: "Legacy comment points at PAP-20.",
|
||||
});
|
||||
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
title: "Legacy plan",
|
||||
format: "markdown",
|
||||
latestBody: "Legacy plan also links /issues/PAP-20.",
|
||||
latestRevisionNumber: 1,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
id: issueDocumentId,
|
||||
companyId,
|
||||
issueId: sourceIssueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
});
|
||||
|
||||
await refs.syncAllForCompany(companyId);
|
||||
|
||||
const summary = await refs.listIssueReferenceSummary(sourceIssueId);
|
||||
|
||||
expect(summary.outbound).toHaveLength(1);
|
||||
expect(summary.outbound[0]?.issue.identifier).toBe("PAP-20");
|
||||
expect(summary.outbound[0]?.mentionCount).toBe(2);
|
||||
expect(summary.outbound[0]?.sources.map((source) => source.label)).toEqual(["plan", "comment"]);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,19 @@ function registerModuleMocks() {
|
||||
}),
|
||||
instanceSettingsService: () => ({}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -53,6 +53,19 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
@@ -94,6 +107,19 @@ function registerModuleMocks() {
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -52,6 +52,19 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -59,6 +59,19 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => mockProjectService,
|
||||
|
||||
+134
-3
@@ -42,6 +42,7 @@ import {
|
||||
issueApprovalService,
|
||||
ISSUE_LIST_DEFAULT_LIMIT,
|
||||
ISSUE_LIST_MAX_LIMIT,
|
||||
issueReferenceService,
|
||||
issueService,
|
||||
clampIssueListLimit,
|
||||
documentService,
|
||||
@@ -133,6 +134,23 @@ function summarizeIssueRelationForActivity(relation: {
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeIssueReferenceActivityDetails(input:
|
||||
| {
|
||||
addedReferencedIssues: ActivityIssueRelationSummary[];
|
||||
removedReferencedIssues: ActivityIssueRelationSummary[];
|
||||
currentReferencedIssues: ActivityIssueRelationSummary[];
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
if (!input) return {};
|
||||
return {
|
||||
...(input.addedReferencedIssues.length > 0 ? { addedReferencedIssues: input.addedReferencedIssues } : {}),
|
||||
...(input.removedReferencedIssues.length > 0 ? { removedReferencedIssues: input.removedReferencedIssues } : {}),
|
||||
...(input.currentReferencedIssues.length > 0 ? { currentReferencedIssues: input.currentReferencedIssues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string {
|
||||
return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
|
||||
}
|
||||
@@ -314,6 +332,7 @@ export function issueRoutes(
|
||||
const executionWorkspacesSvc = executionWorkspaceService(db);
|
||||
const workProductsSvc = workProductService(db);
|
||||
const documentsSvc = documentService(db);
|
||||
const issueReferencesSvc = issueReferenceService(db);
|
||||
const routinesSvc = routineService(db);
|
||||
const feedbackExportService = opts?.feedbackExportService;
|
||||
const upload = multer({
|
||||
@@ -871,12 +890,13 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations] = await Promise.all([
|
||||
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, referenceSummary] = await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
svc.getAncestors(issue.id),
|
||||
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
|
||||
documentsSvc.getIssueDocumentPayload(issue),
|
||||
svc.getRelationSummaries(issue.id),
|
||||
issueReferencesSvc.listIssueReferenceSummary(issue.id),
|
||||
]);
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
@@ -891,6 +911,8 @@ export function issueRoutes(
|
||||
ancestors,
|
||||
blockedBy: relations.blockedBy,
|
||||
blocks: relations.blocks,
|
||||
relatedWork: referenceSummary,
|
||||
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||
...documentPayload,
|
||||
project: project ?? null,
|
||||
goal: goal ?? null,
|
||||
@@ -963,6 +985,7 @@ export function issueRoutes(
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const result = await documentsSvc.upsertIssueDocument({
|
||||
issueId: issue.id,
|
||||
key: keyParsed.data,
|
||||
@@ -976,6 +999,9 @@ export function issueRoutes(
|
||||
createdByRunId: actor.runId ?? null,
|
||||
});
|
||||
const doc = result.document;
|
||||
await issueReferencesSvc.syncDocument(doc.id);
|
||||
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
@@ -992,6 +1018,11 @@ export function issueRoutes(
|
||||
title: doc.title,
|
||||
format: doc.format,
|
||||
revisionNumber: doc.latestRevisionNumber,
|
||||
...summarizeIssueReferenceActivityDetails({
|
||||
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1035,6 +1066,7 @@ export function issueRoutes(
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const result = await documentsSvc.restoreIssueDocumentRevision({
|
||||
issueId: issue.id,
|
||||
key: keyParsed.data,
|
||||
@@ -1042,6 +1074,9 @@ export function issueRoutes(
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
await issueReferencesSvc.syncDocument(result.document.id);
|
||||
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
@@ -1060,6 +1095,11 @@ export function issueRoutes(
|
||||
revisionNumber: result.document.latestRevisionNumber,
|
||||
restoredFromRevisionId: result.restoredFromRevisionId,
|
||||
restoredFromRevisionNumber: result.restoredFromRevisionNumber,
|
||||
...summarizeIssueReferenceActivityDetails({
|
||||
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1084,11 +1124,15 @@ export function issueRoutes(
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
return;
|
||||
}
|
||||
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Document not found" });
|
||||
return;
|
||||
}
|
||||
await issueReferencesSvc.deleteDocumentSource(removed.id);
|
||||
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
@@ -1103,6 +1147,11 @@ export function issueRoutes(
|
||||
key: removed.key,
|
||||
documentId: removed.id,
|
||||
title: removed.title,
|
||||
...summarizeIssueReferenceActivityDetails({
|
||||
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
}),
|
||||
},
|
||||
});
|
||||
res.json({ ok: true });
|
||||
@@ -1427,6 +1476,12 @@ export function issueRoutes(
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
await issueReferencesSvc.syncIssue(issue.id);
|
||||
const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
||||
issueReferencesSvc.emptySummary(),
|
||||
referenceSummary,
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
@@ -1441,6 +1496,11 @@ export function issueRoutes(
|
||||
title: issue.title,
|
||||
identifier: issue.identifier,
|
||||
...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}),
|
||||
...summarizeIssueReferenceActivityDetails({
|
||||
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1454,7 +1514,11 @@ export function issueRoutes(
|
||||
requestedByActorId: actor.actorId,
|
||||
});
|
||||
|
||||
res.status(201).json(issue);
|
||||
res.status(201).json({
|
||||
...issue,
|
||||
relatedWork: referenceSummary,
|
||||
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/issues/:id/children", validate(createChildIssueSchema), async (req, res) => {
|
||||
@@ -1530,6 +1594,7 @@ export function issueRoutes(
|
||||
existing.companyId,
|
||||
req.body.assigneeAgentId as string | null | undefined,
|
||||
);
|
||||
const titleOrDescriptionChanged = req.body.title !== undefined || req.body.description !== undefined;
|
||||
const existingRelations =
|
||||
Array.isArray(req.body.blockedByIssueIds)
|
||||
? await svc.getRelationSummaries(existing.id)
|
||||
@@ -1552,6 +1617,9 @@ export function issueRoutes(
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
}));
|
||||
const updateReferenceSummaryBefore = titleOrDescriptionChanged
|
||||
? await issueReferencesSvc.listIssueReferenceSummary(existing.id)
|
||||
: null;
|
||||
let interruptedRunId: string | null = null;
|
||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
||||
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
|
||||
@@ -1723,7 +1791,21 @@ export function issueRoutes(
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown } = issue;
|
||||
if (titleOrDescriptionChanged) {
|
||||
await issueReferencesSvc.syncIssue(issue.id);
|
||||
}
|
||||
const updateReferenceSummaryAfter = titleOrDescriptionChanged
|
||||
? await issueReferencesSvc.listIssueReferenceSummary(issue.id)
|
||||
: null;
|
||||
const updateReferenceDiff = updateReferenceSummaryBefore && updateReferenceSummaryAfter
|
||||
? issueReferencesSvc.diffIssueReferenceSummary(updateReferenceSummaryBefore, updateReferenceSummaryAfter)
|
||||
: null;
|
||||
let issueResponse: typeof issue & {
|
||||
blockedBy?: unknown;
|
||||
blocks?: unknown;
|
||||
relatedWork?: Awaited<ReturnType<typeof issueReferencesSvc.listIssueReferenceSummary>>;
|
||||
referencedIssueIdentifiers?: string[];
|
||||
} = issue;
|
||||
let updatedRelations: Awaited<ReturnType<typeof svc.getRelationSummaries>> | null = null;
|
||||
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
|
||||
updatedRelations = await svc.getRelationSummaries(issue.id);
|
||||
@@ -1775,6 +1857,15 @@ export function issueRoutes(
|
||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
_previous: hasFieldChanges ? previous : undefined,
|
||||
...summarizeIssueReferenceActivityDetails(
|
||||
updateReferenceDiff
|
||||
? {
|
||||
addedReferencedIssues: updateReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
removedReferencedIssues: updateReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
currentReferencedIssues: updateReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
}
|
||||
: null,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1870,11 +1961,26 @@ export function issueRoutes(
|
||||
|
||||
let comment = null;
|
||||
if (commentBody) {
|
||||
const commentReferenceSummaryBefore = updateReferenceSummaryAfter
|
||||
?? await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
comment = await svc.addComment(id, commentBody, {
|
||||
agentId: actor.agentId ?? undefined,
|
||||
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
||||
runId: actor.runId,
|
||||
});
|
||||
await issueReferencesSvc.syncComment(comment.id);
|
||||
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
||||
commentReferenceSummaryBefore,
|
||||
commentReferenceSummaryAfter,
|
||||
);
|
||||
issueResponse = {
|
||||
...issueResponse,
|
||||
relatedWork: commentReferenceSummaryAfter,
|
||||
referencedIssueIdentifiers: commentReferenceSummaryAfter.outbound.map(
|
||||
(item) => item.issue.identifier ?? item.issue.id,
|
||||
),
|
||||
};
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
@@ -1893,9 +1999,22 @@ export function issueRoutes(
|
||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
...(hasFieldChanges ? { updated: true } : {}),
|
||||
...summarizeIssueReferenceActivityDetails({
|
||||
addedReferencedIssues: commentReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
removedReferencedIssues: commentReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
currentReferencedIssues: commentReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
} else if (updateReferenceSummaryAfter) {
|
||||
issueResponse = {
|
||||
...issueResponse,
|
||||
relatedWork: updateReferenceSummaryAfter,
|
||||
referencedIssueIdentifiers: updateReferenceSummaryAfter.outbound.map(
|
||||
(item) => item.issue.identifier ?? item.issue.id,
|
||||
),
|
||||
};
|
||||
}
|
||||
const assigneeChanged =
|
||||
issue.assigneeAgentId !== existing.assigneeAgentId || issue.assigneeUserId !== existing.assigneeUserId;
|
||||
@@ -2489,6 +2608,7 @@ export function issueRoutes(
|
||||
let reopenFromStatus: string | null = null;
|
||||
let interruptedRunId: string | null = null;
|
||||
let currentIssue = issue;
|
||||
const commentReferenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
|
||||
if (effectiveReopenRequested && isClosed) {
|
||||
const reopenedIssue = await svc.update(id, { status: "todo" });
|
||||
@@ -2550,6 +2670,12 @@ export function issueRoutes(
|
||||
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
||||
runId: actor.runId,
|
||||
});
|
||||
await issueReferencesSvc.syncComment(comment.id);
|
||||
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
|
||||
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
||||
commentReferenceSummaryBefore,
|
||||
commentReferenceSummaryAfter,
|
||||
);
|
||||
|
||||
if (actor.runId) {
|
||||
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
|
||||
@@ -2572,6 +2698,11 @@ export function issueRoutes(
|
||||
issueTitle: currentIssue.title,
|
||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
...summarizeIssueReferenceActivityDetails({
|
||||
addedReferencedIssues: commentReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
removedReferencedIssues: commentReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
currentReferencedIssues: commentReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
type IssueFilters,
|
||||
} from "./issues.js";
|
||||
export { issueApprovalService } from "./issue-approvals.js";
|
||||
export { issueReferenceService } from "./issue-references.js";
|
||||
export { goalService } from "./goals.js";
|
||||
export { activityService, type ActivityFilters } from "./activity.js";
|
||||
export { approvalService } from "./approvals.js";
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { and, asc, eq, inArray, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { documents, issueComments, issueDocuments, issueReferenceMentions, issues } from "@paperclipai/db";
|
||||
import type {
|
||||
IssueReferenceSource,
|
||||
IssueReferenceSourceKind,
|
||||
IssueRelatedWorkItem,
|
||||
IssueRelatedWorkSummary,
|
||||
IssueRelationIssueSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { extractIssueReferenceMatches } from "@paperclipai/shared";
|
||||
import { notFound } from "../errors.js";
|
||||
|
||||
const SOURCE_KIND_ORDER: Record<IssueReferenceSourceKind, number> = {
|
||||
title: 0,
|
||||
description: 1,
|
||||
document: 2,
|
||||
comment: 3,
|
||||
};
|
||||
|
||||
function sourceLabel(kind: IssueReferenceSourceKind, documentKey: string | null): string {
|
||||
if (kind === "document") return documentKey?.trim() || "document";
|
||||
return kind;
|
||||
}
|
||||
|
||||
function sourceWhere(
|
||||
input: {
|
||||
companyId?: string;
|
||||
sourceIssueId?: string;
|
||||
sourceKind: IssueReferenceSourceKind;
|
||||
sourceRecordId?: string | null;
|
||||
},
|
||||
) {
|
||||
const conditions = [eq(issueReferenceMentions.sourceKind, input.sourceKind)];
|
||||
if (input.companyId) conditions.push(eq(issueReferenceMentions.companyId, input.companyId));
|
||||
if (input.sourceIssueId) conditions.push(eq(issueReferenceMentions.sourceIssueId, input.sourceIssueId));
|
||||
if (input.sourceRecordId) {
|
||||
conditions.push(eq(issueReferenceMentions.sourceRecordId, input.sourceRecordId));
|
||||
} else {
|
||||
conditions.push(isNull(issueReferenceMentions.sourceRecordId));
|
||||
}
|
||||
return and(...conditions);
|
||||
}
|
||||
|
||||
function toIssueSummary(row: {
|
||||
relatedIssueId: string;
|
||||
relatedIssueIdentifier: string | null;
|
||||
relatedIssueTitle: string;
|
||||
relatedIssueStatus: IssueRelationIssueSummary["status"];
|
||||
relatedIssuePriority: IssueRelationIssueSummary["priority"];
|
||||
relatedIssueAssigneeAgentId: string | null;
|
||||
relatedIssueAssigneeUserId: string | null;
|
||||
}): IssueRelationIssueSummary {
|
||||
return {
|
||||
id: row.relatedIssueId,
|
||||
identifier: row.relatedIssueIdentifier,
|
||||
title: row.relatedIssueTitle,
|
||||
status: row.relatedIssueStatus,
|
||||
priority: row.relatedIssuePriority,
|
||||
assigneeAgentId: row.relatedIssueAssigneeAgentId,
|
||||
assigneeUserId: row.relatedIssueAssigneeUserId,
|
||||
};
|
||||
}
|
||||
|
||||
function sortSources(a: IssueReferenceSource, b: IssueReferenceSource) {
|
||||
const orderDelta = SOURCE_KIND_ORDER[a.kind] - SOURCE_KIND_ORDER[b.kind];
|
||||
if (orderDelta !== 0) return orderDelta;
|
||||
const labelDelta = a.label.localeCompare(b.label);
|
||||
if (labelDelta !== 0) return labelDelta;
|
||||
return (a.sourceRecordId ?? "").localeCompare(b.sourceRecordId ?? "");
|
||||
}
|
||||
|
||||
function sortRelatedWork(a: IssueRelatedWorkItem, b: IssueRelatedWorkItem) {
|
||||
if (b.mentionCount !== a.mentionCount) return b.mentionCount - a.mentionCount;
|
||||
const leftLabel = a.issue.identifier ?? a.issue.title;
|
||||
const rightLabel = b.issue.identifier ?? b.issue.title;
|
||||
return leftLabel.localeCompare(rightLabel);
|
||||
}
|
||||
|
||||
function emptySummary(): IssueRelatedWorkSummary {
|
||||
return {
|
||||
outbound: [],
|
||||
inbound: [],
|
||||
};
|
||||
}
|
||||
|
||||
function diffIssueSummaries(
|
||||
before: IssueRelatedWorkSummary,
|
||||
after: IssueRelatedWorkSummary,
|
||||
): {
|
||||
addedReferencedIssues: IssueRelationIssueSummary[];
|
||||
removedReferencedIssues: IssueRelationIssueSummary[];
|
||||
currentReferencedIssues: IssueRelationIssueSummary[];
|
||||
} {
|
||||
const beforeById = new Map(before.outbound.map((item) => [item.issue.id, item.issue]));
|
||||
const afterById = new Map(after.outbound.map((item) => [item.issue.id, item.issue]));
|
||||
|
||||
return {
|
||||
addedReferencedIssues: after.outbound
|
||||
.map((item) => item.issue)
|
||||
.filter((issue) => !beforeById.has(issue.id)),
|
||||
removedReferencedIssues: before.outbound
|
||||
.map((item) => item.issue)
|
||||
.filter((issue) => !afterById.has(issue.id)),
|
||||
currentReferencedIssues: after.outbound.map((item) => item.issue),
|
||||
};
|
||||
}
|
||||
|
||||
export function issueReferenceService(db: Db) {
|
||||
async function replaceSourceMentions(
|
||||
input: {
|
||||
companyId: string;
|
||||
sourceIssueId: string;
|
||||
sourceKind: IssueReferenceSourceKind;
|
||||
sourceRecordId: string | null;
|
||||
documentKey: string | null;
|
||||
text: string | null | undefined;
|
||||
},
|
||||
dbOrTx: any = db,
|
||||
) {
|
||||
const matches = extractIssueReferenceMatches(input.text ?? "");
|
||||
const identifiers = matches.map((match) => match.identifier);
|
||||
type ResolvedTargetRow = {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
};
|
||||
|
||||
const resolvedTargets: ResolvedTargetRow[] = identifiers.length > 0
|
||||
? await dbOrTx
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, input.companyId), inArray(issues.identifier, identifiers)))
|
||||
: [];
|
||||
const targetByIdentifier = new Map<string, string>(
|
||||
resolvedTargets
|
||||
.filter((row): row is ResolvedTargetRow & { identifier: string } => typeof row.identifier === "string")
|
||||
.map((row) => [row.identifier, row.id]),
|
||||
);
|
||||
|
||||
await dbOrTx.delete(issueReferenceMentions).where(sourceWhere(input));
|
||||
|
||||
if (matches.length === 0) return;
|
||||
|
||||
const seenTargetIds = new Set<string>();
|
||||
const values = matches.flatMap((match) => {
|
||||
const targetIssueId = targetByIdentifier.get(match.identifier);
|
||||
if (!targetIssueId || targetIssueId === input.sourceIssueId || seenTargetIds.has(targetIssueId)) {
|
||||
return [];
|
||||
}
|
||||
seenTargetIds.add(targetIssueId);
|
||||
return [{
|
||||
companyId: input.companyId,
|
||||
sourceIssueId: input.sourceIssueId,
|
||||
targetIssueId,
|
||||
sourceKind: input.sourceKind,
|
||||
sourceRecordId: input.sourceRecordId,
|
||||
documentKey: input.documentKey,
|
||||
matchedText: match.matchedText,
|
||||
}];
|
||||
});
|
||||
|
||||
if (values.length > 0) {
|
||||
await dbOrTx.insert(issueReferenceMentions).values(values);
|
||||
}
|
||||
}
|
||||
|
||||
async function issueById(issueId: string, dbOrTx: any = db) {
|
||||
return dbOrTx
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
title: issues.title,
|
||||
description: issues.description,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows: Array<{ id: string; companyId: string; title: string; description: string | null }>) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function syncIssue(issueId: string, dbOrTx: any = db) {
|
||||
const runSync = async (tx: any) => {
|
||||
const issue = await issueById(issueId, tx);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
|
||||
await replaceSourceMentions({
|
||||
companyId: issue.companyId,
|
||||
sourceIssueId: issue.id,
|
||||
sourceKind: "title",
|
||||
sourceRecordId: null,
|
||||
documentKey: null,
|
||||
text: issue.title,
|
||||
}, tx);
|
||||
|
||||
await replaceSourceMentions({
|
||||
companyId: issue.companyId,
|
||||
sourceIssueId: issue.id,
|
||||
sourceKind: "description",
|
||||
sourceRecordId: null,
|
||||
documentKey: null,
|
||||
text: issue.description,
|
||||
}, tx);
|
||||
};
|
||||
|
||||
return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx);
|
||||
}
|
||||
|
||||
async function syncComment(commentId: string, dbOrTx: any = db) {
|
||||
const comment = await dbOrTx
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
companyId: issueComments.companyId,
|
||||
issueId: issueComments.issueId,
|
||||
body: issueComments.body,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.id, commentId))
|
||||
.then((rows: Array<{ id: string; companyId: string; issueId: string; body: string }>) => rows[0] ?? null);
|
||||
if (!comment) throw notFound("Issue comment not found");
|
||||
|
||||
await replaceSourceMentions({
|
||||
companyId: comment.companyId,
|
||||
sourceIssueId: comment.issueId,
|
||||
sourceKind: "comment",
|
||||
sourceRecordId: comment.id,
|
||||
documentKey: null,
|
||||
text: comment.body,
|
||||
}, dbOrTx);
|
||||
}
|
||||
|
||||
async function syncDocument(documentId: string, dbOrTx: any = db) {
|
||||
const document = await dbOrTx
|
||||
.select({
|
||||
documentId: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
body: documents.latestBody,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(eq(documents.id, documentId))
|
||||
.then((rows: Array<{ documentId: string; companyId: string; issueId: string; key: string; body: string }>) => rows[0] ?? null);
|
||||
|
||||
if (!document) {
|
||||
await dbOrTx
|
||||
.delete(issueReferenceMentions)
|
||||
.where(and(eq(issueReferenceMentions.sourceKind, "document"), eq(issueReferenceMentions.sourceRecordId, documentId)));
|
||||
return;
|
||||
}
|
||||
|
||||
await replaceSourceMentions({
|
||||
companyId: document.companyId,
|
||||
sourceIssueId: document.issueId,
|
||||
sourceKind: "document",
|
||||
sourceRecordId: document.documentId,
|
||||
documentKey: document.key,
|
||||
text: document.body,
|
||||
}, dbOrTx);
|
||||
}
|
||||
|
||||
async function deleteDocumentSource(documentId: string, dbOrTx: any = db) {
|
||||
await dbOrTx
|
||||
.delete(issueReferenceMentions)
|
||||
.where(and(eq(issueReferenceMentions.sourceKind, "document"), eq(issueReferenceMentions.sourceRecordId, documentId)));
|
||||
}
|
||||
|
||||
async function syncAllForIssue(issueId: string, dbOrTx: any = db) {
|
||||
const issue = await issueById(issueId, dbOrTx);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
|
||||
await syncIssue(issueId, dbOrTx);
|
||||
|
||||
const [comments, docs] = await Promise.all([
|
||||
dbOrTx
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, issueId)),
|
||||
dbOrTx
|
||||
.select({ id: documents.id })
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(eq(issueDocuments.issueId, issueId)),
|
||||
]);
|
||||
|
||||
for (const comment of comments) {
|
||||
await syncComment(comment.id, dbOrTx);
|
||||
}
|
||||
for (const doc of docs) {
|
||||
await syncDocument(doc.id, dbOrTx);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAllForCompany(companyId: string, dbOrTx: any = db) {
|
||||
const issueRows = await dbOrTx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId))
|
||||
.orderBy(asc(issues.createdAt), asc(issues.id));
|
||||
|
||||
for (const issue of issueRows) {
|
||||
await syncAllForIssue(issue.id, dbOrTx);
|
||||
}
|
||||
}
|
||||
|
||||
async function listIssueReferenceSummary(issueId: string, dbOrTx: any = db): Promise<IssueRelatedWorkSummary> {
|
||||
const issue = await issueById(issueId, dbOrTx);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
|
||||
const [outboundRows, inboundRows] = await Promise.all([
|
||||
dbOrTx
|
||||
.select({
|
||||
relatedIssueId: issues.id,
|
||||
relatedIssueIdentifier: issues.identifier,
|
||||
relatedIssueTitle: issues.title,
|
||||
relatedIssueStatus: issues.status,
|
||||
relatedIssuePriority: issues.priority,
|
||||
relatedIssueAssigneeAgentId: issues.assigneeAgentId,
|
||||
relatedIssueAssigneeUserId: issues.assigneeUserId,
|
||||
sourceKind: issueReferenceMentions.sourceKind,
|
||||
sourceRecordId: issueReferenceMentions.sourceRecordId,
|
||||
documentKey: issueReferenceMentions.documentKey,
|
||||
matchedText: issueReferenceMentions.matchedText,
|
||||
})
|
||||
.from(issueReferenceMentions)
|
||||
.innerJoin(issues, eq(issueReferenceMentions.targetIssueId, issues.id))
|
||||
.where(and(
|
||||
eq(issueReferenceMentions.companyId, issue.companyId),
|
||||
eq(issueReferenceMentions.sourceIssueId, issueId),
|
||||
)),
|
||||
dbOrTx
|
||||
.select({
|
||||
relatedIssueId: issues.id,
|
||||
relatedIssueIdentifier: issues.identifier,
|
||||
relatedIssueTitle: issues.title,
|
||||
relatedIssueStatus: issues.status,
|
||||
relatedIssuePriority: issues.priority,
|
||||
relatedIssueAssigneeAgentId: issues.assigneeAgentId,
|
||||
relatedIssueAssigneeUserId: issues.assigneeUserId,
|
||||
sourceKind: issueReferenceMentions.sourceKind,
|
||||
sourceRecordId: issueReferenceMentions.sourceRecordId,
|
||||
documentKey: issueReferenceMentions.documentKey,
|
||||
matchedText: issueReferenceMentions.matchedText,
|
||||
})
|
||||
.from(issueReferenceMentions)
|
||||
.innerJoin(issues, eq(issueReferenceMentions.sourceIssueId, issues.id))
|
||||
.where(and(
|
||||
eq(issueReferenceMentions.companyId, issue.companyId),
|
||||
eq(issueReferenceMentions.targetIssueId, issueId),
|
||||
)),
|
||||
]);
|
||||
|
||||
const mapRows = (rows: Array<{
|
||||
relatedIssueId: string;
|
||||
relatedIssueIdentifier: string | null;
|
||||
relatedIssueTitle: string;
|
||||
relatedIssueStatus: IssueRelationIssueSummary["status"];
|
||||
relatedIssuePriority: IssueRelationIssueSummary["priority"];
|
||||
relatedIssueAssigneeAgentId: string | null;
|
||||
relatedIssueAssigneeUserId: string | null;
|
||||
sourceKind: IssueReferenceSourceKind;
|
||||
sourceRecordId: string | null;
|
||||
documentKey: string | null;
|
||||
matchedText: string | null;
|
||||
}>) => {
|
||||
const grouped = new Map<string, IssueRelatedWorkItem>();
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.relatedIssueId) ?? {
|
||||
issue: toIssueSummary(row),
|
||||
mentionCount: 0,
|
||||
sources: [],
|
||||
};
|
||||
existing.mentionCount += 1;
|
||||
existing.sources.push({
|
||||
kind: row.sourceKind,
|
||||
sourceRecordId: row.sourceRecordId,
|
||||
label: sourceLabel(row.sourceKind, row.documentKey),
|
||||
matchedText: row.matchedText,
|
||||
});
|
||||
grouped.set(row.relatedIssueId, existing);
|
||||
}
|
||||
|
||||
return [...grouped.values()]
|
||||
.map((item) => ({ ...item, sources: [...item.sources].sort(sortSources) }))
|
||||
.sort(sortRelatedWork);
|
||||
};
|
||||
|
||||
return {
|
||||
outbound: mapRows(outboundRows),
|
||||
inbound: mapRows(inboundRows),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
syncIssue,
|
||||
syncComment,
|
||||
syncDocument,
|
||||
deleteDocumentSource,
|
||||
syncAllForIssue,
|
||||
syncAllForCompany,
|
||||
listIssueReferenceSummary,
|
||||
diffIssueReferenceSummary: diffIssueSummaries,
|
||||
emptySummary,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user