2026-05-04 13:20:58 -05:00
|
|
|
export const ISSUE_REFERENCE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]*-\d+$/;
|
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>
2026-04-21 10:02:52 -05:00
|
|
|
|
|
|
|
|
export interface IssueReferenceMatch {
|
|
|
|
|
index: number;
|
|
|
|
|
length: number;
|
|
|
|
|
identifier: string;
|
|
|
|
|
matchedText: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 13:20:58 -05:00
|
|
|
const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\/[^\s<>()]+|[A-Z][A-Z0-9]*-\d+/gi;
|
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>
2026-04-21 10:02:52 -05:00
|
|
|
|
|
|
|
|
function preserveNewlinesAsWhitespace(value: string) {
|
|
|
|
|
return value.replace(/[^\n]/g, " ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stripMarkdownCode(markdown: string): string {
|
|
|
|
|
if (!markdown) return "";
|
|
|
|
|
|
|
|
|
|
let output = "";
|
|
|
|
|
let index = 0;
|
|
|
|
|
|
|
|
|
|
while (index < markdown.length) {
|
|
|
|
|
const remaining = markdown.slice(index);
|
|
|
|
|
const fenceMatch = /^(?:```+|~~~+)/.exec(remaining);
|
|
|
|
|
const atLineStart = index === 0 || markdown[index - 1] === "\n";
|
|
|
|
|
|
|
|
|
|
if (atLineStart && fenceMatch) {
|
|
|
|
|
const fence = fenceMatch[0]!;
|
|
|
|
|
const blockStart = index;
|
|
|
|
|
index += fence.length;
|
|
|
|
|
while (index < markdown.length && markdown[index] !== "\n") index += 1;
|
|
|
|
|
if (index < markdown.length) index += 1;
|
|
|
|
|
|
|
|
|
|
while (index < markdown.length) {
|
|
|
|
|
const lineStart = index === 0 || markdown[index - 1] === "\n";
|
|
|
|
|
if (lineStart && markdown.startsWith(fence, index)) {
|
|
|
|
|
index += fence.length;
|
|
|
|
|
while (index < markdown.length && markdown[index] !== "\n") index += 1;
|
|
|
|
|
if (index < markdown.length) index += 1;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
output += preserveNewlinesAsWhitespace(markdown.slice(blockStart, index));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (markdown[index] === "`") {
|
|
|
|
|
let tickCount = 1;
|
|
|
|
|
while (index + tickCount < markdown.length && markdown[index + tickCount] === "`") {
|
|
|
|
|
tickCount += 1;
|
|
|
|
|
}
|
|
|
|
|
const fence = "`".repeat(tickCount);
|
|
|
|
|
const inlineStart = index;
|
|
|
|
|
index += tickCount;
|
|
|
|
|
const closeIndex = markdown.indexOf(fence, index);
|
|
|
|
|
if (closeIndex === -1) {
|
|
|
|
|
output += markdown.slice(inlineStart, inlineStart + tickCount);
|
|
|
|
|
index = inlineStart + tickCount;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
index = closeIndex + tickCount;
|
|
|
|
|
output += preserveNewlinesAsWhitespace(markdown.slice(inlineStart, index));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
output += markdown[index]!;
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return output;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trimTrailingPunctuation(token: string): string {
|
|
|
|
|
let trimmed = token;
|
|
|
|
|
while (trimmed.length > 0) {
|
|
|
|
|
const last = trimmed[trimmed.length - 1]!;
|
|
|
|
|
if (!".,!?;:".includes(last) && last !== ")" && last !== "]") break;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
(last === ")" && (trimmed.match(/\(/g)?.length ?? 0) >= (trimmed.match(/\)/g)?.length ?? 0))
|
|
|
|
|
|| (last === "]" && (trimmed.match(/\[/g)?.length ?? 0) >= (trimmed.match(/\]/g)?.length ?? 0))
|
|
|
|
|
) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
trimmed = trimmed.slice(0, -1);
|
|
|
|
|
}
|
|
|
|
|
return trimmed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function normalizeIssueIdentifier(value: string): string | null {
|
|
|
|
|
const trimmed = value.trim().toUpperCase();
|
|
|
|
|
return ISSUE_REFERENCE_IDENTIFIER_RE.test(trimmed) ? trimmed : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildIssueReferenceHref(identifier: string): string {
|
|
|
|
|
const normalized = normalizeIssueIdentifier(identifier);
|
|
|
|
|
return `/issues/${normalized ?? identifier.trim()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function parseIssueReferenceHref(href: string): { identifier: string } | null {
|
|
|
|
|
const raw = href.trim();
|
|
|
|
|
if (!raw) return null;
|
|
|
|
|
|
|
|
|
|
let url: URL;
|
|
|
|
|
try {
|
|
|
|
|
url = raw.startsWith("/")
|
|
|
|
|
? new URL(raw, "https://paperclip.invalid")
|
|
|
|
|
: new URL(raw);
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const segments = url.pathname
|
|
|
|
|
.split("/")
|
|
|
|
|
.map((segment) => segment.trim())
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
|
|
|
if (segments[index]?.toLowerCase() !== "issues") continue;
|
|
|
|
|
const identifier = normalizeIssueIdentifier(segments[index + 1] ?? "");
|
|
|
|
|
if (identifier) {
|
|
|
|
|
return { identifier };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function findIssueReferenceMatches(text: string): IssueReferenceMatch[] {
|
|
|
|
|
if (!text) return [];
|
|
|
|
|
|
|
|
|
|
const matches: IssueReferenceMatch[] = [];
|
|
|
|
|
let match: RegExpExecArray | null;
|
|
|
|
|
const re = new RegExp(ISSUE_REFERENCE_TOKEN_RE);
|
|
|
|
|
|
|
|
|
|
while ((match = re.exec(text)) !== null) {
|
|
|
|
|
const rawToken = match[0];
|
|
|
|
|
const cleanedToken = trimTrailingPunctuation(rawToken);
|
|
|
|
|
if (!cleanedToken) continue;
|
|
|
|
|
|
|
|
|
|
const identifier =
|
|
|
|
|
normalizeIssueIdentifier(cleanedToken)
|
|
|
|
|
?? parseIssueReferenceHref(cleanedToken)?.identifier
|
|
|
|
|
?? null;
|
|
|
|
|
|
|
|
|
|
if (!identifier) continue;
|
|
|
|
|
|
|
|
|
|
const cleanedIndex = match.index;
|
|
|
|
|
matches.push({
|
|
|
|
|
index: cleanedIndex,
|
|
|
|
|
length: cleanedToken.length,
|
|
|
|
|
identifier,
|
|
|
|
|
matchedText: cleanedToken,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return matches;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function extractIssueReferenceIdentifiers(markdown: string): string[] {
|
|
|
|
|
const scrubbed = stripMarkdownCode(markdown);
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
const ordered: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const match of findIssueReferenceMatches(scrubbed)) {
|
|
|
|
|
if (seen.has(match.identifier)) continue;
|
|
|
|
|
seen.add(match.identifier);
|
|
|
|
|
ordered.push(match.identifier);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ordered;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function extractIssueReferenceMatches(markdown: string): IssueReferenceMatch[] {
|
|
|
|
|
const scrubbed = stripMarkdownCode(markdown);
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
const ordered: IssueReferenceMatch[] = [];
|
|
|
|
|
|
|
|
|
|
for (const match of findIssueReferenceMatches(scrubbed)) {
|
|
|
|
|
if (seen.has(match.identifier)) continue;
|
|
|
|
|
seen.add(match.identifier);
|
|
|
|
|
ordered.push(match);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ordered;
|
|
|
|
|
}
|