mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
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:
parent
1954eb3048
commit
ab9051b595
49 changed files with 16100 additions and 28 deletions
|
|
@ -27,6 +27,18 @@ pnpm db:migrate
|
||||||
|
|
||||||
When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance.
|
When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance.
|
||||||
|
|
||||||
|
Issue reference mentions follow the normal migration path: the schema migration creates the tracking table, but it does not backfill historical issue titles, descriptions, comments, or documents automatically.
|
||||||
|
|
||||||
|
To backfill existing content manually after migrating, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm issue-references:backfill
|
||||||
|
# optional: limit to one company
|
||||||
|
pnpm issue-references:backfill -- --company <company-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Future issue, comment, and document writes sync references automatically without running the backfill command.
|
||||||
|
|
||||||
This mode is ideal for local development and one-command installs.
|
This mode is ideal for local development and one-command installs.
|
||||||
|
|
||||||
Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`).
|
Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`).
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"test:run": "pnpm run preflight:workspace-links && vitest run",
|
"test:run": "pnpm run preflight:workspace-links && vitest run",
|
||||||
"db:generate": "pnpm --filter @paperclipai/db generate",
|
"db:generate": "pnpm --filter @paperclipai/db generate",
|
||||||
"db:migrate": "pnpm --filter @paperclipai/db migrate",
|
"db:migrate": "pnpm --filter @paperclipai/db migrate",
|
||||||
|
"issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts",
|
||||||
"secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts",
|
"secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts",
|
||||||
"db:backup": "./scripts/backup-db.sh",
|
"db:backup": "./scripts/backup-db.sh",
|
||||||
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
|
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,5 @@ export {
|
||||||
formatEmbeddedPostgresError,
|
formatEmbeddedPostgresError,
|
||||||
} from "./embedded-postgres-error.js";
|
} from "./embedded-postgres-error.js";
|
||||||
export { issueRelations } from "./schema/issue_relations.js";
|
export { issueRelations } from "./schema/issue_relations.js";
|
||||||
|
export { issueReferenceMentions } from "./schema/issue_reference_mentions.js";
|
||||||
export * from "./schema/index.js";
|
export * from "./schema/index.js";
|
||||||
|
|
|
||||||
50
packages/db/src/migrations/0060_orange_annihilus.sql
Normal file
50
packages/db/src/migrations/0060_orange_annihilus.sql
Normal file
|
|
@ -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;
|
||||||
14023
packages/db/src/migrations/meta/0060_snapshot.json
Normal file
14023
packages/db/src/migrations/meta/0060_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -421,6 +421,13 @@
|
||||||
"when": 1776542246000,
|
"when": 1776542246000,
|
||||||
"tag": "0059_plugin_database_namespaces",
|
"tag": "0059_plugin_database_namespaces",
|
||||||
"breakpoints": true
|
"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 { projectGoals } from "./project_goals.js";
|
||||||
export { goals } from "./goals.js";
|
export { goals } from "./goals.js";
|
||||||
export { issues } from "./issues.js";
|
export { issues } from "./issues.js";
|
||||||
|
export { issueReferenceMentions } from "./issue_reference_mentions.js";
|
||||||
export { issueRelations } from "./issue_relations.js";
|
export { issueRelations } from "./issue_relations.js";
|
||||||
export { routines, routineTriggers, routineRuns } from "./routines.js";
|
export { routines, routineTriggers, routineRuns } from "./routines.js";
|
||||||
export { issueWorkProducts } from "./issue_work_products.js";
|
export { issueWorkProducts } from "./issue_work_products.js";
|
||||||
|
|
|
||||||
48
packages/db/src/schema/issue_reference_mentions.ts
Normal file
48
packages/db/src/schema/issue_reference_mentions.ts
Normal file
|
|
@ -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 {
|
export function isSystemIssueDocumentKey(key: string): key is SystemIssueDocumentKey {
|
||||||
return SYSTEM_ISSUE_DOCUMENT_KEY_SET.has(key);
|
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 const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
|
||||||
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
|
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export {
|
||||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||||
SYSTEM_ISSUE_DOCUMENT_KEYS,
|
SYSTEM_ISSUE_DOCUMENT_KEYS,
|
||||||
isSystemIssueDocumentKey,
|
isSystemIssueDocumentKey,
|
||||||
|
ISSUE_REFERENCE_SOURCE_KINDS,
|
||||||
ISSUE_EXECUTION_POLICY_MODES,
|
ISSUE_EXECUTION_POLICY_MODES,
|
||||||
ISSUE_EXECUTION_STAGE_TYPES,
|
ISSUE_EXECUTION_STAGE_TYPES,
|
||||||
ISSUE_EXECUTION_STATE_STATUSES,
|
ISSUE_EXECUTION_STATE_STATUSES,
|
||||||
|
|
@ -109,6 +110,7 @@ export {
|
||||||
type IssueOriginKind,
|
type IssueOriginKind,
|
||||||
type IssueRelationType,
|
type IssueRelationType,
|
||||||
type SystemIssueDocumentKey,
|
type SystemIssueDocumentKey,
|
||||||
|
type IssueReferenceSourceKind,
|
||||||
type IssueExecutionPolicyMode,
|
type IssueExecutionPolicyMode,
|
||||||
type IssueExecutionStageType,
|
type IssueExecutionStageType,
|
||||||
type IssueExecutionStateStatus,
|
type IssueExecutionStateStatus,
|
||||||
|
|
@ -286,6 +288,9 @@ export type {
|
||||||
IssueWorkProductReviewState,
|
IssueWorkProductReviewState,
|
||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
|
IssueReferenceSource,
|
||||||
|
IssueRelatedWorkItem,
|
||||||
|
IssueRelatedWorkSummary,
|
||||||
IssueRelation,
|
IssueRelation,
|
||||||
IssueRelationIssueSummary,
|
IssueRelationIssueSummary,
|
||||||
IssueExecutionPolicy,
|
IssueExecutionPolicy,
|
||||||
|
|
@ -432,6 +437,16 @@ export type {
|
||||||
QuotaWindow,
|
QuotaWindow,
|
||||||
ProviderQuotaResult,
|
ProviderQuotaResult,
|
||||||
} from "./types/index.js";
|
} from "./types/index.js";
|
||||||
|
export {
|
||||||
|
ISSUE_REFERENCE_IDENTIFIER_RE,
|
||||||
|
buildIssueReferenceHref,
|
||||||
|
extractIssueReferenceIdentifiers,
|
||||||
|
extractIssueReferenceMatches,
|
||||||
|
findIssueReferenceMatches,
|
||||||
|
normalizeIssueIdentifier,
|
||||||
|
parseIssueReferenceHref,
|
||||||
|
type IssueReferenceMatch,
|
||||||
|
} from "./issue-references.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
sidebarOrderPreferenceSchema,
|
sidebarOrderPreferenceSchema,
|
||||||
|
|
|
||||||
68
packages/shared/src/issue-references.test.ts
Normal file
68
packages/shared/src/issue-references.test.ts
Normal file
|
|
@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
188
packages/shared/src/issue-references.ts
Normal file
188
packages/shared/src/issue-references.ts
Normal file
|
|
@ -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 {
|
export type {
|
||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
|
IssueReferenceSource,
|
||||||
|
IssueRelatedWorkItem,
|
||||||
|
IssueRelatedWorkSummary,
|
||||||
IssueRelation,
|
IssueRelation,
|
||||||
IssueRelationIssueSummary,
|
IssueRelationIssueSummary,
|
||||||
IssueExecutionPolicy,
|
IssueExecutionPolicy,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
IssueExecutionDecisionOutcome,
|
IssueExecutionDecisionOutcome,
|
||||||
IssueExecutionPolicyMode,
|
IssueExecutionPolicyMode,
|
||||||
|
IssueReferenceSourceKind,
|
||||||
IssueExecutionStageType,
|
IssueExecutionStageType,
|
||||||
IssueExecutionStateStatus,
|
IssueExecutionStateStatus,
|
||||||
IssueOriginKind,
|
IssueOriginKind,
|
||||||
|
|
@ -123,6 +124,24 @@ export interface IssueRelation {
|
||||||
relatedIssue: IssueRelationIssueSummary;
|
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 {
|
export interface IssueExecutionStagePrincipal {
|
||||||
type: "agent" | "user";
|
type: "agent" | "user";
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
|
|
@ -214,6 +233,8 @@ export interface Issue {
|
||||||
labels?: IssueLabel[];
|
labels?: IssueLabel[];
|
||||||
blockedBy?: IssueRelationIssueSummary[];
|
blockedBy?: IssueRelationIssueSummary[];
|
||||||
blocks?: IssueRelationIssueSummary[];
|
blocks?: IssueRelationIssueSummary[];
|
||||||
|
relatedWork?: IssueRelatedWorkSummary;
|
||||||
|
referencedIssueIdentifiers?: string[];
|
||||||
planDocument?: IssueDocument | null;
|
planDocument?: IssueDocument | null;
|
||||||
documentSummaries?: IssueDocumentSummary[];
|
documentSummaries?: IssueDocumentSummary[];
|
||||||
legacyPlanDocument?: LegacyPlanDocument | null;
|
legacyPlanDocument?: LegacyPlanDocument | null;
|
||||||
|
|
|
||||||
7
packages/shared/vitest.config.ts
Normal file
7
packages/shared/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
43
scripts/backfill-issue-reference-mentions.ts
Normal file
43
scripts/backfill-issue-reference-mentions.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { companies, createDb } from "../packages/db/src/index.js";
|
||||||
|
import { loadConfig } from "../server/src/config.js";
|
||||||
|
import { issueReferenceService } from "../server/src/services/issue-references.js";
|
||||||
|
|
||||||
|
function parseFlag(name: string): string | null {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
if (index < 0) return null;
|
||||||
|
const value = process.argv[index + 1];
|
||||||
|
return value && !value.startsWith("--") ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const config = loadConfig();
|
||||||
|
const dbUrl =
|
||||||
|
process.env.DATABASE_URL?.trim()
|
||||||
|
|| config.databaseUrl
|
||||||
|
|| `postgres://paperclip:paperclip@127.0.0.1:${config.embeddedPostgresPort}/paperclip`;
|
||||||
|
|
||||||
|
const db = createDb(dbUrl);
|
||||||
|
const refs = issueReferenceService(db);
|
||||||
|
const companyId = parseFlag("--company");
|
||||||
|
const companyRows = companyId
|
||||||
|
? [{ id: companyId }]
|
||||||
|
: await db.select({ id: companies.id }).from(companies);
|
||||||
|
|
||||||
|
if (companyRows.length === 0) {
|
||||||
|
console.log("No companies found; nothing to backfill.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Backfilling issue reference mentions for ${companyRows.length} compan${companyRows.length === 1 ? "y" : "ies"}...`);
|
||||||
|
for (const company of companyRows) {
|
||||||
|
console.log(`- ${company.id}`);
|
||||||
|
await refs.syncAllForCompany(company.id);
|
||||||
|
}
|
||||||
|
console.log("Issue reference backfill complete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Issue reference backfill failed: ${message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|
@ -49,6 +49,19 @@ vi.mock("../services/index.js", () => ({
|
||||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,19 @@ vi.mock("../services/index.js", () => ({
|
||||||
listCompanyIds: vi.fn(async () => [companyId]),
|
listCompanyIds: vi.fn(async () => [companyId]),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: vi.fn(async () => undefined),
|
logActivity: vi.fn(async () => undefined),
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,19 @@ function registerRouteMocks() {
|
||||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,19 @@ function registerServiceMocks() {
|
||||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => mockProjectService,
|
projectService: () => mockProjectService,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,19 @@ vi.mock("../services/index.js", () => ({
|
||||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,19 @@ vi.mock("../services/index.js", () => ({
|
||||||
heartbeatService: () => mockHeartbeatService,
|
heartbeatService: () => mockHeartbeatService,
|
||||||
instanceSettingsService: () => mockInstanceSettingsService,
|
instanceSettingsService: () => mockInstanceSettingsService,
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
@ -103,6 +116,19 @@ function registerModuleMocks() {
|
||||||
heartbeatService: () => mockHeartbeatService,
|
heartbeatService: () => mockHeartbeatService,
|
||||||
instanceSettingsService: () => mockInstanceSettingsService,
|
instanceSettingsService: () => mockInstanceSettingsService,
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,19 @@ vi.mock("../services/index.js", () => ({
|
||||||
listCompanyIds: vi.fn(),
|
listCompanyIds: vi.fn(),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: vi.fn(async () => undefined),
|
logActivity: vi.fn(async () => undefined),
|
||||||
projectService: () => ({
|
projectService: () => ({
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,19 @@ vi.mock("../services/index.js", () => ({
|
||||||
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
|
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
@ -68,6 +81,19 @@ function registerModuleMocks() {
|
||||||
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
|
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,19 @@ function registerModuleMocks() {
|
||||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: vi.fn(async () => undefined),
|
logActivity: vi.fn(async () => undefined),
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,19 @@ function registerModuleMocks() {
|
||||||
heartbeatService: () => mockHeartbeatService,
|
heartbeatService: () => mockHeartbeatService,
|
||||||
instanceSettingsService: () => mockInstanceSettingsService,
|
instanceSettingsService: () => mockInstanceSettingsService,
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
|
||||||
244
server/src/__tests__/issue-references-service.test.ts
Normal file
244
server/src/__tests__/issue-references-service.test.ts
Normal file
|
|
@ -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: () => ({}),
|
instanceSettingsService: () => ({}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: vi.fn(async () => undefined),
|
logActivity: vi.fn(async () => undefined),
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,19 @@ vi.mock("../services/index.js", () => ({
|
||||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: vi.fn(async () => undefined),
|
logActivity: vi.fn(async () => undefined),
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
@ -94,6 +107,19 @@ function registerModuleMocks() {
|
||||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: vi.fn(async () => undefined),
|
logActivity: vi.fn(async () => undefined),
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,19 @@ vi.mock("../services/index.js", () => ({
|
||||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: vi.fn(async () => undefined),
|
logActivity: vi.fn(async () => undefined),
|
||||||
projectService: () => ({}),
|
projectService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,19 @@ vi.mock("../services/index.js", () => ({
|
||||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||||
}),
|
}),
|
||||||
issueApprovalService: () => ({}),
|
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,
|
issueService: () => mockIssueService,
|
||||||
logActivity: vi.fn(async () => undefined),
|
logActivity: vi.fn(async () => undefined),
|
||||||
projectService: () => mockProjectService,
|
projectService: () => mockProjectService,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import {
|
||||||
issueApprovalService,
|
issueApprovalService,
|
||||||
ISSUE_LIST_DEFAULT_LIMIT,
|
ISSUE_LIST_DEFAULT_LIMIT,
|
||||||
ISSUE_LIST_MAX_LIMIT,
|
ISSUE_LIST_MAX_LIMIT,
|
||||||
|
issueReferenceService,
|
||||||
issueService,
|
issueService,
|
||||||
clampIssueListLimit,
|
clampIssueListLimit,
|
||||||
documentService,
|
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 {
|
function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string {
|
||||||
return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
|
return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
|
||||||
}
|
}
|
||||||
|
|
@ -314,6 +332,7 @@ export function issueRoutes(
|
||||||
const executionWorkspacesSvc = executionWorkspaceService(db);
|
const executionWorkspacesSvc = executionWorkspaceService(db);
|
||||||
const workProductsSvc = workProductService(db);
|
const workProductsSvc = workProductService(db);
|
||||||
const documentsSvc = documentService(db);
|
const documentsSvc = documentService(db);
|
||||||
|
const issueReferencesSvc = issueReferenceService(db);
|
||||||
const routinesSvc = routineService(db);
|
const routinesSvc = routineService(db);
|
||||||
const feedbackExportService = opts?.feedbackExportService;
|
const feedbackExportService = opts?.feedbackExportService;
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
|
|
@ -871,12 +890,13 @@ export function issueRoutes(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, issue.companyId);
|
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),
|
resolveIssueProjectAndGoal(issue),
|
||||||
svc.getAncestors(issue.id),
|
svc.getAncestors(issue.id),
|
||||||
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
|
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
|
||||||
documentsSvc.getIssueDocumentPayload(issue),
|
documentsSvc.getIssueDocumentPayload(issue),
|
||||||
svc.getRelationSummaries(issue.id),
|
svc.getRelationSummaries(issue.id),
|
||||||
|
issueReferencesSvc.listIssueReferenceSummary(issue.id),
|
||||||
]);
|
]);
|
||||||
const mentionedProjects = mentionedProjectIds.length > 0
|
const mentionedProjects = mentionedProjectIds.length > 0
|
||||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||||
|
|
@ -891,6 +911,8 @@ export function issueRoutes(
|
||||||
ancestors,
|
ancestors,
|
||||||
blockedBy: relations.blockedBy,
|
blockedBy: relations.blockedBy,
|
||||||
blocks: relations.blocks,
|
blocks: relations.blocks,
|
||||||
|
relatedWork: referenceSummary,
|
||||||
|
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||||
...documentPayload,
|
...documentPayload,
|
||||||
project: project ?? null,
|
project: project ?? null,
|
||||||
goal: goal ?? null,
|
goal: goal ?? null,
|
||||||
|
|
@ -963,6 +985,7 @@ export function issueRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
|
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
const result = await documentsSvc.upsertIssueDocument({
|
const result = await documentsSvc.upsertIssueDocument({
|
||||||
issueId: issue.id,
|
issueId: issue.id,
|
||||||
key: keyParsed.data,
|
key: keyParsed.data,
|
||||||
|
|
@ -976,6 +999,9 @@ export function issueRoutes(
|
||||||
createdByRunId: actor.runId ?? null,
|
createdByRunId: actor.runId ?? null,
|
||||||
});
|
});
|
||||||
const doc = result.document;
|
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, {
|
await logActivity(db, {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
|
|
@ -992,6 +1018,11 @@ export function issueRoutes(
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
format: doc.format,
|
format: doc.format,
|
||||||
revisionNumber: doc.latestRevisionNumber,
|
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 actor = getActorInfo(req);
|
||||||
|
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
const result = await documentsSvc.restoreIssueDocumentRevision({
|
const result = await documentsSvc.restoreIssueDocumentRevision({
|
||||||
issueId: issue.id,
|
issueId: issue.id,
|
||||||
key: keyParsed.data,
|
key: keyParsed.data,
|
||||||
|
|
@ -1042,6 +1074,9 @@ export function issueRoutes(
|
||||||
createdByAgentId: actor.agentId ?? null,
|
createdByAgentId: actor.agentId ?? null,
|
||||||
createdByUserId: actor.actorType === "user" ? actor.actorId : 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, {
|
await logActivity(db, {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
|
|
@ -1060,6 +1095,11 @@ export function issueRoutes(
|
||||||
revisionNumber: result.document.latestRevisionNumber,
|
revisionNumber: result.document.latestRevisionNumber,
|
||||||
restoredFromRevisionId: result.restoredFromRevisionId,
|
restoredFromRevisionId: result.restoredFromRevisionId,
|
||||||
restoredFromRevisionNumber: result.restoredFromRevisionNumber,
|
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 });
|
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
|
const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
|
||||||
if (!removed) {
|
if (!removed) {
|
||||||
res.status(404).json({ error: "Document not found" });
|
res.status(404).json({ error: "Document not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await issueReferencesSvc.deleteDocumentSource(removed.id);
|
||||||
|
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
|
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
|
|
@ -1103,6 +1147,11 @@ export function issueRoutes(
|
||||||
key: removed.key,
|
key: removed.key,
|
||||||
documentId: removed.id,
|
documentId: removed.id,
|
||||||
title: removed.title,
|
title: removed.title,
|
||||||
|
...summarizeIssueReferenceActivityDetails({
|
||||||
|
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||||
|
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||||
|
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
|
@ -1427,6 +1476,12 @@ export function issueRoutes(
|
||||||
createdByAgentId: actor.agentId,
|
createdByAgentId: actor.agentId,
|
||||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
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, {
|
await logActivity(db, {
|
||||||
companyId,
|
companyId,
|
||||||
|
|
@ -1441,6 +1496,11 @@ export function issueRoutes(
|
||||||
title: issue.title,
|
title: issue.title,
|
||||||
identifier: issue.identifier,
|
identifier: issue.identifier,
|
||||||
...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}),
|
...(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,
|
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) => {
|
router.post("/issues/:id/children", validate(createChildIssueSchema), async (req, res) => {
|
||||||
|
|
@ -1530,6 +1594,7 @@ export function issueRoutes(
|
||||||
existing.companyId,
|
existing.companyId,
|
||||||
req.body.assigneeAgentId as string | null | undefined,
|
req.body.assigneeAgentId as string | null | undefined,
|
||||||
);
|
);
|
||||||
|
const titleOrDescriptionChanged = req.body.title !== undefined || req.body.description !== undefined;
|
||||||
const existingRelations =
|
const existingRelations =
|
||||||
Array.isArray(req.body.blockedByIssueIds)
|
Array.isArray(req.body.blockedByIssueIds)
|
||||||
? await svc.getRelationSummaries(existing.id)
|
? await svc.getRelationSummaries(existing.id)
|
||||||
|
|
@ -1552,6 +1617,9 @@ export function issueRoutes(
|
||||||
actorType: actor.actorType,
|
actorType: actor.actorType,
|
||||||
actorId: actor.actorId,
|
actorId: actor.actorId,
|
||||||
}));
|
}));
|
||||||
|
const updateReferenceSummaryBefore = titleOrDescriptionChanged
|
||||||
|
? await issueReferencesSvc.listIssueReferenceSummary(existing.id)
|
||||||
|
: null;
|
||||||
let interruptedRunId: string | null = null;
|
let interruptedRunId: string | null = null;
|
||||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
||||||
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
|
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" });
|
res.status(404).json({ error: "Issue not found" });
|
||||||
return;
|
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;
|
let updatedRelations: Awaited<ReturnType<typeof svc.getRelationSummaries>> | null = null;
|
||||||
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
|
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
|
||||||
updatedRelations = await svc.getRelationSummaries(issue.id);
|
updatedRelations = await svc.getRelationSummaries(issue.id);
|
||||||
|
|
@ -1775,6 +1857,15 @@ export function issueRoutes(
|
||||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
|
||||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
_previous: hasFieldChanges ? previous : undefined,
|
_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;
|
let comment = null;
|
||||||
if (commentBody) {
|
if (commentBody) {
|
||||||
|
const commentReferenceSummaryBefore = updateReferenceSummaryAfter
|
||||||
|
?? await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
comment = await svc.addComment(id, commentBody, {
|
comment = await svc.addComment(id, commentBody, {
|
||||||
agentId: actor.agentId ?? undefined,
|
agentId: actor.agentId ?? undefined,
|
||||||
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
||||||
runId: actor.runId,
|
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, {
|
await logActivity(db, {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
|
|
@ -1893,9 +1999,22 @@ export function issueRoutes(
|
||||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
||||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
...(hasFieldChanges ? { updated: true } : {}),
|
...(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 =
|
const assigneeChanged =
|
||||||
issue.assigneeAgentId !== existing.assigneeAgentId || issue.assigneeUserId !== existing.assigneeUserId;
|
issue.assigneeAgentId !== existing.assigneeAgentId || issue.assigneeUserId !== existing.assigneeUserId;
|
||||||
|
|
@ -2489,6 +2608,7 @@ export function issueRoutes(
|
||||||
let reopenFromStatus: string | null = null;
|
let reopenFromStatus: string | null = null;
|
||||||
let interruptedRunId: string | null = null;
|
let interruptedRunId: string | null = null;
|
||||||
let currentIssue = issue;
|
let currentIssue = issue;
|
||||||
|
const commentReferenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
|
|
||||||
if (effectiveReopenRequested && isClosed) {
|
if (effectiveReopenRequested && isClosed) {
|
||||||
const reopenedIssue = await svc.update(id, { status: "todo" });
|
const reopenedIssue = await svc.update(id, { status: "todo" });
|
||||||
|
|
@ -2550,6 +2670,12 @@ export function issueRoutes(
|
||||||
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
||||||
runId: actor.runId,
|
runId: actor.runId,
|
||||||
});
|
});
|
||||||
|
await issueReferencesSvc.syncComment(comment.id);
|
||||||
|
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
|
||||||
|
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
||||||
|
commentReferenceSummaryBefore,
|
||||||
|
commentReferenceSummaryAfter,
|
||||||
|
);
|
||||||
|
|
||||||
if (actor.runId) {
|
if (actor.runId) {
|
||||||
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
|
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
|
||||||
|
|
@ -2572,6 +2698,11 @@ export function issueRoutes(
|
||||||
issueTitle: currentIssue.title,
|
issueTitle: currentIssue.title,
|
||||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
||||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
...(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,
|
type IssueFilters,
|
||||||
} from "./issues.js";
|
} from "./issues.js";
|
||||||
export { issueApprovalService } from "./issue-approvals.js";
|
export { issueApprovalService } from "./issue-approvals.js";
|
||||||
|
export { issueReferenceService } from "./issue-references.js";
|
||||||
export { goalService } from "./goals.js";
|
export { goalService } from "./goals.js";
|
||||||
export { activityService, type ActivityFilters } from "./activity.js";
|
export { activityService, type ActivityFilters } from "./activity.js";
|
||||||
export { approvalService } from "./approvals.js";
|
export { approvalService } from "./approvals.js";
|
||||||
|
|
|
||||||
407
server/src/services/issue-references.ts
Normal file
407
server/src/services/issue-references.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
|
import { IssueReferenceActivitySummary } from "./IssueReferenceActivitySummary";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { formatActivityVerb } from "../lib/activity-format";
|
import { formatActivityVerb } from "../lib/activity-format";
|
||||||
|
|
@ -50,19 +51,22 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
|
||||||
const actorAvatarUrl = userProfile?.image ?? null;
|
const actorAvatarUrl = userProfile?.image ?? null;
|
||||||
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<div className="flex gap-3">
|
<div className="space-y-2">
|
||||||
<p className="flex-1 min-w-0 truncate">
|
<div className="flex gap-3">
|
||||||
<Identity
|
<p className="flex-1 min-w-0 truncate">
|
||||||
name={actorName}
|
<Identity
|
||||||
avatarUrl={actorAvatarUrl}
|
name={actorName}
|
||||||
size="xs"
|
avatarUrl={actorAvatarUrl}
|
||||||
className="align-baseline"
|
size="xs"
|
||||||
/>
|
className="align-baseline"
|
||||||
<span className="text-muted-foreground ml-1">{verb} </span>
|
/>
|
||||||
{name && <span className="font-medium">{name}</span>}
|
<span className="text-muted-foreground ml-1">{verb} </span>
|
||||||
{entityTitle && <span className="text-muted-foreground ml-1">— {entityTitle}</span>}
|
{name && <span className="font-medium">{name}</span>}
|
||||||
</p>
|
{entityTitle && <span className="text-muted-foreground ml-1">— {entityTitle}</span>}
|
||||||
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
|
</p>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<IssueReferenceActivitySummary event={event} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,126 @@ describe("IssueProperties", () => {
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows related task references below sub-issues", async () => {
|
||||||
|
const root = renderProperties(container, {
|
||||||
|
issue: createIssue({
|
||||||
|
relatedWork: {
|
||||||
|
outbound: [
|
||||||
|
{
|
||||||
|
issue: {
|
||||||
|
id: "issue-22",
|
||||||
|
identifier: "PAP-22",
|
||||||
|
title: "Related task",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
mentionCount: 1,
|
||||||
|
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inbound: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
childIssues: [],
|
||||||
|
onUpdate: vi.fn(),
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain("Task ids");
|
||||||
|
expect(container.textContent).toContain("Related Tasks");
|
||||||
|
expect(container.textContent).toContain("PAP-22");
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides related task references already covered by blockers, blocking, and sub-issues", async () => {
|
||||||
|
const root = renderProperties(container, {
|
||||||
|
issue: createIssue({
|
||||||
|
blockedBy: [
|
||||||
|
{
|
||||||
|
id: "issue-22",
|
||||||
|
identifier: "PAP-22",
|
||||||
|
title: "Blocker",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: "issue-33",
|
||||||
|
identifier: "PAP-33",
|
||||||
|
title: "Blocked issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relatedWork: {
|
||||||
|
outbound: [
|
||||||
|
{
|
||||||
|
issue: {
|
||||||
|
id: "issue-22",
|
||||||
|
identifier: "PAP-22",
|
||||||
|
title: "Blocker",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
mentionCount: 1,
|
||||||
|
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
issue: {
|
||||||
|
id: "issue-33",
|
||||||
|
identifier: "PAP-33",
|
||||||
|
title: "Blocked issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
mentionCount: 1,
|
||||||
|
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-33" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
issue: {
|
||||||
|
id: "child-44",
|
||||||
|
identifier: "PAP-44",
|
||||||
|
title: "Child issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
mentionCount: 1,
|
||||||
|
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-44" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inbound: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
childIssues: [
|
||||||
|
createIssue({
|
||||||
|
id: "child-44",
|
||||||
|
identifier: "PAP-44",
|
||||||
|
title: "Child issue",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
onUpdate: vi.fn(),
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain("Related Tasks");
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
it("shows an add-label button when labels already exist and opens the picker", async () => {
|
it("shows an add-label button when labels already exist and opens the picker", async () => {
|
||||||
const root = renderProperties(container, {
|
const root = renderProperties(container, {
|
||||||
issue: createIssue({
|
issue: createIssue({
|
||||||
|
|
@ -531,7 +651,6 @@ describe("IssueProperties", () => {
|
||||||
|
|
||||||
act(() => rerenderedRoot.unmount());
|
act(() => rerenderedRoot.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
|
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
|
||||||
const onUpdate = vi.fn();
|
const onUpdate = vi.fn();
|
||||||
const root = renderProperties(container, {
|
const root = renderProperties(container, {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execu
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
import { PriorityIcon } from "./PriorityIcon";
|
import { PriorityIcon } from "./PriorityIcon";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
|
import { IssueReferencePill } from "./IssueReferencePill";
|
||||||
import { formatDate, cn, projectUrl } from "../lib/utils";
|
import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
@ -295,6 +296,30 @@ export function IssueProperties({
|
||||||
if (isMainIssueWorkspace({ issue, project: issueProject })) return null;
|
if (isMainIssueWorkspace({ issue, project: issueProject })) return null;
|
||||||
return runningRuntimeServiceWithUrl(issue.currentExecutionWorkspace?.runtimeServices);
|
return runningRuntimeServiceWithUrl(issue.currentExecutionWorkspace?.runtimeServices);
|
||||||
}, [issue, issueProject]);
|
}, [issue, issueProject]);
|
||||||
|
const referencedIssueIdentifiers = issue.referencedIssueIdentifiers ?? [];
|
||||||
|
const relatedTasks = useMemo(() => {
|
||||||
|
const excluded = new Set<string>();
|
||||||
|
const addExcluded = (candidate: { id: string; identifier?: string | null }) => {
|
||||||
|
excluded.add(candidate.id);
|
||||||
|
if (candidate.identifier) excluded.add(candidate.identifier);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const blocker of issue.blockedBy ?? []) addExcluded(blocker);
|
||||||
|
for (const blocked of issue.blocks ?? []) addExcluded(blocked);
|
||||||
|
for (const child of childIssues) addExcluded(child);
|
||||||
|
|
||||||
|
const referencedIssues = issue.relatedWork?.outbound.map((item) => item.issue) ?? [];
|
||||||
|
if (referencedIssues.length > 0) {
|
||||||
|
return referencedIssues.filter((referenced) => {
|
||||||
|
const label = referenced.identifier ?? referenced.id;
|
||||||
|
return !excluded.has(referenced.id) && !excluded.has(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return referencedIssueIdentifiers
|
||||||
|
.filter((identifier) => !excluded.has(identifier))
|
||||||
|
.map((identifier) => ({ id: identifier, identifier, title: identifier }));
|
||||||
|
}, [childIssues, issue.blockedBy, issue.blocks, issue.relatedWork?.outbound, referencedIssueIdentifiers]);
|
||||||
const projectLink = (id: string | null) => {
|
const projectLink = (id: string | null) => {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const project = projects?.find((p) => p.id === id) ?? null;
|
const project = projects?.find((p) => p.id === id) ?? null;
|
||||||
|
|
@ -1113,12 +1138,22 @@ export function IssueProperties({
|
||||||
onClick={onAddSubIssue}
|
onClick={onAddSubIssue}
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
Add sub-issue
|
Add sub-issue
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
|
|
||||||
|
{relatedTasks.length > 0 ? (
|
||||||
|
<PropertyRow label="Related Tasks">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{relatedTasks.map((related) => (
|
||||||
|
<IssueReferencePill key={related.id} issue={related} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PropertyRow>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<PropertyPicker
|
<PropertyPicker
|
||||||
inline={inline}
|
inline={inline}
|
||||||
label="Reviewers"
|
label="Reviewers"
|
||||||
|
|
|
||||||
73
ui/src/components/IssueReferenceActivitySummary.tsx
Normal file
73
ui/src/components/IssueReferenceActivitySummary.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type { ActivityEvent } from "@paperclipai/shared";
|
||||||
|
import { Plus, Minus } from "lucide-react";
|
||||||
|
import { IssueReferencePill } from "./IssueReferencePill";
|
||||||
|
|
||||||
|
type ActivityIssueReference = {
|
||||||
|
id: string;
|
||||||
|
identifier?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readIssueReferences(details: Record<string, unknown> | null | undefined, key: string): ActivityIssueReference[] {
|
||||||
|
const value = details?.[key];
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((item): item is ActivityIssueReference => !!item && typeof item === "object");
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
items,
|
||||||
|
strikethrough,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
items: ActivityIssueReference[];
|
||||||
|
strikethrough?: boolean;
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
aria-label={label}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
</span>
|
||||||
|
{items.map((issue) => (
|
||||||
|
<IssueReferencePill
|
||||||
|
key={`${label}:${issue.id}`}
|
||||||
|
strikethrough={strikethrough}
|
||||||
|
issue={{
|
||||||
|
id: issue.id,
|
||||||
|
identifier: issue.identifier ?? null,
|
||||||
|
title: issue.title ?? issue.identifier ?? issue.id,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueReferenceActivitySummary({ event }: { event: Pick<ActivityEvent, "details"> }) {
|
||||||
|
const added = readIssueReferences(event.details, "addedReferencedIssues");
|
||||||
|
const removed = readIssueReferences(event.details, "removedReferencedIssues");
|
||||||
|
if (added.length === 0 && removed.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<Section
|
||||||
|
label="Added references"
|
||||||
|
icon={<Plus className="h-3 w-3 text-green-600 dark:text-green-400" aria-hidden="true" />}
|
||||||
|
items={added}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
label="Removed references"
|
||||||
|
icon={<Minus className="h-3 w-3 text-red-600 dark:text-red-400" aria-hidden="true" />}
|
||||||
|
items={removed}
|
||||||
|
strikethrough
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
ui/src/components/IssueReferencePill.tsx
Normal file
55
ui/src/components/IssueReferencePill.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
|
||||||
|
export function IssueReferencePill({
|
||||||
|
issue,
|
||||||
|
strikethrough,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
issue: Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
|
||||||
|
Partial<Pick<IssueRelationIssueSummary, "status">>;
|
||||||
|
strikethrough?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const issueLabel = issue.identifier ?? issue.title;
|
||||||
|
const classNames = cn(
|
||||||
|
"paperclip-mention-chip paperclip-mention-chip--issue",
|
||||||
|
"inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
|
||||||
|
issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring",
|
||||||
|
strikethrough && "opacity-60 line-through decoration-muted-foreground",
|
||||||
|
className,
|
||||||
|
);
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{issue.status ? <StatusIcon status={issue.status} className="h-3 w-3 shrink-0" /> : null}
|
||||||
|
<span>{issue.identifier ?? issue.title}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!issue.identifier) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-mention-kind="issue"
|
||||||
|
className={classNames}
|
||||||
|
title={issue.title}
|
||||||
|
aria-label={`Issue: ${issue.title}`}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/issues/${issueLabel}`}
|
||||||
|
data-mention-kind="issue"
|
||||||
|
className={classNames}
|
||||||
|
title={issue.title}
|
||||||
|
aria-label={`Issue ${issueLabel}: ${issue.title}`}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
ui/src/components/IssueRelatedWorkPanel.test.tsx
Normal file
96
ui/src/components/IssueRelatedWorkPanel.test.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { IssueRelatedWorkPanel } from "./IssueRelatedWorkPanel";
|
||||||
|
|
||||||
|
vi.mock("@/lib/router", () => ({
|
||||||
|
Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => <a href={to} {...props}>{children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("IssueRelatedWorkPanel", () => {
|
||||||
|
it("renders outbound and inbound related work with source labels", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<IssueRelatedWorkPanel
|
||||||
|
relatedWork={{
|
||||||
|
outbound: [
|
||||||
|
{
|
||||||
|
issue: {
|
||||||
|
id: "issue-2",
|
||||||
|
identifier: "PAP-22",
|
||||||
|
title: "Downstream task",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
mentionCount: 2,
|
||||||
|
sources: [
|
||||||
|
{ kind: "title", sourceRecordId: null, label: "title", matchedText: "PAP-22" },
|
||||||
|
{ kind: "document", sourceRecordId: "doc-1", label: "plan", matchedText: "/issues/PAP-22" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inbound: [
|
||||||
|
{
|
||||||
|
issue: {
|
||||||
|
id: "issue-3",
|
||||||
|
identifier: "PAP-33",
|
||||||
|
title: "Upstream task",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "high",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
mentionCount: 1,
|
||||||
|
sources: [
|
||||||
|
{ kind: "comment", sourceRecordId: "comment-1", label: "comment", matchedText: "PAP-1" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("References");
|
||||||
|
expect(html).toContain("Referenced by");
|
||||||
|
expect(html).toContain("PAP-22");
|
||||||
|
expect(html).toContain("PAP-33");
|
||||||
|
expect(html).toContain('aria-label="Issue PAP-22: Downstream task"');
|
||||||
|
expect(html).toContain('aria-label="Issue PAP-33: Upstream task"');
|
||||||
|
expect(html).toContain("plan");
|
||||||
|
expect(html).toContain("comment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses duplicate source labels into a single chip with a count", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<IssueRelatedWorkPanel
|
||||||
|
relatedWork={{
|
||||||
|
outbound: [],
|
||||||
|
inbound: [
|
||||||
|
{
|
||||||
|
issue: {
|
||||||
|
id: "issue-4",
|
||||||
|
identifier: "PAP-44",
|
||||||
|
title: "Chatty inbound",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
mentionCount: 3,
|
||||||
|
sources: [
|
||||||
|
{ kind: "comment", sourceRecordId: "c1", label: "comment", matchedText: "PAP-44 first" },
|
||||||
|
{ kind: "comment", sourceRecordId: "c2", label: "comment", matchedText: "PAP-44 second" },
|
||||||
|
{ kind: "comment", sourceRecordId: "c3", label: "comment", matchedText: "PAP-44 third" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const commentMatches = html.match(/>comment</g) ?? [];
|
||||||
|
expect(commentMatches).toHaveLength(1);
|
||||||
|
expect(html).toContain("×3");
|
||||||
|
});
|
||||||
|
});
|
||||||
110
ui/src/components/IssueRelatedWorkPanel.tsx
Normal file
110
ui/src/components/IssueRelatedWorkPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,13 @@ import type { ReactNode } from "react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, buildUserMentionHref } from "@paperclipai/shared";
|
import {
|
||||||
|
buildAgentMentionHref,
|
||||||
|
buildIssueReferenceHref,
|
||||||
|
buildProjectMentionHref,
|
||||||
|
buildSkillMentionHref,
|
||||||
|
buildUserMentionHref,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { ThemeProvider } from "../context/ThemeContext";
|
import { ThemeProvider } from "../context/ThemeContext";
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
@ -14,7 +20,13 @@ const mockIssuesApi = vi.hoisted(() => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/router", () => ({
|
vi.mock("@/lib/router", () => ({
|
||||||
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
Link: ({
|
||||||
|
children,
|
||||||
|
to,
|
||||||
|
...props
|
||||||
|
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
|
||||||
|
<a href={to} {...props}>{children}</a>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../api/issues", () => ({
|
vi.mock("../api/issues", () => ({
|
||||||
|
|
@ -144,6 +156,7 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('href="/issues/PAP-1271"');
|
expect(html).toContain('href="/issues/PAP-1271"');
|
||||||
expect(html).toContain("text-green-600");
|
expect(html).toContain("text-green-600");
|
||||||
expect(html).toContain(">PAP-1271<");
|
expect(html).toContain(">PAP-1271<");
|
||||||
|
expect(html).not.toContain("paperclip-mention-chip--issue");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rewrites full issue URLs to internal issue links", () => {
|
it("rewrites full issue URLs to internal issue links", () => {
|
||||||
|
|
@ -154,6 +167,7 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('href="/issues/PAP-1179"');
|
expect(html).toContain('href="/issues/PAP-1179"');
|
||||||
expect(html).toContain("text-red-600");
|
expect(html).toContain("text-red-600");
|
||||||
expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<");
|
expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<");
|
||||||
|
expect(html).not.toContain("paperclip-mention-chip--issue");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rewrites issue scheme links to internal issue links", () => {
|
it("rewrites issue scheme links to internal issue links", () => {
|
||||||
|
|
@ -217,4 +231,15 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain("<pre");
|
expect(html).toContain("<pre");
|
||||||
expect(html).toContain('style="max-width:100%;overflow-x:auto"');
|
expect(html).toContain('style="max-width:100%;overflow-x:auto"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders internal issue links and bare identifiers as issue chips", () => {
|
||||||
|
const html = renderMarkdown(`See PAP-42 and [linked task](${buildIssueReferenceHref("PAP-77")}) for follow-up.`, [
|
||||||
|
{ identifier: "PAP-42", status: "done" },
|
||||||
|
{ identifier: "PAP-77", status: "blocked" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(html).toContain('href="/issues/PAP-42"');
|
||||||
|
expect(html).toContain('href="/issues/PAP-77"');
|
||||||
|
expect(html).toContain('data-mention-kind="issue"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,11 @@ function MarkdownIssueLink({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={href} className="inline-flex items-center gap-1.5 align-baseline">
|
<Link
|
||||||
|
to={href}
|
||||||
|
className="inline-flex items-center gap-1 align-baseline font-medium"
|
||||||
|
data-mention-kind="issue"
|
||||||
|
>
|
||||||
{data ? <StatusIcon status={data.status} className="h-3.5 w-3.5" /> : null}
|
{data ? <StatusIcon status={data.status} className="h-3.5 w-3.5" /> : null}
|
||||||
<span>{children}</span>
|
<span>{children}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -223,6 +227,8 @@ export function MarkdownBody({
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const targetHref = parsed.kind === "project"
|
const targetHref = parsed.kind === "project"
|
||||||
? `/projects/${parsed.projectId}`
|
? `/projects/${parsed.projectId}`
|
||||||
|
: parsed.kind === "issue"
|
||||||
|
? `/issues/${parsed.identifier}`
|
||||||
: parsed.kind === "skill"
|
: parsed.kind === "skill"
|
||||||
? `/skills/${parsed.skillId}`
|
? `/skills/${parsed.skillId}`
|
||||||
: parsed.kind === "user"
|
: parsed.kind === "user"
|
||||||
|
|
|
||||||
|
|
@ -730,7 +730,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.kind === "user") {
|
if (parsed.kind === "user" || parsed.kind === "issue") {
|
||||||
applyMentionChipDecoration(link, parsed);
|
applyMentionChipDecoration(link, parsed);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -763,6 +763,11 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown a.paperclip-mention-chip[data-mention-kind="issue"] {
|
||||||
|
border-color: color-mix(in oklab, var(--foreground) 14%, var(--border) 86%);
|
||||||
|
background: color-mix(in oklab, var(--accent) 42%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.dark .paperclip-markdown a {
|
.dark .paperclip-markdown a {
|
||||||
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
|
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref, parseUserMentionHref } from "@paperclipai/shared";
|
import {
|
||||||
|
parseAgentMentionHref,
|
||||||
|
parseIssueReferenceHref,
|
||||||
|
parseProjectMentionHref,
|
||||||
|
parseSkillMentionHref,
|
||||||
|
parseUserMentionHref,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { getAgentIcon } from "./agent-icons";
|
import { getAgentIcon } from "./agent-icons";
|
||||||
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
|
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
|
||||||
|
|
||||||
|
|
@ -9,6 +15,10 @@ export type ParsedMentionChip =
|
||||||
agentId: string;
|
agentId: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
kind: "issue";
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: "project";
|
kind: "project";
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -27,6 +37,14 @@ export type ParsedMentionChip =
|
||||||
const iconMaskCache = new Map<string, string>();
|
const iconMaskCache = new Map<string, string>();
|
||||||
|
|
||||||
export function parseMentionChipHref(href: string): ParsedMentionChip | null {
|
export function parseMentionChipHref(href: string): ParsedMentionChip | null {
|
||||||
|
const issue = parseIssueReferenceHref(href);
|
||||||
|
if (issue) {
|
||||||
|
return {
|
||||||
|
kind: "issue",
|
||||||
|
identifier: issue.identifier,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const agent = parseAgentMentionHref(href);
|
const agent = parseAgentMentionHref(href);
|
||||||
if (agent) {
|
if (agent) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -111,6 +129,7 @@ export function clearMentionChipDecoration(element: HTMLElement) {
|
||||||
element.classList.remove(
|
element.classList.remove(
|
||||||
"paperclip-mention-chip",
|
"paperclip-mention-chip",
|
||||||
"paperclip-mention-chip--agent",
|
"paperclip-mention-chip--agent",
|
||||||
|
"paperclip-mention-chip--issue",
|
||||||
"paperclip-mention-chip--project",
|
"paperclip-mention-chip--project",
|
||||||
"paperclip-mention-chip--user",
|
"paperclip-mention-chip--user",
|
||||||
"paperclip-mention-chip--skill",
|
"paperclip-mention-chip--skill",
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ import { FilterBar, type FilterValue } from "@/components/FilterBar";
|
||||||
import { InlineEditor } from "@/components/InlineEditor";
|
import { InlineEditor } from "@/components/InlineEditor";
|
||||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||||
import { Identity } from "@/components/Identity";
|
import { Identity } from "@/components/Identity";
|
||||||
|
import { IssueReferencePill } from "@/components/IssueReferencePill";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Section wrapper */
|
/* Section wrapper */
|
||||||
|
|
@ -466,6 +467,21 @@ export function DesignGuide() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SubSection>
|
</SubSection>
|
||||||
|
|
||||||
|
<SubSection title="IssueReferencePill">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Used wherever a task is referenced — in markdown, the Related Work tab, and activity summaries.
|
||||||
|
Pass <code className="font-mono">status</code> to show the target issue's state at a glance.
|
||||||
|
Use <code className="font-mono">strikethrough</code> for "removed" contexts.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<IssueReferencePill issue={{ id: "demo-1", identifier: "PAP-123", title: "Identifier only — no status yet" }} />
|
||||||
|
<IssueReferencePill issue={{ id: "demo-2", identifier: "PAP-456", title: "With in_progress status", status: "in_progress" }} />
|
||||||
|
<IssueReferencePill issue={{ id: "demo-3", identifier: "PAP-789", title: "Done status", status: "done" }} />
|
||||||
|
<IssueReferencePill issue={{ id: "demo-4", identifier: "PAP-101", title: "Blocked status", status: "blocked" }} />
|
||||||
|
<IssueReferencePill strikethrough issue={{ id: "demo-5", identifier: "PAP-202", title: "Removed (strikethrough)", status: "todo" }} />
|
||||||
|
</div>
|
||||||
|
</SubSection>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ============================================================ */}
|
{/* ============================================================ */}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ import { IssueChatThread, type IssueChatComposerHandle } from "../components/Iss
|
||||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||||
import { IssuesList } from "../components/IssuesList";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
|
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
|
||||||
|
import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel";
|
||||||
import { IssueProperties } from "../components/IssueProperties";
|
import { IssueProperties } from "../components/IssueProperties";
|
||||||
import { IssueRunLedger } from "../components/IssueRunLedger";
|
import { IssueRunLedger } from "../components/IssueRunLedger";
|
||||||
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
||||||
|
|
@ -94,6 +96,7 @@ import {
|
||||||
Copy,
|
Copy,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Hexagon,
|
Hexagon,
|
||||||
|
ListTree,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
|
|
@ -886,10 +889,13 @@ function IssueDetailActivityTab({
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{activity.slice(0, 20).map((evt) => (
|
{activity.slice(0, 20).map((evt) => (
|
||||||
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div key={evt.id} className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
|
||||||
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
|
<div className="flex items-center gap-1.5">
|
||||||
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
|
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
|
||||||
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
|
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
|
||||||
|
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<IssueReferenceActivitySummary event={evt} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2674,6 +2680,10 @@ export function IssueDetail() {
|
||||||
<ActivityIcon className="h-3.5 w-3.5" />
|
<ActivityIcon className="h-3.5 w-3.5" />
|
||||||
Activity
|
Activity
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="related-work" className="gap-1.5">
|
||||||
|
<ListTree className="h-3.5 w-3.5" />
|
||||||
|
Related work
|
||||||
|
</TabsTrigger>
|
||||||
{issuePluginTabItems.map((item) => (
|
{issuePluginTabItems.map((item) => (
|
||||||
<TabsTrigger key={item.value} value={item.value}>
|
<TabsTrigger key={item.value} value={item.value}>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|
@ -2738,6 +2748,10 @@ export function IssueDetail() {
|
||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="related-work">
|
||||||
|
<IssueRelatedWorkPanel relatedWork={issue.relatedWork} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{activePluginTab && (
|
{activePluginTab && (
|
||||||
<TabsContent value={activePluginTab.value}>
|
<TabsContent value={activePluginTab.value}>
|
||||||
<PluginSlotMount
|
<PluginSlotMount
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
projects: [
|
projects: [
|
||||||
|
"packages/shared",
|
||||||
"packages/db",
|
"packages/db",
|
||||||
"packages/adapter-utils",
|
"packages/adapter-utils",
|
||||||
"packages/adapters/codex-local",
|
"packages/adapters/codex-local",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue