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:
Dotta
2026-04-21 10:02:52 -05:00
committed by GitHub
parent 1954eb3048
commit ab9051b595
49 changed files with 16100 additions and 28 deletions
+1
View File
@@ -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";
+407
View File
@@ -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,
};
}