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
+110
View File
@@ -0,0 +1,110 @@
import type { IssueRelatedWorkItem, IssueRelatedWorkSummary } from "@paperclipai/shared";
import { IssueReferencePill } from "./IssueReferencePill";
type GroupedSource = {
label: string;
count: number;
sampleMatchedText: string | null;
};
function groupSourcesByLabel(sources: IssueRelatedWorkItem["sources"]): GroupedSource[] {
const groups = new Map<string, GroupedSource>();
for (const source of sources) {
const existing = groups.get(source.label);
if (existing) {
existing.count += 1;
} else {
groups.set(source.label, {
label: source.label,
count: 1,
sampleMatchedText: source.matchedText ?? null,
});
}
}
return Array.from(groups.values());
}
function Section({
title,
description,
items,
emptyLabel,
}: {
title: string;
description: string;
items: IssueRelatedWorkItem[];
emptyLabel: string;
}) {
return (
<section className="space-y-3 rounded-lg border border-border p-3">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
{items.length === 0 ? (
<p className="text-xs text-muted-foreground">{emptyLabel}</p>
) : (
<ul className="-mx-1 flex flex-col">
{items.map((item) => {
const groupedSources = groupSourcesByLabel(item.sources);
const showTitle = item.issue.identifier !== item.issue.title;
return (
<li
key={item.issue.id}
className="flex flex-wrap items-center gap-x-2 gap-y-1.5 rounded-md px-1 py-1.5 hover:bg-accent/40"
>
<IssueReferencePill issue={item.issue} />
{showTitle ? (
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{item.issue.title}
</span>
) : null}
<div className="flex flex-wrap items-center gap-1.5">
{groupedSources.map((group) => (
<span
key={`${item.issue.id}:${group.label}`}
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-xs text-muted-foreground"
title={group.sampleMatchedText ?? undefined}
>
<span>{group.label}</span>
{group.count > 1 ? (
<span className="tabular-nums text-[10px] font-medium opacity-80">×{group.count}</span>
) : null}
</span>
))}
</div>
</li>
);
})}
</ul>
)}
</section>
);
}
export function IssueRelatedWorkPanel({
relatedWork,
}: {
relatedWork?: IssueRelatedWorkSummary | null;
}) {
const outbound = relatedWork?.outbound ?? [];
const inbound = relatedWork?.inbound ?? [];
return (
<div className="space-y-3">
<Section
title="References"
description="Other tasks this issue currently points at in its title, description, comments, or documents."
items={outbound}
emptyLabel="This issue does not reference any other tasks yet."
/>
<Section
title="Referenced by"
description="Other tasks that currently point at this issue."
items={inbound}
emptyLabel="No other tasks reference this issue yet."
/>
</div>
);
}