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:
@@ -31,4 +31,5 @@ export {
|
||||
formatEmbeddedPostgresError,
|
||||
} from "./embedded-postgres-error.js";
|
||||
export { issueRelations } from "./schema/issue_relations.js";
|
||||
export { issueReferenceMentions } from "./schema/issue_reference_mentions.js";
|
||||
export * from "./schema/index.js";
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
CREATE TABLE IF NOT EXISTS "issue_reference_mentions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"source_issue_id" uuid NOT NULL,
|
||||
"target_issue_id" uuid NOT NULL,
|
||||
"source_kind" text NOT NULL,
|
||||
"source_record_id" uuid,
|
||||
"document_key" text,
|
||||
"matched_text" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_reference_mentions_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "issue_reference_mentions" ADD CONSTRAINT "issue_reference_mentions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_reference_mentions_source_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "issue_reference_mentions" ADD CONSTRAINT "issue_reference_mentions_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_reference_mentions_target_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "issue_reference_mentions" ADD CONSTRAINT "issue_reference_mentions_target_issue_id_issues_id_fk" FOREIGN KEY ("target_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_issue_idx" ON "issue_reference_mentions" USING btree ("company_id","source_issue_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_target_issue_idx" ON "issue_reference_mentions" USING btree ("company_id","target_issue_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_issue_pair_idx" ON "issue_reference_mentions" USING btree ("company_id","source_issue_id","target_issue_id");--> statement-breakpoint
|
||||
DELETE FROM "issue_reference_mentions"
|
||||
WHERE "id" IN (
|
||||
SELECT "id"
|
||||
FROM (
|
||||
SELECT
|
||||
"id",
|
||||
row_number() OVER (
|
||||
PARTITION BY "company_id", "source_issue_id", "target_issue_id", "source_kind", "source_record_id"
|
||||
ORDER BY "created_at", "id"
|
||||
) AS "row_number"
|
||||
FROM "issue_reference_mentions"
|
||||
) AS "duplicates"
|
||||
WHERE "duplicates"."row_number" > 1
|
||||
);--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "issue_reference_mentions_company_source_mention_uq";--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_mention_record_uq" ON "issue_reference_mentions" USING btree ("company_id","source_issue_id","target_issue_id","source_kind","source_record_id") WHERE "source_record_id" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_mention_null_record_uq" ON "issue_reference_mentions" USING btree ("company_id","source_issue_id","target_issue_id","source_kind") WHERE "source_record_id" IS NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -421,6 +421,13 @@
|
||||
"when": 1776542246000,
|
||||
"tag": "0059_plugin_database_namespaces",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 60,
|
||||
"version": "7",
|
||||
"when": 1776717606743,
|
||||
"tag": "0060_orange_annihilus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||
export { projectGoals } from "./project_goals.js";
|
||||
export { goals } from "./goals.js";
|
||||
export { issues } from "./issues.js";
|
||||
export { issueReferenceMentions } from "./issue_reference_mentions.js";
|
||||
export { issueRelations } from "./issue_relations.js";
|
||||
export { routines, routineTriggers, routineRuns } from "./routines.js";
|
||||
export { issueWorkProducts } from "./issue_work_products.js";
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
export const issueReferenceMentions = pgTable(
|
||||
"issue_reference_mentions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
sourceIssueId: uuid("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
targetIssueId: uuid("target_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
sourceKind: text("source_kind").$type<"title" | "description" | "comment" | "document">().notNull(),
|
||||
sourceRecordId: uuid("source_record_id"),
|
||||
documentKey: text("document_key"),
|
||||
matchedText: text("matched_text"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companySourceIssueIdx: index("issue_reference_mentions_company_source_issue_idx").on(
|
||||
table.companyId,
|
||||
table.sourceIssueId,
|
||||
),
|
||||
companyTargetIssueIdx: index("issue_reference_mentions_company_target_issue_idx").on(
|
||||
table.companyId,
|
||||
table.targetIssueId,
|
||||
),
|
||||
companyIssuePairIdx: index("issue_reference_mentions_company_issue_pair_idx").on(
|
||||
table.companyId,
|
||||
table.sourceIssueId,
|
||||
table.targetIssueId,
|
||||
),
|
||||
companySourceMentionWithRecordUq: uniqueIndex("issue_reference_mentions_company_source_mention_record_uq").on(
|
||||
table.companyId,
|
||||
table.sourceIssueId,
|
||||
table.targetIssueId,
|
||||
table.sourceKind,
|
||||
table.sourceRecordId,
|
||||
).where(sql`${table.sourceRecordId} is not null`),
|
||||
companySourceMentionWithoutRecordUq: uniqueIndex("issue_reference_mentions_company_source_mention_null_record_uq").on(
|
||||
table.companyId,
|
||||
table.sourceIssueId,
|
||||
table.targetIssueId,
|
||||
table.sourceKind,
|
||||
).where(sql`${table.sourceRecordId} is null`),
|
||||
}),
|
||||
);
|
||||
@@ -156,6 +156,8 @@ const SYSTEM_ISSUE_DOCUMENT_KEY_SET = new Set<string>(SYSTEM_ISSUE_DOCUMENT_KEYS
|
||||
export function isSystemIssueDocumentKey(key: string): key is SystemIssueDocumentKey {
|
||||
return SYSTEM_ISSUE_DOCUMENT_KEY_SET.has(key);
|
||||
}
|
||||
export const ISSUE_REFERENCE_SOURCE_KINDS = ["title", "description", "comment", "document"] as const;
|
||||
export type IssueReferenceSourceKind = (typeof ISSUE_REFERENCE_SOURCE_KINDS)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
|
||||
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
SYSTEM_ISSUE_DOCUMENT_KEYS,
|
||||
isSystemIssueDocumentKey,
|
||||
ISSUE_REFERENCE_SOURCE_KINDS,
|
||||
ISSUE_EXECUTION_POLICY_MODES,
|
||||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
ISSUE_EXECUTION_STATE_STATUSES,
|
||||
@@ -109,6 +110,7 @@ export {
|
||||
type IssueOriginKind,
|
||||
type IssueRelationType,
|
||||
type SystemIssueDocumentKey,
|
||||
type IssueReferenceSourceKind,
|
||||
type IssueExecutionPolicyMode,
|
||||
type IssueExecutionStageType,
|
||||
type IssueExecutionStateStatus,
|
||||
@@ -286,6 +288,9 @@ export type {
|
||||
IssueWorkProductReviewState,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueReferenceSource,
|
||||
IssueRelatedWorkItem,
|
||||
IssueRelatedWorkSummary,
|
||||
IssueRelation,
|
||||
IssueRelationIssueSummary,
|
||||
IssueExecutionPolicy,
|
||||
@@ -432,6 +437,16 @@ export type {
|
||||
QuotaWindow,
|
||||
ProviderQuotaResult,
|
||||
} from "./types/index.js";
|
||||
export {
|
||||
ISSUE_REFERENCE_IDENTIFIER_RE,
|
||||
buildIssueReferenceHref,
|
||||
extractIssueReferenceIdentifiers,
|
||||
extractIssueReferenceMatches,
|
||||
findIssueReferenceMatches,
|
||||
normalizeIssueIdentifier,
|
||||
parseIssueReferenceHref,
|
||||
type IssueReferenceMatch,
|
||||
} from "./issue-references.js";
|
||||
|
||||
export {
|
||||
sidebarOrderPreferenceSchema,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildIssueReferenceHref,
|
||||
extractIssueReferenceIdentifiers,
|
||||
findIssueReferenceMatches,
|
||||
normalizeIssueIdentifier,
|
||||
parseIssueReferenceHref,
|
||||
} from "./issue-references.js";
|
||||
|
||||
describe("issue references", () => {
|
||||
it("normalizes identifiers to uppercase", () => {
|
||||
expect(normalizeIssueIdentifier("pap-123")).toBe("PAP-123");
|
||||
expect(normalizeIssueIdentifier("not-an-issue")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses relative and absolute issue hrefs", () => {
|
||||
expect(parseIssueReferenceHref("/issues/PAP-123")).toEqual({ identifier: "PAP-123" });
|
||||
expect(parseIssueReferenceHref("/PAP/issues/pap-456")).toEqual({ identifier: "PAP-456" });
|
||||
expect(parseIssueReferenceHref("https://paperclip.ing/PAP/issues/pap-789#comment-1")).toEqual({
|
||||
identifier: "PAP-789",
|
||||
});
|
||||
expect(parseIssueReferenceHref("https://paperclip.ing/projects/PAP-789")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds canonical issue hrefs", () => {
|
||||
expect(buildIssueReferenceHref("pap-123")).toBe("/issues/PAP-123");
|
||||
});
|
||||
|
||||
it("finds identifiers and issue paths in plain text", () => {
|
||||
expect(findIssueReferenceMatches("See PAP-1, /issues/PAP-2, and https://x.test/PAP/issues/pap-3.")).toEqual([
|
||||
{ index: 4, length: 5, identifier: "PAP-1", matchedText: "PAP-1" },
|
||||
{ index: 11, length: 13, identifier: "PAP-2", matchedText: "/issues/PAP-2" },
|
||||
{
|
||||
index: 30,
|
||||
length: 31,
|
||||
identifier: "PAP-3",
|
||||
matchedText: "https://x.test/PAP/issues/pap-3",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("trims unmatched square brackets from issue path tokens", () => {
|
||||
expect(findIssueReferenceMatches("See /issues/PAP-123] for context.")).toEqual([
|
||||
{ index: 4, length: 15, identifier: "PAP-123", matchedText: "/issues/PAP-123" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts and dedupes references from markdown", () => {
|
||||
expect(extractIssueReferenceIdentifiers("PAP-1 [again](/issues/pap-1) PAP-2")).toEqual(["PAP-1", "PAP-2"]);
|
||||
});
|
||||
|
||||
it("ignores inline code and fenced code blocks", () => {
|
||||
const markdown = [
|
||||
"Use PAP-1 here.",
|
||||
"",
|
||||
"`PAP-2` should not count.",
|
||||
"",
|
||||
"```md",
|
||||
"PAP-3",
|
||||
"/issues/PAP-4",
|
||||
"```",
|
||||
"",
|
||||
"Final /issues/PAP-5 mention.",
|
||||
].join("\n");
|
||||
|
||||
expect(extractIssueReferenceIdentifiers(markdown)).toEqual(["PAP-1", "PAP-5"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
export const ISSUE_REFERENCE_IDENTIFIER_RE = /^[A-Z]+-\d+$/;
|
||||
|
||||
export interface IssueReferenceMatch {
|
||||
index: number;
|
||||
length: number;
|
||||
identifier: string;
|
||||
matchedText: string;
|
||||
}
|
||||
|
||||
const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\/[^\s<>()]+|[A-Z]+-\d+/gi;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -102,6 +102,9 @@ export type {
|
||||
export type {
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueReferenceSource,
|
||||
IssueRelatedWorkItem,
|
||||
IssueRelatedWorkSummary,
|
||||
IssueRelation,
|
||||
IssueRelationIssueSummary,
|
||||
IssueExecutionPolicy,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
IssueExecutionDecisionOutcome,
|
||||
IssueExecutionPolicyMode,
|
||||
IssueReferenceSourceKind,
|
||||
IssueExecutionStageType,
|
||||
IssueExecutionStateStatus,
|
||||
IssueOriginKind,
|
||||
@@ -123,6 +124,24 @@ export interface IssueRelation {
|
||||
relatedIssue: IssueRelationIssueSummary;
|
||||
}
|
||||
|
||||
export interface IssueReferenceSource {
|
||||
kind: IssueReferenceSourceKind;
|
||||
sourceRecordId: string | null;
|
||||
label: string;
|
||||
matchedText: string | null;
|
||||
}
|
||||
|
||||
export interface IssueRelatedWorkItem {
|
||||
issue: IssueRelationIssueSummary;
|
||||
mentionCount: number;
|
||||
sources: IssueReferenceSource[];
|
||||
}
|
||||
|
||||
export interface IssueRelatedWorkSummary {
|
||||
outbound: IssueRelatedWorkItem[];
|
||||
inbound: IssueRelatedWorkItem[];
|
||||
}
|
||||
|
||||
export interface IssueExecutionStagePrincipal {
|
||||
type: "agent" | "user";
|
||||
agentId?: string | null;
|
||||
@@ -214,6 +233,8 @@ export interface Issue {
|
||||
labels?: IssueLabel[];
|
||||
blockedBy?: IssueRelationIssueSummary[];
|
||||
blocks?: IssueRelationIssueSummary[];
|
||||
relatedWork?: IssueRelatedWorkSummary;
|
||||
referencedIssueIdentifiers?: string[];
|
||||
planDocument?: IssueDocument | null;
|
||||
documentSummaries?: IssueDocumentSummary[];
|
||||
legacyPlanDocument?: LegacyPlanDocument | null;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user