External object references across issue surfaces (#8512)
## Thinking Path > - Paperclip is the open source control plane people use to coordinate AI agents, issues, approvals, comments, and work products. > - The involved subsystem is issue context: markdown links, issue properties, related work, lists, filters, inbox/sidebar status, and plugin-provided external context. > - The gap is that URLs to external systems currently remain mostly plain links, so humans and agents must manually open them to understand status, identity, and liveness. > - This matters because external work objects such as GitHub issues and pull requests are part of the operational state of a Paperclip company. > - The implementation keeps core provider-neutral: shared contracts, storage, sync, routes, and UI surfaces live in core while providers can contribute detection and status resolution. > - This pull request adds the external object reference foundation, GitHub provider support, issue-surface rendering, filters, sidebar/list/inbox signals, and test/story coverage. > - The benefit is that linked external work becomes inspectable Paperclip context without hardcoding every provider directly into the UI. ## Linked Issues or Issue Description No public GitHub issue exists for this work. Feature request: - Problem: URLs in Paperclip issues, comments, documents, and related surfaces do not expose provider status or object identity inline. - Proposed behavior: detect supported external object URLs, persist normalized references, refresh provider status, and render concise status-aware links across issue surfaces. - Users affected: board users, agents, and maintainers who triage issues containing external work links. - Acceptance: external object references are company-scoped, provider-extensible, visible in key issue surfaces, filterable where relevant, and covered by focused shared/server/UI tests. Related PR search: - No open duplicate PRs found for `external object references`. - Closed related prior attempt: #4556. ## What Changed - Added shared external-object contracts, validators, status/liveness helpers, and plugin protocol declarations. - Added database schema and additive migrations for external objects, source mentions, and display metadata. - Added server services/routes for detecting, syncing, summarizing, refreshing, and resolving external objects across issues, documents, comments, projects, and plugins. - Added a GitHub external-object provider plus plugin SDK authoring docs. - Wired UI presentation across markdown links, comments, issue chat, documents, properties, related work, issue rows, filters, inbox/sidebar badges, and Storybook stories. - Rebasing cleanup: moved the branch onto current `master`, repaired stale worktree provision config, hardened environment-sensitive tests/mocks, and removed committed screenshot artifacts from the PR branch to keep the reviewable file set below tool limits. ## Verification - `pnpm exec vitest run packages/shared/src/external-objects.test.ts server/src/__tests__/external-object-routes.test.ts server/src/__tests__/external-objects-service.test.ts ui/src/components/ExternalObjectPill.test.tsx ui/src/lib/external-objects.test.ts` passed after rebasing: 5 files, 56 tests. - Historical branch verification before this PR creation included `pnpm test:run`, `pnpm -r typecheck`, and `pnpm build`; this PR body does not claim those were rerun after the final rebase. ## Risks - Medium: this adds a new cross-surface sync path on issue/document/comment writes. The implementation uses safe sync wrappers so external-object failures warn instead of blocking core mutations. - Medium: the migrations introduce new tables and indexes. They are additive and company-scoped. - Medium: provider-specific URL parsing can miss or misclassify edge cases. Shared canonicalization tests and provider tests cover current GitHub shapes. - Low: UI badge/filter behavior could add visual noise for object-heavy issues; component tests and Storybook stories cover the intended surfaces. > Roadmap checked: `ROADMAP.md` references the plugin system as the current extension path and does not list a duplicate core feature. Related long-range docs discuss external references, work products, preview URLs, and plugin extension points; this PR implements the scoped external-object reference foundation. ## Model Used OpenAI Codex, GPT-5 coding-agent runtime, with shell and GitHub CLI tool use. Reasoning mode: medium. Exact deployed runtime model ID and context window were not exposed in the environment. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
cd38c150b0
commit
2dbaf4a7fa
|
|
@ -874,7 +874,7 @@ describe("worktree helpers", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("reseed preserves the current worktree ports, instance id, and branding", async () => {
|
||||
itEmbeddedPostgres("reseed preserves the current worktree ports, instance id, and branding", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const sourceRoot = path.join(tempRoot, "source");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
# External Object Reference Backfill
|
||||
|
||||
## Purpose
|
||||
|
||||
Backfill existing issue titles, descriptions, comments, and documents into `external_object_mentions` after the Phase 3 detector registry and mention sync service exist.
|
||||
|
||||
## Command Shape
|
||||
|
||||
Add a command parallel to the internal issue-reference backfill:
|
||||
|
||||
```sh
|
||||
pnpm external-objects:backfill
|
||||
pnpm external-objects:backfill -- --company <company-id>
|
||||
pnpm external-objects:backfill -- --dry-run
|
||||
```
|
||||
|
||||
## Required Behavior
|
||||
|
||||
- Scan issue title and description sources, issue comments, and issue documents through the same external-object mention sync service used by write hooks.
|
||||
- Preserve company boundaries: every scan query, mention upsert, object upsert, and dry-run summary must include `company_id`.
|
||||
- Use shared URL extraction and canonicalization helpers so raw URLs, URL userinfo, query strings, and fragments are never persisted.
|
||||
- Replace mentions per source instead of appending, using the same source key shape as live write sync.
|
||||
- Report counts by company, source kind, provider key, object type, skipped userinfo URLs, and unresolved URLs.
|
||||
- Default to a non-interactive safe run; `--dry-run` must avoid writes while still reporting what would change.
|
||||
|
||||
## Phase 3 Hook
|
||||
|
||||
The command should be implemented only after the detector registry can classify URLs and the service can upsert mentions/placeholders consistently. Until then, Phase 2 owns the schema and shared helpers that make the command safe.
|
||||
|
|
@ -12,6 +12,9 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
|
|||
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
|
||||
- Worker-side host APIs are capability-gated.
|
||||
- Plugin UI is not sandboxed by manifest capabilities.
|
||||
- External object reference providers are trusted-install only in the MVP.
|
||||
Capabilities gate provider detection/resolution and host API calls, but they
|
||||
are not a sandbox boundary for untrusted marketplace code.
|
||||
- Plugin database migrations are restricted to a host-derived plugin namespace.
|
||||
- Plugin-managed surfaces are first-class records (agents, projects, routines, and
|
||||
skills) rather than private plugin-only state.
|
||||
|
|
@ -22,6 +25,29 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
|
|||
building custom versions.
|
||||
- `ctx.assets` is not supported in the current runtime.
|
||||
|
||||
## External object reference providers
|
||||
|
||||
Plugins can contribute provider-neutral object reference detection and status
|
||||
resolution for URLs and future explicit links. Declare `objectReferences` in the
|
||||
manifest and add at least `external.objects.detect` and `external.objects.read`.
|
||||
|
||||
```ts
|
||||
objectReferences: [
|
||||
{
|
||||
providerKey: "mocktracker",
|
||||
displayName: "Mock Tracker",
|
||||
objectTypes: ["ticket"],
|
||||
urlPatterns: ["https://mock.example/tickets/:id"],
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
Implement `onDetectExternalObjects()` in the worker to recognize sanitized URL
|
||||
candidates and return provider-stable identities. Implement
|
||||
`onResolveExternalObject()` to return normalized board-safe status metadata.
|
||||
Paperclip owns inline markdown rendering; plugins must not return React, HTML,
|
||||
or `dangerouslySetInnerHTML` content for inline references.
|
||||
|
||||
## Scaffold a plugin
|
||||
|
||||
Use the CLI scaffold command:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { getTableConfig } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { externalObjectMentions } from "./schema/external_object_mentions.js";
|
||||
import { externalObjects } from "./schema/external_objects.js";
|
||||
|
||||
function indexColumns(table: Parameters<typeof getTableConfig>[0], indexName: string): string[] {
|
||||
const index = getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName);
|
||||
if (!index) return [];
|
||||
return index.config.columns.map((column) => (column as { name: string }).name);
|
||||
}
|
||||
|
||||
describe("external object reference schema", () => {
|
||||
it("scopes external object uniqueness by company", () => {
|
||||
expect(indexColumns(externalObjects, "external_objects_company_external_id_uq")).toEqual([
|
||||
"company_id",
|
||||
"provider_key",
|
||||
"object_type",
|
||||
"external_id",
|
||||
]);
|
||||
expect(indexColumns(externalObjects, "external_objects_company_identity_uq")).toEqual([
|
||||
"company_id",
|
||||
"provider_key",
|
||||
"object_type",
|
||||
"canonical_identity_hash",
|
||||
]);
|
||||
});
|
||||
|
||||
it("indexes status, refresh scheduling, source issue, and object lookups by company", () => {
|
||||
expect(indexColumns(externalObjects, "external_objects_company_provider_status_idx")).toEqual([
|
||||
"company_id",
|
||||
"provider_key",
|
||||
"status_category",
|
||||
]);
|
||||
expect(indexColumns(externalObjects, "external_objects_company_refresh_idx")).toEqual([
|
||||
"company_id",
|
||||
"next_refresh_at",
|
||||
]);
|
||||
expect(indexColumns(externalObjectMentions, "external_object_mentions_company_source_issue_idx")).toEqual([
|
||||
"company_id",
|
||||
"source_issue_id",
|
||||
]);
|
||||
expect(indexColumns(externalObjectMentions, "external_object_mentions_company_object_idx")).toEqual([
|
||||
"company_id",
|
||||
"object_id",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
CREATE TABLE IF NOT EXISTS "external_objects" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"provider_key" text NOT NULL,
|
||||
"plugin_id" uuid,
|
||||
"object_type" text NOT NULL,
|
||||
"external_id" text NOT NULL,
|
||||
"sanitized_canonical_url" text,
|
||||
"canonical_identity_hash" text,
|
||||
"display_title" text,
|
||||
"status_key" text,
|
||||
"status_label" text,
|
||||
"status_category" text DEFAULT 'unknown' NOT NULL,
|
||||
"status_tone" text DEFAULT 'neutral' NOT NULL,
|
||||
"liveness" text DEFAULT 'unknown' NOT NULL,
|
||||
"is_terminal" boolean DEFAULT false NOT NULL,
|
||||
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"remote_version" text,
|
||||
"etag" text,
|
||||
"last_resolved_at" timestamp with time zone,
|
||||
"last_changed_at" timestamp with time zone,
|
||||
"last_error_at" timestamp with time zone,
|
||||
"next_refresh_at" timestamp with time zone,
|
||||
"last_error_code" text,
|
||||
"last_error_message" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "external_object_mentions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"source_issue_id" uuid NOT NULL,
|
||||
"source_kind" text NOT NULL,
|
||||
"source_record_id" uuid,
|
||||
"document_key" text,
|
||||
"property_key" text,
|
||||
"matched_text_redacted" text,
|
||||
"sanitized_display_url" text,
|
||||
"canonical_identity_hash" text,
|
||||
"canonical_identity" jsonb,
|
||||
"object_id" uuid,
|
||||
"provider_key" text,
|
||||
"detector_key" text,
|
||||
"object_type" text,
|
||||
"confidence" text DEFAULT 'exact' NOT NULL,
|
||||
"created_by_plugin_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "external_objects" ADD CONSTRAINT "external_objects_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "external_objects" ADD CONSTRAINT "external_objects_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_object_id_external_objects_id_fk" FOREIGN KEY ("object_id") REFERENCES "public"."external_objects"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_created_by_plugin_id_plugins_id_fk" FOREIGN KEY ("created_by_plugin_id") REFERENCES "public"."plugins"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "external_objects_company_provider_object_idx" ON "external_objects" USING btree ("company_id","provider_key","object_type");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "external_objects_company_provider_status_idx" ON "external_objects" USING btree ("company_id","provider_key","status_category");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "external_objects_company_refresh_idx" ON "external_objects" USING btree ("company_id","next_refresh_at");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "external_objects_company_external_id_uq" ON "external_objects" USING btree ("company_id","provider_key","object_type","external_id");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "external_objects_company_identity_uq" ON "external_objects" USING btree ("company_id","provider_key","object_type","canonical_identity_hash");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "external_object_mentions_company_source_issue_idx" ON "external_object_mentions" USING btree ("company_id","source_issue_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "external_object_mentions_company_object_idx" ON "external_object_mentions" USING btree ("company_id","object_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "external_object_mentions_company_provider_idx" ON "external_object_mentions" USING btree ("company_id","provider_key","object_type");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "external_object_mentions_company_source_record_uq" ON "external_object_mentions" USING btree ("company_id","source_issue_id","source_kind","source_record_id","document_key","property_key","canonical_identity_hash") WHERE "source_record_id" is not null and "canonical_identity_hash" is not null;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "external_object_mentions_company_source_null_record_uq" ON "external_object_mentions" USING btree ("company_id","source_issue_id","source_kind","document_key","property_key","canonical_identity_hash") WHERE "source_record_id" is null and "canonical_identity_hash" is not null;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "display_key" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "icon_key" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "status_icon_key" text;
|
||||
|
|
@ -743,6 +743,20 @@
|
|||
"when": 1781902000000,
|
||||
"tag": "0105_instance_scoped_environments",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 106,
|
||||
"version": "7",
|
||||
"when": 1781902100000,
|
||||
"tag": "0106_external_object_references",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 107,
|
||||
"version": "7",
|
||||
"when": 1782165200000,
|
||||
"tag": "0107_external_object_display_metadata",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import type { ExternalObjectMentionConfidence, ExternalObjectMentionSourceKind } from "@paperclipai/shared";
|
||||
import { companies } from "./companies.js";
|
||||
import { externalObjects } from "./external_objects.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
export const externalObjectMentions = pgTable(
|
||||
"external_object_mentions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
sourceIssueId: uuid("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
sourceKind: text("source_kind").$type<ExternalObjectMentionSourceKind>().notNull(),
|
||||
sourceRecordId: uuid("source_record_id"),
|
||||
documentKey: text("document_key"),
|
||||
propertyKey: text("property_key"),
|
||||
matchedTextRedacted: text("matched_text_redacted"),
|
||||
sanitizedDisplayUrl: text("sanitized_display_url"),
|
||||
canonicalIdentityHash: text("canonical_identity_hash"),
|
||||
canonicalIdentity: jsonb("canonical_identity").$type<Record<string, unknown>>(),
|
||||
objectId: uuid("object_id").references(() => externalObjects.id, { onDelete: "set null" }),
|
||||
providerKey: text("provider_key"),
|
||||
detectorKey: text("detector_key"),
|
||||
objectType: text("object_type"),
|
||||
confidence: text("confidence").$type<ExternalObjectMentionConfidence>().notNull().default("exact"),
|
||||
createdByPluginId: uuid("created_by_plugin_id").references(() => plugins.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companySourceIssueIdx: index("external_object_mentions_company_source_issue_idx").on(
|
||||
table.companyId,
|
||||
table.sourceIssueId,
|
||||
),
|
||||
companyObjectIdx: index("external_object_mentions_company_object_idx").on(table.companyId, table.objectId),
|
||||
companyProviderIdx: index("external_object_mentions_company_provider_idx").on(
|
||||
table.companyId,
|
||||
table.providerKey,
|
||||
table.objectType,
|
||||
),
|
||||
companySourceMentionWithRecordUq: uniqueIndex("external_object_mentions_company_source_record_uq").on(
|
||||
table.companyId,
|
||||
table.sourceIssueId,
|
||||
table.sourceKind,
|
||||
table.sourceRecordId,
|
||||
table.documentKey,
|
||||
table.propertyKey,
|
||||
table.canonicalIdentityHash,
|
||||
).where(sql`${table.sourceRecordId} is not null and ${table.canonicalIdentityHash} is not null`),
|
||||
companySourceMentionWithoutRecordUq: uniqueIndex("external_object_mentions_company_source_null_record_uq").on(
|
||||
table.companyId,
|
||||
table.sourceIssueId,
|
||||
table.sourceKind,
|
||||
table.documentKey,
|
||||
table.propertyKey,
|
||||
table.canonicalIdentityHash,
|
||||
).where(sql`${table.sourceRecordId} is null and ${table.canonicalIdentityHash} is not null`),
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import {
|
||||
boolean,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type {
|
||||
ExternalObjectLivenessState,
|
||||
ExternalObjectStatusCategory,
|
||||
ExternalObjectStatusTone,
|
||||
} from "@paperclipai/shared";
|
||||
import { companies } from "./companies.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
export const externalObjects = pgTable(
|
||||
"external_objects",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
providerKey: text("provider_key").notNull(),
|
||||
pluginId: uuid("plugin_id").references(() => plugins.id, { onDelete: "set null" }),
|
||||
objectType: text("object_type").notNull(),
|
||||
externalId: text("external_id").notNull(),
|
||||
sanitizedCanonicalUrl: text("sanitized_canonical_url"),
|
||||
canonicalIdentityHash: text("canonical_identity_hash"),
|
||||
displayKey: text("display_key"),
|
||||
iconKey: text("icon_key"),
|
||||
displayTitle: text("display_title"),
|
||||
statusKey: text("status_key"),
|
||||
statusLabel: text("status_label"),
|
||||
statusIconKey: text("status_icon_key"),
|
||||
statusCategory: text("status_category").$type<ExternalObjectStatusCategory>().notNull().default("unknown"),
|
||||
statusTone: text("status_tone").$type<ExternalObjectStatusTone>().notNull().default("neutral"),
|
||||
liveness: text("liveness").$type<ExternalObjectLivenessState>().notNull().default("unknown"),
|
||||
isTerminal: boolean("is_terminal").notNull().default(false),
|
||||
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
|
||||
remoteVersion: text("remote_version"),
|
||||
etag: text("etag"),
|
||||
lastResolvedAt: timestamp("last_resolved_at", { withTimezone: true }),
|
||||
lastChangedAt: timestamp("last_changed_at", { withTimezone: true }),
|
||||
lastErrorAt: timestamp("last_error_at", { withTimezone: true }),
|
||||
nextRefreshAt: timestamp("next_refresh_at", { withTimezone: true }),
|
||||
lastErrorCode: text("last_error_code"),
|
||||
lastErrorMessage: text("last_error_message"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyProviderObjectIdx: index("external_objects_company_provider_object_idx").on(
|
||||
table.companyId,
|
||||
table.providerKey,
|
||||
table.objectType,
|
||||
),
|
||||
companyProviderStatusIdx: index("external_objects_company_provider_status_idx").on(
|
||||
table.companyId,
|
||||
table.providerKey,
|
||||
table.statusCategory,
|
||||
),
|
||||
companyRefreshIdx: index("external_objects_company_refresh_idx").on(table.companyId, table.nextRefreshAt),
|
||||
companyExternalIdUq: uniqueIndex("external_objects_company_external_id_uq").on(
|
||||
table.companyId,
|
||||
table.providerKey,
|
||||
table.objectType,
|
||||
table.externalId,
|
||||
),
|
||||
companyCanonicalIdentityUq: uniqueIndex("external_objects_company_identity_uq").on(
|
||||
table.companyId,
|
||||
table.providerKey,
|
||||
table.objectType,
|
||||
table.canonicalIdentityHash,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -36,6 +36,8 @@ export { issueWatchdogs } from "./issue_watchdogs.js";
|
|||
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";
|
||||
export { issueRecoveryActions } from "./issue_recovery_actions.js";
|
||||
export { issueReferenceMentions } from "./issue_reference_mentions.js";
|
||||
export { externalObjects } from "./external_objects.js";
|
||||
export { externalObjectMentions } from "./external_object_mentions.js";
|
||||
export { issueRelations } from "./issue_relations.js";
|
||||
export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js";
|
||||
export { issueWorkProducts } from "./issue_work_products.js";
|
||||
|
|
|
|||
|
|
@ -341,6 +341,10 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
|||
| | `telemetry.track` |
|
||||
| | `database.namespace.migrate` |
|
||||
| | `database.namespace.write` |
|
||||
| | `external.objects.detect` |
|
||||
| | `external.objects.read` |
|
||||
| | `external.objects.write` |
|
||||
| | `external.objects.refresh` |
|
||||
| **Instance** | `instance.settings.register` |
|
||||
| | `plugin.state.read` |
|
||||
| | `plugin.state.write` |
|
||||
|
|
@ -372,6 +376,42 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
|||
|
||||
Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
|
||||
|
||||
### External Object Reference Providers
|
||||
|
||||
Trusted connector plugins can declare generic external object providers in the
|
||||
manifest. The host owns URL scanning, sanitized canonical URLs, core storage,
|
||||
normalized status rendering, and issue/comment/document write durability. The
|
||||
plugin only identifies provider-owned objects and resolves board-safe status
|
||||
metadata.
|
||||
|
||||
```ts
|
||||
objectReferences: [
|
||||
{
|
||||
providerKey: "mocktracker",
|
||||
displayName: "Mock Tracker",
|
||||
objectTypes: ["ticket"],
|
||||
urlPatterns: ["https://mock.example/tickets/:id"],
|
||||
refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 },
|
||||
},
|
||||
],
|
||||
capabilities: ["external.objects.detect", "external.objects.read"],
|
||||
```
|
||||
|
||||
Implement `onDetectExternalObjects()` to map sanitized URL candidates to
|
||||
`providerKey`, `objectType`, provider-stable `externalId`, and optional display
|
||||
metadata such as `displayKey`/`iconKey`. Implement `onResolveExternalObject()`
|
||||
to return a normalized snapshot with `statusCategory`, `statusTone`,
|
||||
`statusLabel`, optional `statusIconKey`, board-safe `data`, and freshness
|
||||
metadata. Slow or failing plugins are isolated: Paperclip logs the failure and
|
||||
continues saving the source issue, comment, or document.
|
||||
|
||||
MVP security posture: provider plugins are trusted installs. Manifest
|
||||
capabilities gate host APIs and provider invocation paths, but they are not a
|
||||
sandbox boundary for untrusted marketplace code. Plugin UI is same-origin
|
||||
JavaScript and must not be mounted inline in markdown; inline external-object
|
||||
rendering uses host-owned metadata only. Treat untrusted providers as future work
|
||||
that requires worker sandboxing plus isolated plugin UI.
|
||||
|
||||
### Restricted Database Namespace
|
||||
|
||||
Trusted orchestration plugins can declare a host-owned PostgreSQL namespace:
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ import type {
|
|||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentValidationResult,
|
||||
DetectExternalObjectsParams,
|
||||
DetectExternalObjectsResult,
|
||||
ResolveExternalObjectParams,
|
||||
PluginExternalObjectResolveResult,
|
||||
RefreshExternalObjectsParams,
|
||||
RefreshExternalObjectsResult,
|
||||
} from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -243,6 +249,39 @@ export interface PluginDefinition {
|
|||
* access, capabilities, and checkout policy.
|
||||
*/
|
||||
onApiRequest?(input: PluginApiRequestInput): Promise<PluginApiResponse>;
|
||||
|
||||
/**
|
||||
* Called when Paperclip scans issue/comment/document content and asks this
|
||||
* plugin whether any sanitized URL candidates belong to its external object
|
||||
* providers. The host has already stripped URL userinfo, query strings, and
|
||||
* fragments unless provider-safe identity components were explicitly hashed.
|
||||
*
|
||||
* Requires `external.objects.detect`.
|
||||
*/
|
||||
onDetectExternalObjects?(
|
||||
params: DetectExternalObjectsParams,
|
||||
): Promise<DetectExternalObjectsResult>;
|
||||
|
||||
/**
|
||||
* Called when Paperclip needs the current normalized status for one external
|
||||
* object owned by a manifest-declared provider.
|
||||
*
|
||||
* Requires `external.objects.read`.
|
||||
*/
|
||||
onResolveExternalObject?(
|
||||
params: ResolveExternalObjectParams,
|
||||
): Promise<PluginExternalObjectResolveResult>;
|
||||
|
||||
/**
|
||||
* Optional batch resolver used by providers that can refresh many objects
|
||||
* more efficiently than individual `onResolveExternalObject` calls.
|
||||
*
|
||||
* Requires `external.objects.refresh`.
|
||||
*/
|
||||
onRefreshExternalObjects?(
|
||||
params: RefreshExternalObjectsParams,
|
||||
): Promise<RefreshExternalObjectsResult>;
|
||||
|
||||
/**
|
||||
* Called to validate provider-specific configuration for a plugin-hosted
|
||||
* environment driver.
|
||||
|
|
|
|||
|
|
@ -155,6 +155,17 @@ export type {
|
|||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
ExecuteToolParams,
|
||||
PluginExternalObjectUrlCandidate,
|
||||
PluginExternalObjectSourceContext,
|
||||
DetectExternalObjectsParams,
|
||||
PluginExternalObjectDetection,
|
||||
DetectExternalObjectsResult,
|
||||
PluginExternalObjectRecordSnapshot,
|
||||
ResolveExternalObjectParams,
|
||||
PluginExternalObjectResolvedSnapshot,
|
||||
PluginExternalObjectResolveResult,
|
||||
RefreshExternalObjectsParams,
|
||||
RefreshExternalObjectsResult,
|
||||
PluginEnvironmentDiagnostic,
|
||||
PluginEnvironmentDriverBaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
|
|
@ -313,6 +324,8 @@ export type {
|
|||
PluginApiRouteDeclaration,
|
||||
PluginLocalFolderDeclaration,
|
||||
PluginCompanySettings,
|
||||
PluginObjectReferenceRefreshPolicy,
|
||||
PluginObjectReferenceProviderDeclaration,
|
||||
PluginRecord,
|
||||
PluginDatabaseNamespaceRecord,
|
||||
PluginMigrationRecord,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ import type {
|
|||
Goal,
|
||||
PluginLocalFolderDeclaration,
|
||||
PrincipalPermissionGrant,
|
||||
ExternalObjectStatusCategory,
|
||||
ExternalObjectStatusTone,
|
||||
ExternalObjectLivenessState,
|
||||
ExternalObjectMentionConfidence,
|
||||
ExternalObjectMentionSourceKind,
|
||||
} from "@paperclipai/shared";
|
||||
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
|
||||
|
||||
|
|
@ -432,6 +437,113 @@ export interface ExecuteToolParams {
|
|||
runContext: ToolRunContext;
|
||||
}
|
||||
|
||||
export interface PluginExternalObjectUrlCandidate {
|
||||
sanitizedCanonicalUrl: string;
|
||||
sanitizedDisplayUrl: string;
|
||||
canonicalIdentityHash: string;
|
||||
canonicalIdentity: Record<string, unknown>;
|
||||
redactedMatchedText: string;
|
||||
}
|
||||
|
||||
export interface PluginExternalObjectSourceContext {
|
||||
companyId: string;
|
||||
sourceIssueId: string;
|
||||
sourceKind: ExternalObjectMentionSourceKind;
|
||||
sourceRecordId: string | null;
|
||||
documentKey: string | null;
|
||||
propertyKey: string | null;
|
||||
}
|
||||
|
||||
export interface DetectExternalObjectsParams {
|
||||
companyId: string;
|
||||
urls: PluginExternalObjectUrlCandidate[];
|
||||
sourceContext: PluginExternalObjectSourceContext;
|
||||
}
|
||||
|
||||
export interface PluginExternalObjectDetection {
|
||||
urlIdentityHash: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
externalId: string;
|
||||
displayKey?: string | null;
|
||||
iconKey?: string | null;
|
||||
displayTitle?: string | null;
|
||||
confidence?: ExternalObjectMentionConfidence;
|
||||
}
|
||||
|
||||
export interface DetectExternalObjectsResult {
|
||||
detections: PluginExternalObjectDetection[];
|
||||
}
|
||||
|
||||
export interface PluginExternalObjectRecordSnapshot {
|
||||
id: string;
|
||||
companyId: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
externalId: string;
|
||||
sanitizedCanonicalUrl: string | null;
|
||||
canonicalIdentityHash: string | null;
|
||||
displayKey: string | null;
|
||||
iconKey: string | null;
|
||||
displayTitle: string | null;
|
||||
statusKey: string | null;
|
||||
statusLabel: string | null;
|
||||
statusIconKey: string | null;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
statusTone: ExternalObjectStatusTone;
|
||||
liveness: ExternalObjectLivenessState;
|
||||
isTerminal: boolean;
|
||||
data: Record<string, unknown>;
|
||||
remoteVersion: string | null;
|
||||
etag: string | null;
|
||||
}
|
||||
|
||||
export interface ResolveExternalObjectParams {
|
||||
companyId: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
externalId: string;
|
||||
object: PluginExternalObjectRecordSnapshot;
|
||||
}
|
||||
|
||||
export interface PluginExternalObjectResolvedSnapshot {
|
||||
displayKey?: string | null;
|
||||
iconKey?: string | null;
|
||||
displayTitle?: string | null;
|
||||
statusKey?: string | null;
|
||||
statusLabel?: string | null;
|
||||
statusIconKey?: string | null;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
statusTone: ExternalObjectStatusTone;
|
||||
isTerminal?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
remoteVersion?: string | null;
|
||||
etag?: string | null;
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export type PluginExternalObjectResolveResult =
|
||||
| { ok: true; snapshot: PluginExternalObjectResolvedSnapshot }
|
||||
| {
|
||||
ok: false;
|
||||
liveness: Extract<ExternalObjectLivenessState, "auth_required" | "unreachable">;
|
||||
errorCode: string;
|
||||
errorMessage?: string | null;
|
||||
retryAfterSeconds?: number;
|
||||
};
|
||||
|
||||
export interface RefreshExternalObjectsParams {
|
||||
companyId: string;
|
||||
objects: PluginExternalObjectRecordSnapshot[];
|
||||
}
|
||||
|
||||
export interface RefreshExternalObjectsResult {
|
||||
results: Array<{
|
||||
objectId: string;
|
||||
result: PluginExternalObjectResolveResult;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentDiagnostic {
|
||||
severity: "info" | "warning" | "error";
|
||||
message: string;
|
||||
|
|
@ -604,6 +716,18 @@ export interface HostToWorkerMethods {
|
|||
performAction: [params: PerformActionParams, result: unknown];
|
||||
/** @see PLUGIN_SPEC.md §13.10 */
|
||||
executeTool: [params: ExecuteToolParams, result: ToolResult];
|
||||
detectExternalObjects: [
|
||||
params: DetectExternalObjectsParams,
|
||||
result: DetectExternalObjectsResult,
|
||||
];
|
||||
resolveExternalObject: [
|
||||
params: ResolveExternalObjectParams,
|
||||
result: PluginExternalObjectResolveResult,
|
||||
];
|
||||
refreshExternalObjects: [
|
||||
params: RefreshExternalObjectsParams,
|
||||
result: RefreshExternalObjectsResult,
|
||||
];
|
||||
environmentValidateConfig: [
|
||||
params: PluginEnvironmentValidateConfigParams,
|
||||
result: PluginEnvironmentValidationResult,
|
||||
|
|
@ -659,6 +783,9 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
|
|||
"getData",
|
||||
"performAction",
|
||||
"executeTool",
|
||||
"detectExternalObjects",
|
||||
"resolveExternalObject",
|
||||
"refreshExternalObjects",
|
||||
"environmentValidateConfig",
|
||||
"environmentProbe",
|
||||
"environmentAcquireLease",
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export type {
|
|||
PluginDatabaseDeclaration,
|
||||
PluginApiRouteDeclaration,
|
||||
PluginApiRouteCompanyResolution,
|
||||
PluginObjectReferenceRefreshPolicy,
|
||||
PluginObjectReferenceProviderDeclaration,
|
||||
PluginRecord,
|
||||
PluginDatabaseNamespaceRecord,
|
||||
PluginMigrationRecord,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ import type {
|
|||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
ExecuteToolParams,
|
||||
DetectExternalObjectsParams,
|
||||
ResolveExternalObjectParams,
|
||||
RefreshExternalObjectsParams,
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
PluginEnvironmentExecuteParams,
|
||||
|
|
@ -1351,6 +1354,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
|
||||
case "executeTool":
|
||||
return handleExecuteTool(params as ExecuteToolParams);
|
||||
case "detectExternalObjects":
|
||||
return handleDetectExternalObjects(params as DetectExternalObjectsParams);
|
||||
case "resolveExternalObject":
|
||||
return handleResolveExternalObject(params as ResolveExternalObjectParams);
|
||||
case "refreshExternalObjects":
|
||||
return handleRefreshExternalObjects(params as RefreshExternalObjectsParams);
|
||||
|
||||
case "environmentValidateConfig":
|
||||
return handleEnvironmentValidateConfig(params as PluginEnvironmentValidateConfigParams);
|
||||
|
|
@ -1409,6 +1418,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
if (plugin.definition.onHealth) supportedMethods.push("health");
|
||||
if (plugin.definition.onShutdown) supportedMethods.push("shutdown");
|
||||
if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest");
|
||||
if (plugin.definition.onDetectExternalObjects) supportedMethods.push("detectExternalObjects");
|
||||
if (plugin.definition.onResolveExternalObject) supportedMethods.push("resolveExternalObject");
|
||||
if (plugin.definition.onRefreshExternalObjects) supportedMethods.push("refreshExternalObjects");
|
||||
if (plugin.definition.onEnvironmentValidateConfig) supportedMethods.push("environmentValidateConfig");
|
||||
if (plugin.definition.onEnvironmentProbe) supportedMethods.push("environmentProbe");
|
||||
if (plugin.definition.onEnvironmentAcquireLease) supportedMethods.push("environmentAcquireLease");
|
||||
|
|
@ -1588,6 +1600,27 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
return entry.fn(params.parameters, params.runContext);
|
||||
}
|
||||
|
||||
async function handleDetectExternalObjects(params: DetectExternalObjectsParams) {
|
||||
if (!plugin.definition.onDetectExternalObjects) {
|
||||
throw methodNotImplemented("detectExternalObjects");
|
||||
}
|
||||
return plugin.definition.onDetectExternalObjects(params);
|
||||
}
|
||||
|
||||
async function handleResolveExternalObject(params: ResolveExternalObjectParams) {
|
||||
if (!plugin.definition.onResolveExternalObject) {
|
||||
throw methodNotImplemented("resolveExternalObject");
|
||||
}
|
||||
return plugin.definition.onResolveExternalObject(params);
|
||||
}
|
||||
|
||||
async function handleRefreshExternalObjects(params: RefreshExternalObjectsParams) {
|
||||
if (!plugin.definition.onRefreshExternalObjects) {
|
||||
throw methodNotImplemented("refreshExternalObjects");
|
||||
}
|
||||
return plugin.definition.onRefreshExternalObjects(params);
|
||||
}
|
||||
|
||||
function methodNotImplemented(method: string): Error & { code: number } {
|
||||
return Object.assign(
|
||||
new Error(`${method} is not implemented by this plugin`),
|
||||
|
|
|
|||
|
|
@ -358,6 +358,53 @@ export const DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES = [
|
|||
export type DocumentAnnotationAnchorConfidence =
|
||||
(typeof DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES)[number];
|
||||
|
||||
export const EXTERNAL_OBJECT_STATUS_CATEGORIES = [
|
||||
"unknown",
|
||||
"open",
|
||||
"waiting",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"blocked",
|
||||
"closed",
|
||||
"archived",
|
||||
"auth_required",
|
||||
"unreachable",
|
||||
] as const;
|
||||
export type ExternalObjectStatusCategory = (typeof EXTERNAL_OBJECT_STATUS_CATEGORIES)[number];
|
||||
|
||||
export const EXTERNAL_OBJECT_STATUS_TONES = [
|
||||
"neutral",
|
||||
"info",
|
||||
"success",
|
||||
"warning",
|
||||
"danger",
|
||||
"muted",
|
||||
] as const;
|
||||
export type ExternalObjectStatusTone = (typeof EXTERNAL_OBJECT_STATUS_TONES)[number];
|
||||
|
||||
export const EXTERNAL_OBJECT_LIVENESS_STATES = [
|
||||
"unknown",
|
||||
"fresh",
|
||||
"stale",
|
||||
"auth_required",
|
||||
"unreachable",
|
||||
] as const;
|
||||
export type ExternalObjectLivenessState = (typeof EXTERNAL_OBJECT_LIVENESS_STATES)[number];
|
||||
|
||||
export const EXTERNAL_OBJECT_MENTION_SOURCE_KINDS = [
|
||||
"title",
|
||||
"description",
|
||||
"comment",
|
||||
"document",
|
||||
"property",
|
||||
"plugin",
|
||||
] as const;
|
||||
export type ExternalObjectMentionSourceKind = (typeof EXTERNAL_OBJECT_MENTION_SOURCE_KINDS)[number];
|
||||
|
||||
export const EXTERNAL_OBJECT_MENTION_CONFIDENCES = ["exact", "likely", "possible"] as const;
|
||||
export type ExternalObjectMentionConfidence = (typeof EXTERNAL_OBJECT_MENTION_CONFIDENCES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
|
||||
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
|
||||
|
||||
|
|
@ -677,6 +724,7 @@ export const LIVE_EVENT_TYPES = [
|
|||
"heartbeat.run.log",
|
||||
"agent.status",
|
||||
"activity.logged",
|
||||
"external_object.updated",
|
||||
"plugin.ui.updated",
|
||||
"plugin.worker.crashed",
|
||||
"plugin.worker.restarted",
|
||||
|
|
@ -851,6 +899,10 @@ export const PLUGIN_CAPABILITIES = [
|
|||
"telemetry.track",
|
||||
"database.namespace.migrate",
|
||||
"database.namespace.write",
|
||||
"external.objects.detect",
|
||||
"external.objects.read",
|
||||
"external.objects.write",
|
||||
"external.objects.refresh",
|
||||
// Plugin State
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { parseIssueReferenceHref } from "./issue-references.js";
|
||||
import type {
|
||||
ExternalObjectCanonicalIdentity,
|
||||
ExternalObjectCanonicalUrl,
|
||||
ExternalObjectMentionSource,
|
||||
ExternalObjectUrlCanonicalizationOptions,
|
||||
ExternalObjectUrlMatch,
|
||||
} from "./external-objects.js";
|
||||
|
||||
const EXTERNAL_URL_TOKEN_RE = /https?:\/\/[^\s<>()]+/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;
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function normalizePathname(pathname: string): string {
|
||||
return pathname || "/";
|
||||
}
|
||||
|
||||
export function findExternalObjectUrlMatches(markdown: string): ExternalObjectUrlMatch[] {
|
||||
if (!markdown) return [];
|
||||
|
||||
const scrubbed = stripMarkdownCode(markdown);
|
||||
const matches: ExternalObjectUrlMatch[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(EXTERNAL_URL_TOKEN_RE);
|
||||
|
||||
while ((match = re.exec(scrubbed)) !== null) {
|
||||
const matchedText = trimTrailingPunctuation(match[0]);
|
||||
if (!matchedText || parseIssueReferenceHref(matchedText)) continue;
|
||||
|
||||
matches.push({
|
||||
index: match.index,
|
||||
length: matchedText.length,
|
||||
matchedText,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function canonicalizeExternalObjectUrl(
|
||||
value: string,
|
||||
options: ExternalObjectUrlCanonicalizationOptions = {},
|
||||
): ExternalObjectCanonicalUrl | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
if (url.username || url.password) return null;
|
||||
|
||||
const scheme = url.protocol === "https:" ? "https" : "http";
|
||||
const path = normalizePathname(url.pathname);
|
||||
const sanitizedCanonicalUrl = `${scheme}://${url.host.toLowerCase()}${path}`;
|
||||
const identityQueryParams = new Set(options.identityQueryParams ?? []);
|
||||
const queryParamHashes: Record<string, string> = {};
|
||||
|
||||
for (const key of [...identityQueryParams].sort()) {
|
||||
const values = url.searchParams.getAll(key);
|
||||
if (values.length === 0) continue;
|
||||
queryParamHashes[key] = sha256Hex(values.join("\u0000"));
|
||||
}
|
||||
|
||||
const canonicalIdentity: ExternalObjectCanonicalIdentity = {
|
||||
scheme,
|
||||
host: url.host.toLowerCase(),
|
||||
path,
|
||||
...(Object.keys(queryParamHashes).length > 0 ? { queryParamHashes } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
sanitizedCanonicalUrl,
|
||||
sanitizedDisplayUrl: sanitizedCanonicalUrl,
|
||||
canonicalIdentity,
|
||||
canonicalIdentityHash: sha256Hex(stableStringify(canonicalIdentity)),
|
||||
redactedMatchedText: sanitizedCanonicalUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractExternalObjectCanonicalUrls(
|
||||
markdown: string,
|
||||
options: ExternalObjectUrlCanonicalizationOptions = {},
|
||||
): ExternalObjectCanonicalUrl[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: ExternalObjectCanonicalUrl[] = [];
|
||||
|
||||
for (const match of findExternalObjectUrlMatches(markdown)) {
|
||||
const canonical = canonicalizeExternalObjectUrl(match.matchedText, options);
|
||||
if (!canonical || seen.has(canonical.canonicalIdentityHash)) continue;
|
||||
seen.add(canonical.canonicalIdentityHash);
|
||||
ordered.push(canonical);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function buildExternalObjectScopedIdentityKey(args: {
|
||||
companyId: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
canonicalIdentityHash: string;
|
||||
}): string {
|
||||
return [args.companyId, args.providerKey, args.objectType, args.canonicalIdentityHash].join(":");
|
||||
}
|
||||
|
||||
export function buildExternalObjectMentionSourceKey(source: Required<Pick<
|
||||
ExternalObjectMentionSource,
|
||||
"companyId" | "sourceIssueId" | "sourceKind"
|
||||
>> & ExternalObjectMentionSource): string {
|
||||
return [
|
||||
source.companyId,
|
||||
source.sourceIssueId,
|
||||
source.sourceKind,
|
||||
source.sourceRecordId ?? "",
|
||||
source.documentKey ?? "",
|
||||
source.propertyKey ?? "",
|
||||
].join(":");
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatExternalObjectMentionSourceLabel } from "./external-objects.js";
|
||||
import {
|
||||
buildExternalObjectMentionSourceKey,
|
||||
buildExternalObjectScopedIdentityKey,
|
||||
canonicalizeExternalObjectUrl,
|
||||
extractExternalObjectCanonicalUrls,
|
||||
findExternalObjectUrlMatches,
|
||||
} from "./external-objects-server.js";
|
||||
import { externalObjectProviderKeySchema, externalObjectTypeSchema } from "./validators/external-object.js";
|
||||
|
||||
describe("external object references", () => {
|
||||
it("extracts external urls without changing internal issue reference behavior", () => {
|
||||
expect(
|
||||
findExternalObjectUrlMatches(
|
||||
"See PAP-1, /issues/PAP-2, https://paperclip.ing/PAP/issues/PAP-3, and https://github.com/acme/app/pull/4.",
|
||||
),
|
||||
).toEqual([{ index: 70, length: 34, matchedText: "https://github.com/acme/app/pull/4" }]);
|
||||
});
|
||||
|
||||
it("ignores urls inside inline and fenced code", () => {
|
||||
const markdown = [
|
||||
"Use https://github.com/acme/app/pull/1 here.",
|
||||
"`https://github.com/acme/app/pull/2` should not count.",
|
||||
"```",
|
||||
"https://github.com/acme/app/pull/3",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
expect(findExternalObjectUrlMatches(markdown).map((match) => match.matchedText)).toEqual([
|
||||
"https://github.com/acme/app/pull/1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("canonicalizes urls by stripping query and fragment by default", () => {
|
||||
expect(canonicalizeExternalObjectUrl("HTTPS://GitHub.com/acme/app/pull/1?token=secret#discussion")).toMatchObject({
|
||||
sanitizedCanonicalUrl: "https://github.com/acme/app/pull/1",
|
||||
sanitizedDisplayUrl: "https://github.com/acme/app/pull/1",
|
||||
redactedMatchedText: "https://github.com/acme/app/pull/1",
|
||||
canonicalIdentity: {
|
||||
scheme: "https",
|
||||
host: "github.com",
|
||||
path: "/acme/app/pull/1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects urls with userinfo", () => {
|
||||
expect(canonicalizeExternalObjectUrl("https://token:secret@github.com/acme/app/pull/1")).toBeNull();
|
||||
});
|
||||
|
||||
it("hashes provider-required query identity values without storing plaintext", () => {
|
||||
const first = canonicalizeExternalObjectUrl("https://deploy.test/run?id=secret-run&token=drop", {
|
||||
identityQueryParams: ["id"],
|
||||
});
|
||||
const second = canonicalizeExternalObjectUrl("https://deploy.test/run?id=secret-run&token=other", {
|
||||
identityQueryParams: ["id"],
|
||||
});
|
||||
|
||||
expect(first?.sanitizedCanonicalUrl).toBe("https://deploy.test/run");
|
||||
expect(first?.canonicalIdentity.queryParamHashes?.id).toHaveLength(64);
|
||||
expect(first?.canonicalIdentity.queryParamHashes?.id).not.toContain("secret-run");
|
||||
expect(second?.canonicalIdentityHash).toBe(first?.canonicalIdentityHash);
|
||||
});
|
||||
|
||||
it("dedupes extracted canonical urls by canonical identity", () => {
|
||||
expect(
|
||||
extractExternalObjectCanonicalUrls(
|
||||
"https://github.com/acme/app/pull/1?token=a and https://github.com/acme/app/pull/1#discussion",
|
||||
).map((entry) => entry.sanitizedCanonicalUrl),
|
||||
).toEqual(["https://github.com/acme/app/pull/1"]);
|
||||
});
|
||||
|
||||
it("includes company id in scoped object identity keys", () => {
|
||||
const base = {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
canonicalIdentityHash: "hash",
|
||||
};
|
||||
|
||||
expect(buildExternalObjectScopedIdentityKey({ companyId: "company-a", ...base })).not.toBe(
|
||||
buildExternalObjectScopedIdentityKey({ companyId: "company-b", ...base }),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds source keys for replacing mentions from the same source", () => {
|
||||
const oldMentionSource = buildExternalObjectMentionSourceKey({
|
||||
companyId: "company-a",
|
||||
sourceIssueId: "issue-1",
|
||||
sourceKind: "comment",
|
||||
sourceRecordId: "comment-1",
|
||||
});
|
||||
const newMentionSource = buildExternalObjectMentionSourceKey({
|
||||
companyId: "company-a",
|
||||
sourceIssueId: "issue-1",
|
||||
sourceKind: "comment",
|
||||
sourceRecordId: "comment-1",
|
||||
});
|
||||
const anotherCompanySource = buildExternalObjectMentionSourceKey({
|
||||
companyId: "company-b",
|
||||
sourceIssueId: "issue-1",
|
||||
sourceKind: "comment",
|
||||
sourceRecordId: "comment-1",
|
||||
});
|
||||
|
||||
expect(newMentionSource).toBe(oldMentionSource);
|
||||
expect(anotherCompanySource).not.toBe(oldMentionSource);
|
||||
});
|
||||
|
||||
it("formats stable source labels", () => {
|
||||
expect(formatExternalObjectMentionSourceLabel({ sourceKind: "title" })).toBe("Title");
|
||||
expect(formatExternalObjectMentionSourceLabel({ sourceKind: "document", documentKey: "plan" })).toBe(
|
||||
"Document: plan",
|
||||
);
|
||||
expect(formatExternalObjectMentionSourceLabel({ sourceKind: "property", propertyKey: "pr" })).toBe(
|
||||
"Property: pr",
|
||||
);
|
||||
});
|
||||
|
||||
it("validates provider keys and object types", () => {
|
||||
expect(externalObjectProviderKeySchema.parse("github.enterprise")).toBe("github.enterprise");
|
||||
expect(externalObjectTypeSchema.parse("pull_request")).toBe("pull_request");
|
||||
expect(externalObjectProviderKeySchema.safeParse("GitHub").success).toBe(false);
|
||||
expect(externalObjectTypeSchema.safeParse("pull-request").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { ExternalObjectMentionSourceKind } from "./constants.js";
|
||||
|
||||
export interface ExternalObjectUrlMatch {
|
||||
index: number;
|
||||
length: number;
|
||||
matchedText: string;
|
||||
}
|
||||
|
||||
export interface ExternalObjectCanonicalIdentity {
|
||||
scheme: "http" | "https";
|
||||
host: string;
|
||||
path: string;
|
||||
queryParamHashes?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ExternalObjectUrlCanonicalizationOptions {
|
||||
identityQueryParams?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ExternalObjectCanonicalUrl {
|
||||
sanitizedCanonicalUrl: string;
|
||||
sanitizedDisplayUrl: string;
|
||||
canonicalIdentity: ExternalObjectCanonicalIdentity;
|
||||
canonicalIdentityHash: string;
|
||||
redactedMatchedText: string;
|
||||
}
|
||||
|
||||
export interface ExternalObjectMentionSource {
|
||||
companyId?: string;
|
||||
sourceIssueId?: string;
|
||||
sourceKind: ExternalObjectMentionSourceKind;
|
||||
sourceRecordId?: string | null;
|
||||
documentKey?: string | null;
|
||||
propertyKey?: string | null;
|
||||
}
|
||||
|
||||
export function formatExternalObjectMentionSourceLabel(source: ExternalObjectMentionSource): string {
|
||||
switch (source.sourceKind) {
|
||||
case "title":
|
||||
return "Title";
|
||||
case "description":
|
||||
return "Description";
|
||||
case "comment":
|
||||
return "Comment";
|
||||
case "document":
|
||||
return source.documentKey ? `Document: ${source.documentKey}` : "Document";
|
||||
case "property":
|
||||
return source.propertyKey ? `Property: ${source.propertyKey}` : "Property";
|
||||
case "plugin":
|
||||
return "Plugin";
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,11 @@ export {
|
|||
DOCUMENT_ANNOTATION_THREAD_STATUSES,
|
||||
DOCUMENT_ANNOTATION_ANCHOR_STATES,
|
||||
DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES,
|
||||
EXTERNAL_OBJECT_STATUS_CATEGORIES,
|
||||
EXTERNAL_OBJECT_STATUS_TONES,
|
||||
EXTERNAL_OBJECT_LIVENESS_STATES,
|
||||
EXTERNAL_OBJECT_MENTION_SOURCE_KINDS,
|
||||
EXTERNAL_OBJECT_MENTION_CONFIDENCES,
|
||||
ISSUE_EXECUTION_POLICY_MODES,
|
||||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
ISSUE_MONITOR_SCHEDULED_BY,
|
||||
|
|
@ -217,6 +222,11 @@ export {
|
|||
type DocumentAnnotationThreadStatus,
|
||||
type DocumentAnnotationAnchorState,
|
||||
type DocumentAnnotationAnchorConfidence,
|
||||
type ExternalObjectStatusCategory,
|
||||
type ExternalObjectStatusTone,
|
||||
type ExternalObjectLivenessState,
|
||||
type ExternalObjectMentionSourceKind,
|
||||
type ExternalObjectMentionConfidence,
|
||||
type IssueExecutionPolicyMode,
|
||||
type IssueExecutionStageType,
|
||||
type IssueMonitorScheduledBy,
|
||||
|
|
@ -522,6 +532,11 @@ export type {
|
|||
DocumentTextRange,
|
||||
UpdateDocumentAnnotationThreadRequest,
|
||||
AttachmentArtifactWorkProductMetadata,
|
||||
ExternalObject,
|
||||
ExternalObjectMention,
|
||||
ExternalObjectMentionGroup,
|
||||
ExternalObjectSummary,
|
||||
ExternalObjectSummaryItem,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueBlockerAttention,
|
||||
|
|
@ -786,6 +801,8 @@ export type {
|
|||
PluginDatabaseDeclaration,
|
||||
PluginApiRouteCompanyResolution,
|
||||
PluginApiRouteDeclaration,
|
||||
PluginObjectReferenceRefreshPolicy,
|
||||
PluginObjectReferenceProviderDeclaration,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginDatabaseNamespaceRecord,
|
||||
|
|
@ -829,6 +846,15 @@ export {
|
|||
type VerifyDocumentAnchorSelectorResult,
|
||||
} from "./document-anchors.js";
|
||||
|
||||
export {
|
||||
formatExternalObjectMentionSourceLabel,
|
||||
type ExternalObjectCanonicalIdentity,
|
||||
type ExternalObjectCanonicalUrl,
|
||||
type ExternalObjectMentionSource,
|
||||
type ExternalObjectUrlCanonicalizationOptions,
|
||||
type ExternalObjectUrlMatch,
|
||||
} from "./external-objects.js";
|
||||
|
||||
export {
|
||||
sidebarOrderPreferenceSchema,
|
||||
upsertSidebarOrderPreferenceSchema,
|
||||
|
|
@ -924,11 +950,24 @@ export {
|
|||
feedbackTargetTypeSchema,
|
||||
feedbackTraceStatusSchema,
|
||||
feedbackVoteValueSchema,
|
||||
externalObjectStatusCategorySchema,
|
||||
externalObjectStatusToneSchema,
|
||||
externalObjectLivenessStateSchema,
|
||||
externalObjectMentionSourceKindSchema,
|
||||
externalObjectMentionConfidenceSchema,
|
||||
externalObjectProviderKeySchema,
|
||||
externalObjectTypeSchema,
|
||||
externalObjectCanonicalIdentitySchema,
|
||||
externalObjectMentionSourceSchema,
|
||||
upsertIssueFeedbackVoteSchema,
|
||||
type CreateCompany,
|
||||
type UpdateCompany,
|
||||
type UpdateCompanyBranding,
|
||||
type UpsertIssueFeedbackVote,
|
||||
type ExternalObjectCanonicalIdentityInput,
|
||||
type ExternalObjectMentionSourceInput,
|
||||
type ExternalObjectProviderKeyInput,
|
||||
type ExternalObjectTypeInput,
|
||||
environmentDriverSchema,
|
||||
environmentStatusSchema,
|
||||
environmentLeaseStatusSchema,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import type {
|
||||
ExternalObjectLivenessState,
|
||||
ExternalObjectMentionConfidence,
|
||||
ExternalObjectMentionSourceKind,
|
||||
ExternalObjectStatusCategory,
|
||||
ExternalObjectStatusTone,
|
||||
} from "../constants.js";
|
||||
|
||||
export interface ExternalObject {
|
||||
id: string;
|
||||
companyId: string;
|
||||
providerKey: string;
|
||||
pluginId: string | null;
|
||||
objectType: string;
|
||||
externalId: string;
|
||||
sanitizedCanonicalUrl: string | null;
|
||||
canonicalIdentityHash: string | null;
|
||||
displayKey?: string | null;
|
||||
iconKey?: string | null;
|
||||
displayTitle: string | null;
|
||||
statusKey: string | null;
|
||||
statusLabel: string | null;
|
||||
statusIconKey?: string | null;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
statusTone: ExternalObjectStatusTone;
|
||||
liveness: ExternalObjectLivenessState;
|
||||
isTerminal: boolean;
|
||||
data: Record<string, unknown>;
|
||||
remoteVersion: string | null;
|
||||
etag: string | null;
|
||||
lastResolvedAt: string | null;
|
||||
lastChangedAt: string | null;
|
||||
lastErrorAt: string | null;
|
||||
nextRefreshAt: string | null;
|
||||
lastErrorCode: string | null;
|
||||
lastErrorMessage: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ExternalObjectMention {
|
||||
id: string;
|
||||
companyId: string;
|
||||
sourceIssueId: string;
|
||||
sourceKind: ExternalObjectMentionSourceKind;
|
||||
sourceRecordId: string | null;
|
||||
documentKey: string | null;
|
||||
propertyKey: string | null;
|
||||
matchedTextRedacted: string | null;
|
||||
sanitizedDisplayUrl: string | null;
|
||||
canonicalIdentityHash: string | null;
|
||||
canonicalIdentity: Record<string, unknown> | null;
|
||||
objectId: string | null;
|
||||
providerKey: string | null;
|
||||
detectorKey: string | null;
|
||||
objectType: string | null;
|
||||
confidence: ExternalObjectMentionConfidence;
|
||||
createdByPluginId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ExternalObjectMentionGroup {
|
||||
object: ExternalObject | null;
|
||||
mentions: ExternalObjectMention[];
|
||||
mentionCount: number;
|
||||
sourceLabels: string[];
|
||||
}
|
||||
|
||||
export interface ExternalObjectSummaryItem {
|
||||
id: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
displayKey?: string | null;
|
||||
iconKey?: string | null;
|
||||
displayTitle: string | null;
|
||||
statusIconKey?: string | null;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
statusTone: ExternalObjectStatusTone;
|
||||
liveness: ExternalObjectLivenessState;
|
||||
isTerminal: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalObjectSummary {
|
||||
total: number;
|
||||
byStatusCategory: Record<string, number>;
|
||||
byLiveness: Record<string, number>;
|
||||
highestSeverity: ExternalObjectStatusTone;
|
||||
staleCount: number;
|
||||
authRequiredCount: number;
|
||||
unreachableCount: number;
|
||||
objects: ExternalObjectSummaryItem[];
|
||||
}
|
||||
|
|
@ -265,6 +265,14 @@ export type {
|
|||
CompanyArtifactSource,
|
||||
CompanyArtifactsResponse,
|
||||
} from "./artifact.js";
|
||||
|
||||
export type {
|
||||
ExternalObject,
|
||||
ExternalObjectMention,
|
||||
ExternalObjectMentionGroup,
|
||||
ExternalObjectSummary,
|
||||
ExternalObjectSummaryItem,
|
||||
} from "./external-object.js";
|
||||
export type {
|
||||
Issue,
|
||||
IssueWorkMode,
|
||||
|
|
@ -551,6 +559,8 @@ export type {
|
|||
PluginDatabaseDeclaration,
|
||||
PluginApiRouteCompanyResolution,
|
||||
PluginApiRouteDeclaration,
|
||||
PluginObjectReferenceRefreshPolicy,
|
||||
PluginObjectReferenceProviderDeclaration,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginDatabaseNamespaceRecord,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableIssuePlanDecompositions: boolean;
|
||||
enableExperimentalFileViewer: boolean;
|
||||
enableCloudSync: boolean;
|
||||
enableExternalObjects: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
enableIssueGraphLivenessAutoRecovery: boolean;
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: number;
|
||||
|
|
|
|||
|
|
@ -504,6 +504,31 @@ export interface PluginApiRouteDeclaration {
|
|||
companyResolution?: PluginApiRouteCompanyResolution;
|
||||
}
|
||||
|
||||
export interface PluginObjectReferenceRefreshPolicy {
|
||||
/** Default freshness window for resolved objects from this provider. */
|
||||
defaultTtlSeconds?: number;
|
||||
/** UI-visible staleness window. Core still stores liveness separately from remote status. */
|
||||
staleAfterSeconds?: number;
|
||||
}
|
||||
|
||||
export interface PluginObjectReferenceProviderDeclaration {
|
||||
/** Stable provider key such as "github", "linear", or "mocktracker". */
|
||||
providerKey: string;
|
||||
/** Human-readable provider name shown in operator-facing surfaces. */
|
||||
displayName: string;
|
||||
/** Provider object types this plugin can detect and resolve. */
|
||||
objectTypes: string[];
|
||||
/**
|
||||
* Human-readable URL patterns this provider recognizes.
|
||||
* These are metadata for operators and docs; workers still perform detection.
|
||||
*/
|
||||
urlPatterns?: string[];
|
||||
/** Optional default refresh behavior for this provider. */
|
||||
refreshPolicy?: PluginObjectReferenceRefreshPolicy;
|
||||
/** Optional webhook endpoint keys declared under `webhooks` that can refresh these objects. */
|
||||
webhookEndpointKeys?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Manifest V1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -570,6 +595,8 @@ export interface PaperclipPluginManifestV1 {
|
|||
skills?: PluginManagedSkillDeclaration[];
|
||||
/** Trusted local folders this plugin can configure and access by stable key. */
|
||||
localFolders?: PluginLocalFolderDeclaration[];
|
||||
/** External object reference providers this plugin contributes. */
|
||||
objectReferences?: PluginObjectReferenceProviderDeclaration[];
|
||||
/**
|
||||
* Legacy top-level launcher declarations.
|
||||
* Prefer `ui.launchers` for new manifests.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
EXTERNAL_OBJECT_LIVENESS_STATES,
|
||||
EXTERNAL_OBJECT_MENTION_CONFIDENCES,
|
||||
EXTERNAL_OBJECT_MENTION_SOURCE_KINDS,
|
||||
EXTERNAL_OBJECT_STATUS_CATEGORIES,
|
||||
EXTERNAL_OBJECT_STATUS_TONES,
|
||||
} from "../constants.js";
|
||||
|
||||
export const externalObjectStatusCategorySchema = z.enum(EXTERNAL_OBJECT_STATUS_CATEGORIES);
|
||||
export const externalObjectStatusToneSchema = z.enum(EXTERNAL_OBJECT_STATUS_TONES);
|
||||
export const externalObjectLivenessStateSchema = z.enum(EXTERNAL_OBJECT_LIVENESS_STATES);
|
||||
export const externalObjectMentionSourceKindSchema = z.enum(EXTERNAL_OBJECT_MENTION_SOURCE_KINDS);
|
||||
export const externalObjectMentionConfidenceSchema = z.enum(EXTERNAL_OBJECT_MENTION_CONFIDENCES);
|
||||
export const externalObjectProviderKeySchema = z.string().trim().min(1).max(80).regex(/^[a-z][a-z0-9_.-]*$/);
|
||||
export const externalObjectTypeSchema = z.string().trim().min(1).max(80).regex(/^[a-z][a-z0-9_]*$/);
|
||||
|
||||
export const externalObjectCanonicalIdentitySchema = z
|
||||
.object({
|
||||
scheme: z.enum(["http", "https"]),
|
||||
host: z.string().trim().min(1),
|
||||
path: z.string().trim().min(1),
|
||||
queryParamHashes: z.record(z.string().regex(/^[a-f0-9]{64}$/)).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const externalObjectMentionSourceSchema = z
|
||||
.object({
|
||||
sourceKind: externalObjectMentionSourceKindSchema,
|
||||
documentKey: z.string().trim().min(1).optional().nullable(),
|
||||
propertyKey: z.string().trim().min(1).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ExternalObjectCanonicalIdentityInput = z.infer<typeof externalObjectCanonicalIdentitySchema>;
|
||||
export type ExternalObjectMentionSourceInput = z.infer<typeof externalObjectMentionSourceSchema>;
|
||||
export type ExternalObjectProviderKeyInput = z.infer<typeof externalObjectProviderKeySchema>;
|
||||
export type ExternalObjectTypeInput = z.infer<typeof externalObjectTypeSchema>;
|
||||
|
|
@ -59,6 +59,22 @@ export {
|
|||
updateResourceMembershipSchema,
|
||||
type UpdateResourceMembership,
|
||||
} from "./resource-memberships.js";
|
||||
|
||||
export {
|
||||
externalObjectStatusCategorySchema,
|
||||
externalObjectStatusToneSchema,
|
||||
externalObjectLivenessStateSchema,
|
||||
externalObjectMentionSourceKindSchema,
|
||||
externalObjectMentionConfidenceSchema,
|
||||
externalObjectProviderKeySchema,
|
||||
externalObjectTypeSchema,
|
||||
externalObjectCanonicalIdentitySchema,
|
||||
externalObjectMentionSourceSchema,
|
||||
type ExternalObjectCanonicalIdentityInput,
|
||||
type ExternalObjectMentionSourceInput,
|
||||
type ExternalObjectProviderKeyInput,
|
||||
type ExternalObjectTypeInput,
|
||||
} from "./external-object.js";
|
||||
export {
|
||||
companySkillSourceTypeSchema,
|
||||
companySkillTrustLevelSchema,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enableIssuePlanDecompositions: z.boolean().default(false),
|
||||
enableExperimentalFileViewer: z.boolean().default(false),
|
||||
enableCloudSync: z.boolean().default(false),
|
||||
enableExternalObjects: z.boolean().default(false),
|
||||
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
||||
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: z
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
ISSUE_SURFACE_VISIBILITIES,
|
||||
} from "../constants.js";
|
||||
import { routineVariableSchema } from "./routine.js";
|
||||
import { externalObjectProviderKeySchema, externalObjectTypeSchema } from "./external-object.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema placeholder – a permissive validator for JSON Schema objects
|
||||
|
|
@ -568,6 +569,55 @@ export const pluginApiRouteDeclarationSchema = z.object({
|
|||
|
||||
export type PluginApiRouteDeclarationInput = z.infer<typeof pluginApiRouteDeclarationSchema>;
|
||||
|
||||
export const pluginObjectReferenceRefreshPolicySchema = z.object({
|
||||
defaultTtlSeconds: z.number().int().positive().max(86_400).optional(),
|
||||
staleAfterSeconds: z.number().int().positive().max(604_800).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (
|
||||
value.defaultTtlSeconds != null &&
|
||||
value.staleAfterSeconds != null &&
|
||||
value.staleAfterSeconds < value.defaultTtlSeconds
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "staleAfterSeconds must be greater than or equal to defaultTtlSeconds",
|
||||
path: ["staleAfterSeconds"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const pluginObjectReferenceProviderDeclarationSchema = z.object({
|
||||
providerKey: externalObjectProviderKeySchema,
|
||||
displayName: z.string().min(1).max(100),
|
||||
objectTypes: z.array(externalObjectTypeSchema).min(1),
|
||||
urlPatterns: z.array(z.string().trim().min(1).max(500)).optional(),
|
||||
refreshPolicy: pluginObjectReferenceRefreshPolicySchema.optional(),
|
||||
webhookEndpointKeys: z.array(z.string().min(1)).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
const duplicateObjectTypes = value.objectTypes.filter((type, i) => value.objectTypes.indexOf(type) !== i);
|
||||
if (duplicateObjectTypes.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate objectTypes: ${[...new Set(duplicateObjectTypes)].join(", ")}`,
|
||||
path: ["objectTypes"],
|
||||
});
|
||||
}
|
||||
|
||||
const webhookKeys = value.webhookEndpointKeys ?? [];
|
||||
const duplicateWebhookKeys = webhookKeys.filter((key, i) => webhookKeys.indexOf(key) !== i);
|
||||
if (duplicateWebhookKeys.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate webhookEndpointKeys: ${[...new Set(duplicateWebhookKeys)].join(", ")}`,
|
||||
path: ["webhookEndpointKeys"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginObjectReferenceProviderDeclarationInput = z.infer<
|
||||
typeof pluginObjectReferenceProviderDeclarationSchema
|
||||
>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Manifest V1 schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -647,6 +697,7 @@ export const pluginManifestV1Schema = z.object({
|
|||
routines: z.array(pluginManagedRoutineDeclarationSchema).optional(),
|
||||
skills: z.array(pluginManagedSkillDeclarationSchema).optional(),
|
||||
localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(),
|
||||
objectReferences: z.array(pluginObjectReferenceProviderDeclarationSchema).optional(),
|
||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||
ui: z.object({
|
||||
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
|
||||
|
|
@ -787,6 +838,31 @@ export const pluginManifestV1Schema = z.object({
|
|||
}
|
||||
}
|
||||
|
||||
if (manifest.objectReferences && manifest.objectReferences.length > 0) {
|
||||
for (const capability of ["external.objects.detect", "external.objects.read"] as const) {
|
||||
if (!manifest.capabilities.includes(capability)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Capability '${capability}' is required when objectReferences are declared`,
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const declaredWebhookKeys = new Set((manifest.webhooks ?? []).map((webhook) => webhook.endpointKey));
|
||||
for (const [providerIndex, provider] of manifest.objectReferences.entries()) {
|
||||
for (const endpointKey of provider.webhookEndpointKeys ?? []) {
|
||||
if (!declaredWebhookKeys.has(endpointKey)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `objectReferences webhookEndpointKey "${endpointKey}" must match a declared webhook endpoint`,
|
||||
path: ["objectReferences", providerIndex, "webhookEndpointKeys"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.database) {
|
||||
const requiredCapabilities = [
|
||||
"database.namespace.migrate",
|
||||
|
|
@ -950,6 +1026,18 @@ export const pluginManifestV1Schema = z.object({
|
|||
}
|
||||
}
|
||||
|
||||
if (manifest.objectReferences) {
|
||||
const providerKeys = manifest.objectReferences.map((provider) => provider.providerKey);
|
||||
const duplicateProviders = providerKeys.filter((key, i) => providerKeys.indexOf(key) !== i);
|
||||
if (duplicateProviders.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate object reference provider keys: ${[...new Set(duplicateProviders)].join(", ")}`,
|
||||
path: ["objectReferences"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// UI slot ids must be unique within the plugin (namespaced at runtime)
|
||||
if (manifest.ui) {
|
||||
if (manifest.ui.slots) {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,84 @@ paperclipai_command_available() {
|
|||
return 1
|
||||
}
|
||||
|
||||
existing_worktree_config_is_usable() {
|
||||
WORKTREE_CONFIG_PATH="$worktree_config_path" \
|
||||
WORKTREE_ENV_PATH="$worktree_env_path" \
|
||||
node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
function expandHomePrefix(value) {
|
||||
if (!value) return value;
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEnvFile(contents) {
|
||||
const entries = {};
|
||||
for (const rawLine of contents.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||
if (!match) continue;
|
||||
const [, key, rawValue] = match;
|
||||
const value = rawValue.trim();
|
||||
if (
|
||||
(value.startsWith("\"") && value.endsWith("\"")) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
entries[key] = value.slice(1, -1);
|
||||
continue;
|
||||
}
|
||||
entries[key] = value.replace(/\s+#.*$/, "").trim();
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function fail(reason) {
|
||||
console.error(reason);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configPath = path.resolve(process.env.WORKTREE_CONFIG_PATH);
|
||||
const envPath = path.resolve(process.env.WORKTREE_ENV_PATH);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const env = parseEnvFile(fs.readFileSync(envPath, "utf8"));
|
||||
const envConfigPath = expandHomePrefix(env.PAPERCLIP_CONFIG);
|
||||
if (envConfigPath && path.resolve(envConfigPath) !== configPath) {
|
||||
fail(`existing worktree env points at ${envConfigPath}, not ${configPath}`);
|
||||
}
|
||||
|
||||
const homeDir = expandHomePrefix(env.PAPERCLIP_HOME);
|
||||
const instanceId = env.PAPERCLIP_INSTANCE_ID;
|
||||
if (!homeDir || !instanceId) {
|
||||
fail("existing worktree env is missing PAPERCLIP_HOME or PAPERCLIP_INSTANCE_ID");
|
||||
}
|
||||
if (!fs.existsSync(homeDir)) {
|
||||
fail(`existing worktree home does not exist on this host: ${homeDir}`);
|
||||
}
|
||||
|
||||
const instanceRoot = path.resolve(homeDir, "instances", instanceId);
|
||||
const runtimePaths = [
|
||||
config.database?.embeddedPostgresDataDir,
|
||||
config.database?.backup?.dir,
|
||||
config.logging?.logDir,
|
||||
config.storage?.localDisk?.baseDir,
|
||||
config.secrets?.localEncrypted?.keyFilePath,
|
||||
].filter((value) => typeof value === "string" && value.length > 0);
|
||||
|
||||
for (const rawValue of runtimePaths) {
|
||||
const resolved = path.resolve(expandHomePrefix(rawValue));
|
||||
const relative = path.relative(instanceRoot, resolved);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
fail(`existing worktree config path is outside ${instanceRoot}: ${resolved}`);
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
write_fallback_worktree_config() {
|
||||
WORKTREE_NAME="$worktree_name" \
|
||||
BASE_CWD="$base_cwd" \
|
||||
|
|
@ -332,9 +410,12 @@ main().catch((error) => {
|
|||
EOF
|
||||
}
|
||||
|
||||
if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]]; then
|
||||
if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]] && existing_worktree_config_is_usable; then
|
||||
echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2
|
||||
else
|
||||
if [[ -e "$worktree_config_path" || -e "$worktree_env_path" ]]; then
|
||||
echo "Existing isolated Paperclip worktree config is stale for this host; regenerating." >&2
|
||||
fi
|
||||
if paperclipai_command_available; then
|
||||
run_isolated_worktree_init
|
||||
else
|
||||
|
|
|
|||
|
|
@ -180,5 +180,5 @@ describeLiveSsh("live SSH environment smoke", () => {
|
|||
expect(result.stdout).toContain(config.remoteWorkspacePath);
|
||||
expect(result.stdout).toContain("git");
|
||||
expect(result.stdout).toContain("tar");
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,252 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const issueId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
const ownerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const peerAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const ownerRunId = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExternalObjectsService = vi.hoisted(() => ({
|
||||
getIssueSummary: vi.fn(),
|
||||
getIssueSummaries: vi.fn(),
|
||||
listForIssue: vi.fn(),
|
||||
refreshIssueObjects: vi.fn(),
|
||||
}));
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerRouteMocks() {
|
||||
vi.doMock("../services/external-objects.js", () => ({
|
||||
externalObjectService: () => mockExternalObjectsService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/instance-settings.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/task-watchdog-scope.js", () => ({
|
||||
TASK_WATCHDOG_ORIGIN_KIND: "task_watchdog",
|
||||
resolveTaskWatchdogMutationScope: vi.fn(async () => ({ kind: "none" })),
|
||||
taskWatchdogScopeAllowsIssueMutation: vi.fn(async () => ({ kind: "none" })),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
companySearchService: () => ({}),
|
||||
documentAnnotationService: () => ({}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueRecoveryActionService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
issueThreadInteractionService: () => ({}),
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
}
|
||||
|
||||
function makeIssue(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: issueId,
|
||||
companyId,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
assigneeUserId: null,
|
||||
identifier: "PAP-2265",
|
||||
title: "External object routes",
|
||||
executionWorkspaceId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function createApp(actor: Express.Request["actor"]) {
|
||||
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, { provider: "local_disk" } as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function boardActor(): Express.Request["actor"] {
|
||||
return {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
userName: null,
|
||||
userEmail: null,
|
||||
companyIds: [companyId],
|
||||
memberships: [],
|
||||
isInstanceAdmin: false,
|
||||
source: "local_implicit",
|
||||
};
|
||||
}
|
||||
|
||||
function ownerActor(): Express.Request["actor"] {
|
||||
return {
|
||||
type: "agent",
|
||||
agentId: ownerAgentId,
|
||||
companyId,
|
||||
keyId: "key-1",
|
||||
runId: ownerRunId,
|
||||
source: "agent_key",
|
||||
};
|
||||
}
|
||||
|
||||
function peerActor(): Express.Request["actor"] {
|
||||
return {
|
||||
type: "agent",
|
||||
agentId: peerAgentId,
|
||||
companyId,
|
||||
keyId: "key-2",
|
||||
runId: "66666666-6666-4666-8666-666666666666",
|
||||
source: "agent_key",
|
||||
};
|
||||
}
|
||||
|
||||
describe("external object routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../services/external-objects.js");
|
||||
registerRouteMocks();
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue());
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({
|
||||
allowed: action === "issue:mutate",
|
||||
explanation: "Denied by test mock",
|
||||
}));
|
||||
mockAgentService.list.mockResolvedValue([
|
||||
{ id: ownerAgentId, companyId, reportsTo: null, permissions: { canCreateAgents: false } },
|
||||
{ id: peerAgentId, companyId, reportsTo: null, permissions: { canCreateAgents: false } },
|
||||
]);
|
||||
mockExternalObjectsService.getIssueSummary.mockResolvedValue({ total: 1, objects: [] });
|
||||
mockExternalObjectsService.getIssueSummaries.mockResolvedValue(new Map([
|
||||
[issueId, { total: 1, objects: [] }],
|
||||
]));
|
||||
mockExternalObjectsService.listForIssue.mockResolvedValue([]);
|
||||
mockExternalObjectsService.refreshIssueObjects.mockResolvedValue([
|
||||
{ object: { id: "77777777-7777-4777-8777-777777777777" }, refreshed: false, reason: "no_resolver" },
|
||||
]);
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
||||
enableExternalObjects: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces company access on read routes", async () => {
|
||||
const app = await createApp({ ...ownerActor(), companyId: "other-company" });
|
||||
|
||||
const res = await request(app).get(`/api/issues/${issueId}/external-object-summary`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockExternalObjectsService.getIssueSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows board users to read issue external object summaries", async () => {
|
||||
const app = await createApp(boardActor());
|
||||
|
||||
const res = await request(app).get(`/api/issues/${issueId}/external-object-summary`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.total).toBe(1);
|
||||
expect(mockExternalObjectsService.getIssueSummary).toHaveBeenCalledWith(issueId);
|
||||
});
|
||||
|
||||
it("allows board users to fetch company-scoped external object summaries in bulk", async () => {
|
||||
const app = await createApp(boardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/issues/external-object-summaries`)
|
||||
.send({ issueIds: [issueId] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.summaries[issueId].total).toBe(1);
|
||||
expect(mockExternalObjectsService.getIssueSummaries).toHaveBeenCalledWith(companyId, [issueId]);
|
||||
});
|
||||
|
||||
it("enforces company access on bulk external object summaries", async () => {
|
||||
const app = await createApp({ ...ownerActor(), companyId: "other-company" });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/issues/external-object-summaries`)
|
||||
.send({ issueIds: [issueId] });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockExternalObjectsService.getIssueSummaries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires active checkout ownership for agent manual refresh", async () => {
|
||||
const app = await createApp(peerActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/issues/${issueId}/external-objects/refresh`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toBe("Issue is checked out by another agent");
|
||||
expect(mockExternalObjectsService.refreshIssueObjects).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows the checked-out agent to request manual refresh", async () => {
|
||||
const app = await createApp(ownerActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/issues/${issueId}/external-objects/refresh`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId);
|
||||
expect(mockExternalObjectsService.refreshIssueObjects).toHaveBeenCalledWith(issueId, expect.objectContaining({
|
||||
companyId,
|
||||
actor: expect.objectContaining({ actorType: "agent", actorId: ownerAgentId }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,705 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
companies,
|
||||
createDb,
|
||||
activityLog,
|
||||
externalObjectMentions,
|
||||
externalObjects,
|
||||
issueComments,
|
||||
issues,
|
||||
plugins,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
createExternalObjectDetectorRegistry,
|
||||
createExternalObjectResolverRegistry,
|
||||
externalObjectService,
|
||||
type ExternalObjectResolver,
|
||||
} from "../services/external-objects.js";
|
||||
import { canonicalizeExternalObjectUrl } from "@paperclipai/shared/external-objects-server";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import { createGitHubExternalObjectProvider } from "../services/github-external-object-provider.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres external object tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("external object registries", () => {
|
||||
it("lets provider detectors claim urls before the generic fallback", async () => {
|
||||
const canonical = canonicalizeExternalObjectUrl("https://github.com/acme/app/pull/42");
|
||||
if (!canonical) throw new Error("expected canonical url");
|
||||
const registry = createExternalObjectDetectorRegistry([
|
||||
{
|
||||
key: "github",
|
||||
detect: ({ urls }) =>
|
||||
urls.map((url) => ({
|
||||
canonical: url,
|
||||
detectorKey: "github",
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
externalId: "acme/app#42",
|
||||
confidence: "exact",
|
||||
})),
|
||||
},
|
||||
]);
|
||||
|
||||
const detections = await registry.detect({
|
||||
companyId: "company-1",
|
||||
urls: [canonical],
|
||||
sourceContext: {
|
||||
companyId: "company-1",
|
||||
sourceIssueId: "issue-1",
|
||||
sourceKind: "description",
|
||||
sourceRecordId: null,
|
||||
documentKey: null,
|
||||
propertyKey: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(detections).toHaveLength(1);
|
||||
expect(detections[0]).toMatchObject({
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
externalId: "acme/app#42",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to generic url objects when no provider detector claims a url", async () => {
|
||||
const canonical = canonicalizeExternalObjectUrl("https://example.com/path?token=secret#frag");
|
||||
if (!canonical) throw new Error("expected canonical url");
|
||||
const registry = createExternalObjectDetectorRegistry([]);
|
||||
|
||||
const detections = await registry.detect({
|
||||
companyId: "company-1",
|
||||
urls: [canonical],
|
||||
sourceContext: {
|
||||
companyId: "company-1",
|
||||
sourceIssueId: "issue-1",
|
||||
sourceKind: "description",
|
||||
sourceRecordId: null,
|
||||
documentKey: null,
|
||||
propertyKey: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(detections[0]).toMatchObject({
|
||||
providerKey: "url",
|
||||
objectType: "link",
|
||||
externalId: canonical.canonicalIdentityHash,
|
||||
displayTitle: "https://example.com/path",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches resolvers by provider and optional object type", () => {
|
||||
const fallbackResolver: ExternalObjectResolver = {
|
||||
providerKey: "github",
|
||||
resolve: async () => ({
|
||||
ok: true,
|
||||
snapshot: { statusCategory: "unknown", statusTone: "neutral" },
|
||||
}),
|
||||
};
|
||||
const pullRequestResolver: ExternalObjectResolver = {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
resolve: async () => ({
|
||||
ok: true,
|
||||
snapshot: { statusCategory: "open", statusTone: "info" },
|
||||
}),
|
||||
};
|
||||
const registry = createExternalObjectResolverRegistry([pullRequestResolver, fallbackResolver]);
|
||||
|
||||
expect(registry.find({ providerKey: "github", objectType: "pull_request" })).toBe(pullRequestResolver);
|
||||
expect(registry.find({ providerKey: "github", objectType: "issue" })).toBe(fallbackResolver);
|
||||
expect(registry.find({ providerKey: "linear", objectType: "issue" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub external object provider", () => {
|
||||
function githubObject(path: string, objectType: "pull_request" | "issue") {
|
||||
const canonical = canonicalizeExternalObjectUrl(`https://github.com/acme/app/${path}`);
|
||||
if (!canonical) throw new Error("expected canonical url");
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: "company-1",
|
||||
providerKey: "github",
|
||||
objectType,
|
||||
externalId: `acme/app#${path}`,
|
||||
sanitizedCanonicalUrl: canonical.sanitizedCanonicalUrl,
|
||||
canonicalIdentityHash: canonical.canonicalIdentityHash,
|
||||
displayTitle: "acme/app#42",
|
||||
statusKey: null,
|
||||
statusLabel: null,
|
||||
statusCategory: "unknown",
|
||||
statusTone: "neutral",
|
||||
liveness: "unknown",
|
||||
isTerminal: false,
|
||||
data: {},
|
||||
remoteVersion: null,
|
||||
etag: null,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function response(body: Record<string, unknown>, init: ResponseInit = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json", etag: '"etag-1"', ...(init.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
it("detects GitHub pull request and issue URLs before the generic fallback", async () => {
|
||||
const provider = createGitHubExternalObjectProvider({} as any, { tokenProvider: null });
|
||||
const pr = canonicalizeExternalObjectUrl("https://github.com/Acme/App/pull/42?token=secret#discussion");
|
||||
const issue = canonicalizeExternalObjectUrl("https://github.com/Acme/App/issues/7");
|
||||
const other = canonicalizeExternalObjectUrl("https://example.com/Acme/App/pull/42");
|
||||
if (!pr || !issue || !other) throw new Error("expected canonical urls");
|
||||
|
||||
const detections = await provider.detector.detect({
|
||||
companyId: "company-1",
|
||||
urls: [pr, issue, other],
|
||||
sourceContext: {
|
||||
companyId: "company-1",
|
||||
sourceIssueId: "issue-1",
|
||||
sourceKind: "description",
|
||||
sourceRecordId: null,
|
||||
documentKey: null,
|
||||
propertyKey: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(detections).toEqual([
|
||||
expect.objectContaining({
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
externalId: "acme/app#pull/42",
|
||||
displayKey: "GitHub Pull Request",
|
||||
iconKey: "github",
|
||||
displayTitle: "Acme/App#42",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
providerKey: "github",
|
||||
objectType: "issue",
|
||||
externalId: "acme/app#issues/7",
|
||||
displayKey: "GitHub Issue",
|
||||
iconKey: "github",
|
||||
displayTitle: "Acme/App#7",
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(detections)).not.toContain("secret");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"open",
|
||||
{ state: "open", draft: false, merged: false, title: "Ship it", updated_at: "2026-04-24T01:02:03Z" },
|
||||
{ statusKey: "open", statusLabel: "Open", statusIconKey: "git-pull-request", statusCategory: "open", statusTone: "info", isTerminal: false },
|
||||
],
|
||||
[
|
||||
"draft",
|
||||
{ state: "open", draft: true, merged: false, title: "WIP", updated_at: "2026-04-24T01:02:03Z" },
|
||||
{ statusKey: "draft", statusLabel: "Draft", statusIconKey: "clock", statusCategory: "waiting", statusTone: "warning", isTerminal: false },
|
||||
],
|
||||
[
|
||||
"closed",
|
||||
{ state: "closed", draft: false, merged: false, title: "Closed PR", updated_at: "2026-04-24T01:02:03Z" },
|
||||
{ statusKey: "closed", statusLabel: "Closed", statusIconKey: "x-circle", statusCategory: "closed", statusTone: "muted", isTerminal: true },
|
||||
],
|
||||
[
|
||||
"merged",
|
||||
{ state: "closed", draft: false, merged: true, title: "Merged PR", updated_at: "2026-04-24T01:02:03Z" },
|
||||
{ statusKey: "merged", statusLabel: "Merged", statusIconKey: "git-merge", statusCategory: "succeeded", statusTone: "success", isTerminal: true },
|
||||
],
|
||||
])("resolves a %s pull request snapshot", async (_name, body, expected) => {
|
||||
const fetch = vi.fn(async () => response(body));
|
||||
const provider = createGitHubExternalObjectProvider({} as any, { fetch, tokenProvider: null });
|
||||
const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!;
|
||||
|
||||
const result = await resolver.resolve({
|
||||
companyId: "company-1",
|
||||
object: githubObject("pull/42", "pull_request"),
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://api.github.com/repos/acme/app/pulls/42",
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({ authorization: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
snapshot: expect.objectContaining({
|
||||
...expected,
|
||||
displayKey: "GitHub Pull Request",
|
||||
iconKey: "github",
|
||||
displayTitle: expect.stringContaining(String(body.title)),
|
||||
remoteVersion: "2026-04-24T01:02:03Z",
|
||||
etag: '"etag-1"',
|
||||
data: expect.objectContaining({
|
||||
provider: "github",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
number: 42,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("authorization");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"open",
|
||||
{ state: "open", title: "Issue", state_reason: null, updated_at: "2026-04-24T01:02:03Z" },
|
||||
{ statusKey: "open", statusLabel: "Open", statusIconKey: "circle-dot", statusCategory: "open", statusTone: "info", isTerminal: false },
|
||||
],
|
||||
[
|
||||
"closed",
|
||||
{ state: "closed", title: "Issue", state_reason: "completed", updated_at: "2026-04-24T01:02:03Z" },
|
||||
{ statusKey: "closed_completed", statusLabel: "Closed: completed", statusIconKey: "circle", statusCategory: "closed", statusTone: "muted", isTerminal: true },
|
||||
],
|
||||
])("resolves a %s issue snapshot", async (_name, body, expected) => {
|
||||
const fetch = vi.fn(async () => response(body));
|
||||
const provider = createGitHubExternalObjectProvider({} as any, { fetch, tokenProvider: null });
|
||||
const resolver = provider.resolvers.find((entry) => entry.objectType === "issue")!;
|
||||
|
||||
const result = await resolver.resolve({
|
||||
companyId: "company-1",
|
||||
object: githubObject("issues/42", "issue"),
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("https://api.github.com/repos/acme/app/issues/42", expect.any(Object));
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
snapshot: expect.objectContaining({
|
||||
...expected,
|
||||
displayKey: "GitHub Issue",
|
||||
iconKey: "github",
|
||||
data: expect.objectContaining({
|
||||
provider: "github",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
number: 42,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a configured token without storing it in the resolved snapshot", async () => {
|
||||
const fetch = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(init?.headers).toEqual(expect.objectContaining({ authorization: "Bearer ghp_secret" }));
|
||||
return response({ state: "open", draft: false, merged: false, title: "Private PR" });
|
||||
});
|
||||
const provider = createGitHubExternalObjectProvider({} as any, {
|
||||
fetch,
|
||||
tokenProvider: async () => "ghp_secret",
|
||||
});
|
||||
const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!;
|
||||
|
||||
const result = await resolver.resolve({
|
||||
companyId: "company-1",
|
||||
object: githubObject("pull/42", "pull_request"),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(JSON.stringify(result)).not.toContain("ghp_secret");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"auth-required",
|
||||
new Response("", { status: 401 }),
|
||||
{ ok: false, liveness: "auth_required", errorCode: "github_auth_required" },
|
||||
],
|
||||
[
|
||||
"rate-limit",
|
||||
new Response("", {
|
||||
status: 403,
|
||||
headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 120) },
|
||||
}),
|
||||
{ ok: false, liveness: "unreachable", errorCode: "github_rate_limited" },
|
||||
],
|
||||
[
|
||||
"not-found",
|
||||
new Response("", { status: 404, headers: { etag: '"missing"' } }),
|
||||
{ ok: true, snapshot: expect.objectContaining({ displayKey: "GitHub Pull Request", iconKey: "github", statusKey: "not_found", statusIconKey: "archive", statusCategory: "archived", statusTone: "muted" }) },
|
||||
],
|
||||
])("maps %s responses to provider-safe results", async (_name, githubResponse, expected) => {
|
||||
const provider = createGitHubExternalObjectProvider({} as any, {
|
||||
fetch: async () => githubResponse,
|
||||
tokenProvider: null,
|
||||
});
|
||||
const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!;
|
||||
|
||||
const result = await resolver.resolve({
|
||||
companyId: "company-1",
|
||||
object: githubObject("pull/42", "pull_request"),
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining(expected));
|
||||
expect(JSON.stringify(result)).not.toContain("http");
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("externalObjectService", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-external-objects-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(externalObjectMentions);
|
||||
await db.delete(externalObjects);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function createIssue(companyId = randomUUID()) {
|
||||
const issueId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `E${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: `PAP-${companyId.replace(/-/g, "").slice(0, 12).toUpperCase()}`,
|
||||
title: "External refs",
|
||||
description: "Track https://github.com/acme/app/pull/42?token=secret#discussion twice https://github.com/acme/app/pull/42.",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
return { companyId, issueId };
|
||||
}
|
||||
|
||||
it("syncs sanitized, deduped mentions without storing secret-bearing urls", async () => {
|
||||
const { companyId, issueId } = await createIssue();
|
||||
const svc = externalObjectService(db);
|
||||
|
||||
await svc.syncIssue(issueId);
|
||||
|
||||
const [objectRows, mentionRows] = await Promise.all([
|
||||
db.select().from(externalObjects),
|
||||
db.select().from(externalObjectMentions),
|
||||
]);
|
||||
expect(objectRows).toHaveLength(1);
|
||||
expect(objectRows[0]).toMatchObject({
|
||||
companyId,
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
externalId: "acme/app#pull/42",
|
||||
sanitizedCanonicalUrl: "https://github.com/acme/app/pull/42",
|
||||
liveness: "unknown",
|
||||
statusCategory: "unknown",
|
||||
});
|
||||
expect(JSON.stringify(objectRows[0])).not.toContain("secret");
|
||||
expect(mentionRows).toHaveLength(1);
|
||||
expect(mentionRows[0]).toMatchObject({
|
||||
companyId,
|
||||
sourceIssueId: issueId,
|
||||
sourceKind: "description",
|
||||
sanitizedDisplayUrl: "https://github.com/acme/app/pull/42",
|
||||
matchedTextRedacted: "https://github.com/acme/app/pull/42",
|
||||
});
|
||||
});
|
||||
|
||||
it("no-ops detection and summaries when external objects are disabled", async () => {
|
||||
const { issueId } = await createIssue();
|
||||
const svc = externalObjectService(db, { enabled: false });
|
||||
|
||||
await svc.syncIssue(issueId);
|
||||
|
||||
const [objectRows, mentionRows, summary] = await Promise.all([
|
||||
db.select().from(externalObjects),
|
||||
db.select().from(externalObjectMentions),
|
||||
svc.getIssueSummary(issueId),
|
||||
]);
|
||||
expect(objectRows).toHaveLength(0);
|
||||
expect(mentionRows).toHaveLength(0);
|
||||
expect(summary).toMatchObject({
|
||||
total: 0,
|
||||
byStatusCategory: {},
|
||||
byLiveness: {},
|
||||
highestSeverity: "neutral",
|
||||
objects: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves last-known status when resolver reports auth and unreachable failures", async () => {
|
||||
const { companyId, issueId } = await createIssue();
|
||||
const resolver: ExternalObjectResolver = {
|
||||
providerKey: "url",
|
||||
objectType: "link",
|
||||
resolve: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
snapshot: {
|
||||
statusCategory: "open",
|
||||
statusTone: "info",
|
||||
statusKey: "open",
|
||||
statusLabel: "Open",
|
||||
ttlSeconds: 1,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
liveness: "auth_required",
|
||||
errorCode: "auth_required",
|
||||
errorMessage: "token=secret failed for https://github.com/acme/app/pull/42?token=secret",
|
||||
retryAfterSeconds: 60,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
liveness: "unreachable",
|
||||
errorCode: "network",
|
||||
errorMessage: "GET https://github.com/acme/app/pull/42 failed",
|
||||
retryAfterSeconds: 60,
|
||||
}),
|
||||
};
|
||||
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
|
||||
await svc.syncIssue(issueId);
|
||||
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
|
||||
await svc.refreshObject(object.id, { companyId, force: true });
|
||||
await svc.refreshObject(object.id, { companyId, force: true });
|
||||
|
||||
const authFailure = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
expect(authFailure.lastErrorMessage).toContain("token=[redacted]");
|
||||
expect(authFailure.lastErrorMessage).not.toContain("secret");
|
||||
|
||||
await svc.refreshObject(object.id, { companyId, force: true });
|
||||
|
||||
const updated = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
expect(updated.statusCategory).toBe("open");
|
||||
expect(updated.statusLabel).toBe("Open");
|
||||
expect(updated.liveness).toBe("unreachable");
|
||||
expect(updated.lastErrorMessage).toContain("[redacted-url]");
|
||||
expect(updated.lastErrorMessage).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("schedules newly detected objects for automatic refresh", async () => {
|
||||
const { companyId, issueId } = await createIssue();
|
||||
const resolve = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
snapshot: {
|
||||
statusCategory: "open" as const,
|
||||
statusTone: "info" as const,
|
||||
statusKey: "open",
|
||||
statusLabel: "Open",
|
||||
ttlSeconds: 300,
|
||||
},
|
||||
}));
|
||||
const resolver: ExternalObjectResolver = {
|
||||
providerKey: "url",
|
||||
objectType: "link",
|
||||
resolve,
|
||||
};
|
||||
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
|
||||
await svc.syncIssue(issueId);
|
||||
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
|
||||
expect(object.nextRefreshAt).toBeInstanceOf(Date);
|
||||
|
||||
const refreshed = await svc.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000));
|
||||
|
||||
expect(refreshed).toHaveLength(1);
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("removes comment mentions when a synced comment is hard-deleted", async () => {
|
||||
const { companyId, issueId } = await createIssue();
|
||||
const commentId = randomUUID();
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId,
|
||||
companyId,
|
||||
issueId,
|
||||
authorType: "user",
|
||||
authorUserId: "local-board",
|
||||
body: "See https://github.com/acme/app/issues/88",
|
||||
});
|
||||
const svc = externalObjectService(db, { github: false });
|
||||
|
||||
await svc.syncComment(commentId);
|
||||
expect(await db.select().from(externalObjectMentions)).toHaveLength(1);
|
||||
|
||||
await db.delete(issueComments).where(eq(issueComments.id, commentId));
|
||||
await svc.syncComment(commentId);
|
||||
|
||||
expect(await db.select().from(externalObjectMentions)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips terminal objects when refreshing due objects", async () => {
|
||||
const { companyId, issueId } = await createIssue();
|
||||
const resolve = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
snapshot: {
|
||||
statusCategory: "closed" as const,
|
||||
statusTone: "muted" as const,
|
||||
statusKey: "closed",
|
||||
statusLabel: "Closed",
|
||||
isTerminal: true,
|
||||
ttlSeconds: 1,
|
||||
},
|
||||
}));
|
||||
const resolver: ExternalObjectResolver = {
|
||||
providerKey: "url",
|
||||
objectType: "link",
|
||||
resolve,
|
||||
};
|
||||
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
|
||||
await svc.syncIssue(issueId);
|
||||
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
|
||||
await svc.refreshObject(object.id, { companyId, force: true });
|
||||
await db
|
||||
.update(externalObjects)
|
||||
.set({ nextRefreshAt: new Date(0) })
|
||||
.where(eq(externalObjects.id, object.id));
|
||||
|
||||
const refreshed = await svc.refreshDueObjects(companyId);
|
||||
|
||||
expect(refreshed).toEqual([]);
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps external object identities company-scoped for duplicate urls", async () => {
|
||||
const first = await createIssue();
|
||||
const second = await createIssue();
|
||||
const svc = externalObjectService(db);
|
||||
|
||||
await svc.syncIssue(first.issueId);
|
||||
await svc.syncIssue(second.issueId);
|
||||
|
||||
const objectRows = await db.select().from(externalObjects);
|
||||
expect(objectRows).toHaveLength(2);
|
||||
expect(new Set(objectRows.map((row) => row.companyId))).toEqual(new Set([first.companyId, second.companyId]));
|
||||
expect(new Set(objectRows.map((row) => row.canonicalIdentityHash)).size).toBe(1);
|
||||
});
|
||||
|
||||
it("uses a mock plugin provider to detect and resolve non-GitHub objects", async () => {
|
||||
const { companyId, issueId } = await createIssue();
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
description: "Track https://mock.example/tickets/123?secret=drop",
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclip.mock-object-provider",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Mock Object Provider",
|
||||
description: "Detects mock tracker tickets",
|
||||
author: "Paperclip",
|
||||
categories: ["connector"],
|
||||
capabilities: ["external.objects.detect", "external.objects.read"],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
objectReferences: [
|
||||
{
|
||||
providerKey: "mocktracker",
|
||||
displayName: "Mock Tracker",
|
||||
objectTypes: ["ticket"],
|
||||
urlPatterns: ["https://mock.example/tickets/:id"],
|
||||
},
|
||||
],
|
||||
};
|
||||
const [plugin] = await db.insert(plugins).values({
|
||||
pluginKey: manifest.id,
|
||||
packageName: "@paperclip/mock-object-provider",
|
||||
version: manifest.version,
|
||||
apiVersion: 1,
|
||||
categories: manifest.categories,
|
||||
manifestJson: manifest,
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
}).returning();
|
||||
|
||||
const workerManager = {
|
||||
call: vi.fn(async (pluginId: string, method: string, params: any) => {
|
||||
expect(pluginId).toBe(plugin!.id);
|
||||
if (method === "detectExternalObjects") {
|
||||
return {
|
||||
detections: params.urls.map((url: any) => ({
|
||||
urlIdentityHash: url.canonicalIdentityHash,
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
displayKey: "Mock Ticket",
|
||||
iconKey: "circle-dot",
|
||||
displayTitle: "Mock ticket 123",
|
||||
confidence: "exact",
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (method === "resolveExternalObject") {
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: {
|
||||
displayKey: "Mock Ticket",
|
||||
iconKey: "circle-dot",
|
||||
displayTitle: `Resolved ${params.externalId}`,
|
||||
statusKey: "ready",
|
||||
statusLabel: "Ready",
|
||||
statusIconKey: "check-circle",
|
||||
statusCategory: "succeeded",
|
||||
statusTone: "success",
|
||||
ttlSeconds: 300,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as PluginWorkerManager;
|
||||
|
||||
const svc = externalObjectService(db, { pluginWorkerManager: workerManager });
|
||||
await svc.syncIssue(issueId);
|
||||
|
||||
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
expect(object).toMatchObject({
|
||||
companyId,
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
displayKey: "Mock Ticket",
|
||||
iconKey: "circle-dot",
|
||||
pluginId: plugin!.id,
|
||||
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
|
||||
});
|
||||
expect(JSON.stringify(object)).not.toContain("secret");
|
||||
|
||||
const refreshed = await svc.refreshObject(object.id, { companyId, force: true });
|
||||
expect(refreshed.object).toMatchObject({
|
||||
displayKey: "Mock Ticket",
|
||||
iconKey: "circle-dot",
|
||||
displayTitle: "Resolved MOCK-123",
|
||||
statusIconKey: "check-circle",
|
||||
statusCategory: "succeeded",
|
||||
statusTone: "success",
|
||||
liveness: "fresh",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -22,13 +22,25 @@ import {
|
|||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { feedbackService } from "../services/feedback.ts";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres feedback service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function closeDbClient(db: ReturnType<typeof createDb> | undefined) {
|
||||
await db?.$client?.end?.({ timeout: 0 });
|
||||
}
|
||||
|
||||
describe("feedbackService.saveIssueVote", () => {
|
||||
describeEmbeddedPostgres("feedbackService.saveIssueVote", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof feedbackService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,19 @@ import {
|
|||
import { runningProcesses } from "../adapters/index.js";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY } from "../services/recovery/index.ts";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres heartbeat comment wake batching tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs = 10_000, intervalMs = 50) {
|
||||
const startedAt = Date.now();
|
||||
|
|
@ -147,7 +159,7 @@ async function createControlledGatewayServer() {
|
|||
};
|
||||
}
|
||||
|
||||
describe("heartbeat comment wake batching", () => {
|
||||
describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ describe("instance settings routes", () => {
|
|||
enableExperimentalFileViewer: false,
|
||||
enableTaskWatchdogs: false,
|
||||
enableCloudSync: false,
|
||||
enableExternalObjects: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
|
|
@ -142,6 +143,7 @@ describe("instance settings routes", () => {
|
|||
enableExperimentalFileViewer: true,
|
||||
enableTaskWatchdogs: true,
|
||||
enableCloudSync: true,
|
||||
enableExternalObjects: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
|
|
@ -194,6 +196,7 @@ describe("instance settings routes", () => {
|
|||
enableExperimentalFileViewer: false,
|
||||
enableTaskWatchdogs: false,
|
||||
enableCloudSync: false,
|
||||
enableExternalObjects: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
|
|
@ -271,6 +274,24 @@ describe("instance settings routes", () => {
|
|||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows local board users to update external object detection", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableExternalObjects: true })
|
||||
.expect(200);
|
||||
|
||||
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
||||
enableExternalObjects: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local board users to update issue graph liveness auto-recovery", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ describe("instance settings service", () => {
|
|||
enableIsolatedWorkspaces: true,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
enableConferenceRoomChat: false,
|
||||
enableExternalObjects: false,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: true,
|
||||
enableTaskWatchdogs: true,
|
||||
|
|
|
|||
|
|
@ -104,6 +104,34 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
|||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
const mockExternalObjectService = vi.hoisted(() => ({
|
||||
getIssueSummaries: vi.fn(async () => new Map()),
|
||||
getIssueSummary: vi.fn(async () => ({
|
||||
authRequiredCount: 0,
|
||||
byLiveness: {},
|
||||
byStatusCategory: {},
|
||||
highestSeverity: "muted",
|
||||
objects: [],
|
||||
staleCount: 0,
|
||||
total: 0,
|
||||
unreachableCount: 0,
|
||||
})),
|
||||
getProjectSummary: vi.fn(async () => ({
|
||||
authRequiredCount: 0,
|
||||
byLiveness: {},
|
||||
byStatusCategory: {},
|
||||
highestSeverity: "muted",
|
||||
objects: [],
|
||||
staleCount: 0,
|
||||
total: 0,
|
||||
unreachableCount: 0,
|
||||
})),
|
||||
listForIssue: vi.fn(async () => []),
|
||||
refreshIssueObjects: vi.fn(async () => []),
|
||||
syncCommentSafely: vi.fn(async () => undefined),
|
||||
syncDocumentSafely: vi.fn(async () => undefined),
|
||||
syncIssueSafely: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function registerRouteMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
|
|
@ -136,6 +164,10 @@ function registerRouteMocks() {
|
|||
workProductService: () => mockWorkProductService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/external-objects.js", () => ({
|
||||
externalObjectService: () => mockExternalObjectService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/activity-log.js", () => ({
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
|
@ -329,6 +361,7 @@ describe("agent issue mutation checkout ownership", () => {
|
|||
vi.doUnmock("../services/activity-log.js");
|
||||
vi.doUnmock("../services/agents.js");
|
||||
vi.doUnmock("../services/documents.js");
|
||||
vi.doUnmock("../services/external-objects.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../services/issues.js");
|
||||
vi.doUnmock("../services/work-products.js");
|
||||
|
|
@ -464,6 +497,14 @@ describe("agent issue mutation checkout ownership", () => {
|
|||
mockIssueService.findMentionedAgents.mockReset();
|
||||
mockDocumentService.upsertIssueDocument.mockReset();
|
||||
mockWorkProductService.createForIssue.mockReset();
|
||||
mockExternalObjectService.getIssueSummaries.mockClear();
|
||||
mockExternalObjectService.getIssueSummary.mockClear();
|
||||
mockExternalObjectService.getProjectSummary.mockClear();
|
||||
mockExternalObjectService.listForIssue.mockClear();
|
||||
mockExternalObjectService.refreshIssueObjects.mockClear();
|
||||
mockExternalObjectService.syncCommentSafely.mockClear();
|
||||
mockExternalObjectService.syncDocumentSafely.mockClear();
|
||||
mockExternalObjectService.syncIssueSafely.mockClear();
|
||||
mockWorkProductService.getById.mockReset();
|
||||
mockWorkProductService.remove.mockReset();
|
||||
mockWorkProductService.update.mockReset();
|
||||
|
|
|
|||
|
|
@ -57,6 +57,24 @@ const mockIssueReferenceService = vi.hoisted(() => ({
|
|||
syncDocument: vi.fn(async () => undefined),
|
||||
syncIssue: vi.fn(async () => undefined),
|
||||
}));
|
||||
const mockExternalObjectService = vi.hoisted(() => ({
|
||||
getIssueSummaries: vi.fn(async () => ({ summaries: {} })),
|
||||
getIssueSummary: vi.fn(async () => ({
|
||||
authRequiredCount: 0,
|
||||
byLiveness: {},
|
||||
byStatusCategory: {},
|
||||
highestSeverity: "muted",
|
||||
objects: [],
|
||||
staleCount: 0,
|
||||
total: 0,
|
||||
unreachableCount: 0,
|
||||
})),
|
||||
listForIssue: vi.fn(async () => []),
|
||||
refreshIssueObjects: vi.fn(async () => []),
|
||||
syncCommentSafely: vi.fn(async () => undefined),
|
||||
syncDocumentSafely: vi.fn(async () => undefined),
|
||||
syncIssueSafely: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
|
|
@ -92,6 +110,10 @@ function registerModuleMocks() {
|
|||
issueService: () => mockIssueService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/external-objects.js", () => ({
|
||||
externalObjectService: () => mockExternalObjectService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
|
|
@ -181,6 +203,7 @@ describe.sequential("issue comment cancel routes", () => {
|
|||
vi.doUnmock("../telemetry.js");
|
||||
vi.doUnmock("../services/access.js");
|
||||
vi.doUnmock("../services/activity-log.js");
|
||||
vi.doUnmock("../services/external-objects.js");
|
||||
vi.doUnmock("../services/feedback.js");
|
||||
vi.doUnmock("../services/heartbeat.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
|
|
@ -240,6 +263,7 @@ describe.sequential("issue comment cancel routes", () => {
|
|||
});
|
||||
mockIssueReferenceService.deleteCommentSource.mockResolvedValue(undefined);
|
||||
mockIssueReferenceService.syncComment.mockResolvedValue(undefined);
|
||||
mockExternalObjectService.syncCommentSafely.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("cancels a queued comment from its author and restores the deleted body", async () => {
|
||||
|
|
@ -352,6 +376,7 @@ describe.sequential("issue comment cancel routes", () => {
|
|||
expect.objectContaining({ afterTombstone: expect.any(Function) }),
|
||||
);
|
||||
expect(mockIssueReferenceService.syncComment).toHaveBeenCalledWith("comment-1", "tx");
|
||||
expect(mockExternalObjectService.syncCommentSafely).toHaveBeenCalledWith("comment-1", "tx");
|
||||
expect(mockDocumentAnnotationService.cleanupForIssueCommentDeletion).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"comment-1",
|
||||
|
|
@ -362,6 +387,7 @@ describe.sequential("issue comment cancel routes", () => {
|
|||
"tx",
|
||||
);
|
||||
expect(mockIssueReferenceService.deleteCommentSource).toHaveBeenCalledWith("annotation-comment-1", "tx");
|
||||
expect(mockExternalObjectService.syncCommentSafely).toHaveBeenCalledWith("annotation-comment-1", "tx");
|
||||
const deletedActivity = mockLogActivity.mock.calls.find((call) => call[1]?.action === "issue.comment_deleted")?.[1];
|
||||
expect(deletedActivity).toEqual(expect.objectContaining({
|
||||
action: "issue.comment_deleted",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ const mockIssueRecoveryActionService = vi.hoisted(() => ({
|
|||
const mockIssueTreeControlService = vi.hoisted(() => ({
|
||||
getActivePauseHoldGate: vi.fn(async () => null),
|
||||
}));
|
||||
const mockExternalObjectService = vi.hoisted(() => ({
|
||||
syncCommentSafely: vi.fn(async () => undefined),
|
||||
syncIssueSafely: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: vi.fn(),
|
||||
|
|
@ -158,6 +162,10 @@ vi.mock("../services/index.js", () => ({
|
|||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../services/external-objects.js", () => ({
|
||||
externalObjectService: () => mockExternalObjectService,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
|
@ -253,6 +261,8 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
mockRoutineService.syncRunStatusForIssue.mockReset();
|
||||
mockIssueRecoveryActionService.getActiveForIssue.mockReset();
|
||||
mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
|
||||
mockExternalObjectService.syncCommentSafely.mockReset();
|
||||
mockExternalObjectService.syncIssueSafely.mockReset();
|
||||
mockTxInsertValues.mockReset();
|
||||
mockTxInsert.mockReset();
|
||||
mockDbSelect.mockReset();
|
||||
|
|
@ -276,6 +286,8 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
mockHeartbeatService.getRun.mockResolvedValue(null);
|
||||
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue(null);
|
||||
mockExternalObjectService.syncCommentSafely.mockResolvedValue(undefined);
|
||||
mockExternalObjectService.syncIssueSafely.mockResolvedValue(undefined);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
|
||||
mockFeedbackService.saveIssueVote.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -159,6 +159,203 @@ describe("plugin environment driver seam", () => {
|
|||
});
|
||||
});
|
||||
|
||||
const objectReferenceManifest: PaperclipPluginManifestV1 = {
|
||||
id: "test.external-object-provider",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "External Object Provider",
|
||||
description: "Test external object provider plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["connector"],
|
||||
capabilities: ["external.objects.detect", "external.objects.read"],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
objectReferences: [
|
||||
{
|
||||
providerKey: "mocktracker",
|
||||
displayName: "Mock Tracker",
|
||||
objectTypes: ["ticket"],
|
||||
urlPatterns: ["https://mock.example/tickets/:id"],
|
||||
refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("plugin external object provider seam", () => {
|
||||
it("validates provider manifest declarations and capabilities", () => {
|
||||
expect(pluginManifestV1Schema.safeParse(objectReferenceManifest).success).toBe(true);
|
||||
|
||||
const missingCapability = pluginManifestV1Schema.safeParse({
|
||||
...objectReferenceManifest,
|
||||
capabilities: ["external.objects.detect"],
|
||||
});
|
||||
expect(missingCapability.success).toBe(false);
|
||||
expect(JSON.stringify(missingCapability.error?.issues)).toContain("external.objects.read");
|
||||
|
||||
const duplicateProvider = pluginManifestV1Schema.safeParse({
|
||||
...objectReferenceManifest,
|
||||
objectReferences: [
|
||||
objectReferenceManifest.objectReferences![0],
|
||||
{ ...objectReferenceManifest.objectReferences![0], displayName: "Duplicate" },
|
||||
],
|
||||
});
|
||||
expect(duplicateProvider.success).toBe(false);
|
||||
expect(JSON.stringify(duplicateProvider.error?.issues)).toContain(
|
||||
"Duplicate object reference provider keys",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces provider capability requirements", () => {
|
||||
const validator = pluginCapabilityValidator();
|
||||
expect(validator.getRequiredCapabilities("external.objects.detect")).toEqual([
|
||||
"external.objects.detect",
|
||||
]);
|
||||
expect(validator.getRequiredCapabilities("external.objects.read")).toEqual([
|
||||
"external.objects.read",
|
||||
]);
|
||||
expect(validator.checkOperation(objectReferenceManifest, "external.objects.read").allowed).toBe(true);
|
||||
|
||||
const withoutCapability = {
|
||||
...objectReferenceManifest,
|
||||
capabilities: ["external.objects.detect"],
|
||||
} satisfies PaperclipPluginManifestV1;
|
||||
|
||||
expect(validator.checkOperation(withoutCapability, "external.objects.read")).toMatchObject({
|
||||
allowed: false,
|
||||
missing: ["external.objects.read"],
|
||||
});
|
||||
expect(validator.validateManifestCapabilities(withoutCapability)).toMatchObject({
|
||||
allowed: false,
|
||||
missing: ["external.objects.read"],
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches provider detection and resolution worker hooks", async () => {
|
||||
const plugin = definePlugin({
|
||||
async setup() {},
|
||||
async onDetectExternalObjects(params) {
|
||||
return {
|
||||
detections: params.urls.map((url) => ({
|
||||
urlIdentityHash: url.canonicalIdentityHash,
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
displayTitle: "Mock ticket",
|
||||
confidence: "exact",
|
||||
})),
|
||||
};
|
||||
},
|
||||
async onResolveExternalObject(params) {
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: {
|
||||
displayTitle: `Resolved ${params.externalId}`,
|
||||
statusKey: "ready",
|
||||
statusLabel: "Ready",
|
||||
statusCategory: "succeeded",
|
||||
statusTone: "success",
|
||||
isTerminal: true,
|
||||
ttlSeconds: 600,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const stdin = new PassThrough();
|
||||
const stdout = new PassThrough();
|
||||
const host = startWorkerRpcHost({ plugin, stdin, stdout });
|
||||
const responses: unknown[] = [];
|
||||
stdout.on("data", (chunk) => {
|
||||
const lines = String(chunk).split("\n").filter(Boolean);
|
||||
for (const line of lines) {
|
||||
responses.push(parseMessage(line));
|
||||
}
|
||||
});
|
||||
|
||||
stdin.write(serializeMessage(createRequest("initialize", {
|
||||
manifest: objectReferenceManifest,
|
||||
config: {},
|
||||
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
||||
apiVersion: 1,
|
||||
}, 1)));
|
||||
await waitForResponses(responses, 1);
|
||||
|
||||
const initializeResponse = responses[0];
|
||||
expect(isJsonRpcSuccessResponse(initializeResponse)).toBe(true);
|
||||
if (!isJsonRpcSuccessResponse(initializeResponse)) return;
|
||||
expect(initializeResponse.result.supportedMethods).toContain("detectExternalObjects");
|
||||
expect(initializeResponse.result.supportedMethods).toContain("resolveExternalObject");
|
||||
|
||||
stdin.write(serializeMessage(createRequest("detectExternalObjects", {
|
||||
companyId: "company-1",
|
||||
urls: [{
|
||||
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
|
||||
sanitizedDisplayUrl: "https://mock.example/tickets/123",
|
||||
canonicalIdentityHash: "hash-123",
|
||||
canonicalIdentity: { scheme: "https", host: "mock.example", path: "/tickets/123" },
|
||||
redactedMatchedText: "https://mock.example/tickets/123",
|
||||
}],
|
||||
sourceContext: {
|
||||
companyId: "company-1",
|
||||
sourceIssueId: "issue-1",
|
||||
sourceKind: "description",
|
||||
sourceRecordId: null,
|
||||
documentKey: null,
|
||||
propertyKey: null,
|
||||
},
|
||||
}, 2)));
|
||||
await waitForResponses(responses, 2);
|
||||
|
||||
const detectResponse = responses[1];
|
||||
expect(isJsonRpcSuccessResponse(detectResponse)).toBe(true);
|
||||
if (!isJsonRpcSuccessResponse(detectResponse)) return;
|
||||
expect(detectResponse.result.detections[0]).toMatchObject({
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
});
|
||||
|
||||
stdin.write(serializeMessage(createRequest("resolveExternalObject", {
|
||||
companyId: "company-1",
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
object: {
|
||||
id: "object-1",
|
||||
companyId: "company-1",
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
|
||||
canonicalIdentityHash: "hash-123",
|
||||
displayTitle: "Mock ticket",
|
||||
statusKey: null,
|
||||
statusLabel: null,
|
||||
statusCategory: "unknown",
|
||||
statusTone: "neutral",
|
||||
liveness: "unknown",
|
||||
isTerminal: false,
|
||||
data: {},
|
||||
remoteVersion: null,
|
||||
etag: null,
|
||||
},
|
||||
}, 3)));
|
||||
await waitForResponses(responses, 3);
|
||||
|
||||
const resolveResponse = responses[2];
|
||||
expect(isJsonRpcSuccessResponse(resolveResponse)).toBe(true);
|
||||
if (!isJsonRpcSuccessResponse(resolveResponse)) return;
|
||||
expect(resolveResponse.result).toMatchObject({
|
||||
ok: true,
|
||||
snapshot: {
|
||||
statusCategory: "succeeded",
|
||||
statusTone: "success",
|
||||
},
|
||||
});
|
||||
|
||||
host.stop();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForResponses(responses: unknown[], count: number): Promise<void> {
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (responses.length < count && Date.now() < deadline) {
|
||||
|
|
|
|||
|
|
@ -1536,6 +1536,97 @@ describe("realizeExecutionWorkspace", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("regenerates stale worktree config that points at another host", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-stale-config-"));
|
||||
const baseRoot = path.join(tempRoot, "base");
|
||||
const worktreeRoot = path.join(tempRoot, "worktree");
|
||||
const fakeBin = path.join(tempRoot, "bin");
|
||||
const fakePnpmPath = path.join(fakeBin, "pnpm");
|
||||
const scriptPath = path.join(worktreeRoot, "provision-worktree.sh");
|
||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||
|
||||
try {
|
||||
await fs.mkdir(baseRoot, { recursive: true });
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.mkdir(fakeBin, { recursive: true });
|
||||
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
|
||||
await fs.chmod(scriptPath, 0o755);
|
||||
await fs.writeFile(
|
||||
path.join(paperclipDir, "config.json"),
|
||||
JSON.stringify({
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "/Users/example/.paperclip-worktrees/instances/stale/db",
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: "/Users/example/.paperclip-worktrees/instances/stale/logs",
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: "/Users/example/.paperclip-worktrees/instances/stale/data/storage",
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
localEncrypted: {
|
||||
keyFilePath: "/Users/example/.paperclip-worktrees/instances/stale/secrets/master.key",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(paperclipDir, ".env"),
|
||||
[
|
||||
"PAPERCLIP_HOME=/Users/example/.paperclip-worktrees",
|
||||
"PAPERCLIP_INSTANCE_ID=stale",
|
||||
`PAPERCLIP_CONFIG=/Users/example/paperclip/${path.basename(worktreeRoot)}/.paperclip/config.json`,
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
fakePnpmPath,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"worktree\" ] && [ \"$3\" = \"init\" ]; then",
|
||||
" mkdir -p \"$PWD/.paperclip\"",
|
||||
" printf '%s\\n' '{\"database\":{\"embeddedPostgresDataDir\":\"'$PWD'/.paperclip/runtime/db\"}}' > \"$PWD/.paperclip/config.json\"",
|
||||
" printf '%s\\n' \"PAPERCLIP_HOME=$PWD/.paperclip/runtime\" \"PAPERCLIP_INSTANCE_ID=healthy\" \"PAPERCLIP_CONFIG=$PWD/.paperclip/config.json\" > \"$PWD/.paperclip/.env\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 0",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(fakePnpmPath, 0o755);
|
||||
|
||||
const result = await execFileAsync(scriptPath, [], {
|
||||
cwd: worktreeRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot,
|
||||
PAPERCLIP_WORKSPACE_CWD: worktreeRoot,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.stderr).toContain("Existing isolated Paperclip worktree config is stale for this host; regenerating.");
|
||||
await expect(fs.readFile(path.join(paperclipDir, ".env"), "utf8")).resolves.toContain(
|
||||
`PAPERCLIP_CONFIG=${worktreeRoot}/.paperclip/config.json`,
|
||||
);
|
||||
await expect(fs.readFile(path.join(paperclipDir, "config.json"), "utf8")).resolves.toContain(worktreeRoot);
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("retries worktree-local pnpm install without a frozen lockfile when the lockfile is outdated", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-outdated-lockfile-"));
|
||||
const baseRoot = path.join(tempRoot, "base");
|
||||
|
|
|
|||
|
|
@ -148,11 +148,18 @@ import {
|
|||
resolveCoreTrustPreset,
|
||||
type TrustPresetResolution,
|
||||
} from "../services/trust-preset-resolver.js";
|
||||
import { externalObjectService } from "../services/external-objects.js";
|
||||
|
||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||
const updateIssueRouteSchema = updateIssueSchema.extend({
|
||||
interrupt: z.boolean().optional(),
|
||||
});
|
||||
const refreshExternalObjectsSchema = z.object({
|
||||
objectIds: z.array(z.string().uuid()).max(50).optional(),
|
||||
}).strict();
|
||||
const externalObjectSummariesSchema = z.object({
|
||||
issueIds: z.array(z.string().uuid()).max(1000),
|
||||
}).strict();
|
||||
|
||||
const promoteLowTrustOutputSchema = z.object({
|
||||
sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]),
|
||||
|
|
@ -1105,6 +1112,10 @@ export function issueRoutes(
|
|||
? heartbeat.wakeup
|
||||
: opts.taskWatchdogEnqueueWakeup ?? undefined,
|
||||
}) ?? noopTaskWatchdogService();
|
||||
const externalObjectsSvc = externalObjectService(db, {
|
||||
pluginWorkerManager: opts.pluginWorkerManager,
|
||||
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
|
||||
});
|
||||
const routinesSvc = routineService(db, {
|
||||
pluginWorkerManager: opts.pluginWorkerManager,
|
||||
});
|
||||
|
|
@ -3730,6 +3741,69 @@ export function issueRoutes(
|
|||
res.json(workProducts);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/external-objects", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const objects = await externalObjectsSvc.listForIssue(issue.id);
|
||||
res.json(objects);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/external-object-summary", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const summary = await externalObjectsSvc.getIssueSummary(issue.id);
|
||||
res.json(summary);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/issues/external-object-summaries", validate(externalObjectSummariesSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const summaries = await externalObjectsSvc.getIssueSummaries(companyId, req.body.issueIds);
|
||||
res.json({ summaries: Object.fromEntries(summaries) });
|
||||
});
|
||||
|
||||
router.post("/issues/:id/external-objects/refresh", validate(refreshExternalObjectsSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
const actor = getActorInfo(req);
|
||||
const results = await externalObjectsSvc.refreshIssueObjects(issue.id, {
|
||||
companyId: issue.companyId,
|
||||
objectIds: req.body.objectIds,
|
||||
actor,
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "external_object.refresh_requested",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
issueId: issue.id,
|
||||
objectIds: results.map((result) => result.object.id),
|
||||
},
|
||||
});
|
||||
res.json({ refreshed: results });
|
||||
});
|
||||
|
||||
router.get("/issues/:id/documents", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
|
@ -4016,6 +4090,7 @@ export function issueRoutes(
|
|||
const redirectedFromLockedDocument =
|
||||
"redirectedFromLockedDocument" in result ? result.redirectedFromLockedDocument : null;
|
||||
await issueReferencesSvc.syncDocument(doc.id);
|
||||
await externalObjectsSvc.syncDocumentSafely(doc.id);
|
||||
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
||||
const remappedAnnotations = result.created
|
||||
|
|
@ -4245,6 +4320,7 @@ export function issueRoutes(
|
|||
});
|
||||
await issueReferencesSvc.syncDocument(result.document.id);
|
||||
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
await externalObjectsSvc.syncDocumentSafely(result.document.id);
|
||||
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
||||
const remappedAnnotations = await documentAnnotationsSvc.remapOpenThreadsForDocument({
|
||||
issueId: issue.id,
|
||||
|
|
@ -4358,6 +4434,7 @@ export function issueRoutes(
|
|||
}
|
||||
await issueReferencesSvc.deleteDocumentSource(removed.id);
|
||||
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
if (removed) await externalObjectsSvc.syncDocumentSafely(removed.id);
|
||||
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
|
|
@ -5025,6 +5102,7 @@ export function issueRoutes(
|
|||
watchdogActorRunId: actor.runId,
|
||||
});
|
||||
await issueReferencesSvc.syncIssue(issue.id);
|
||||
await externalObjectsSvc.syncIssueSafely(issue.id);
|
||||
const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
||||
issueReferencesSvc.emptySummary(),
|
||||
|
|
@ -5190,6 +5268,7 @@ export function issueRoutes(
|
|||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
watchdogActorRunId: actor.runId,
|
||||
});
|
||||
await externalObjectsSvc.syncIssueSafely(issue.id);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: parent.companyId,
|
||||
|
|
@ -5958,6 +6037,7 @@ export function issueRoutes(
|
|||
|
||||
if (titleOrDescriptionChanged) {
|
||||
await issueReferencesSvc.syncIssue(issue.id);
|
||||
await externalObjectsSvc.syncIssueSafely(issue.id);
|
||||
}
|
||||
const updateReferenceSummaryAfter = titleOrDescriptionChanged
|
||||
? await issueReferencesSvc.listIssueReferenceSummary(issue.id)
|
||||
|
|
@ -6263,6 +6343,7 @@ export function issueRoutes(
|
|||
sourceTrust: await sourceTrustForActorWrite(issue, actor),
|
||||
});
|
||||
await issueReferencesSvc.syncComment(comment.id);
|
||||
await externalObjectsSvc.syncCommentSafely(comment.id);
|
||||
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
||||
commentReferenceSummaryBefore,
|
||||
|
|
@ -7256,6 +7337,7 @@ export function issueRoutes(
|
|||
{
|
||||
afterTombstone: async (deletedComment, tx) => {
|
||||
await issueReferencesSvc.syncComment(deletedComment.id, tx);
|
||||
await externalObjectsSvc.syncCommentSafely(deletedComment.id, tx);
|
||||
annotationCleanup = await documentAnnotationsSvc.cleanupForIssueCommentDeletion(issue.id, deletedComment.id, {
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
|
|
@ -7265,7 +7347,10 @@ export function issueRoutes(
|
|||
}, tx);
|
||||
await Promise.all(
|
||||
annotationCleanup.deletedCommentIds.map((annotationCommentId) =>
|
||||
issueReferencesSvc.deleteCommentSource(annotationCommentId, tx)
|
||||
Promise.all([
|
||||
issueReferencesSvc.deleteCommentSource(annotationCommentId, tx),
|
||||
externalObjectsSvc.syncCommentSafely(annotationCommentId, tx),
|
||||
])
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -7705,6 +7790,7 @@ export function issueRoutes(
|
|||
}
|
||||
|
||||
await issueReferencesSvc.syncComment(comment.id);
|
||||
await externalObjectsSvc.syncCommentSafely(comment.id);
|
||||
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
|
||||
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
||||
commentReferenceSummaryBefore,
|
||||
|
|
|
|||
|
|
@ -447,6 +447,14 @@ const jsonBody = (schema: z.ZodTypeAny) => ({
|
|||
|
||||
const r = responses;
|
||||
|
||||
const externalObjectSummariesBodySchema = z.object({
|
||||
issueIds: z.array(z.string().uuid()).max(1000),
|
||||
}).strict();
|
||||
|
||||
const refreshExternalObjectsBodySchema = z.object({
|
||||
objectIds: z.array(z.string().uuid()).max(50).optional(),
|
||||
}).strict();
|
||||
|
||||
function paramsSchemaFromPath(routePath: string): z.ZodObject<z.ZodRawShape> | undefined {
|
||||
const names = [...routePath.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]);
|
||||
if (names.length === 0) return undefined;
|
||||
|
|
@ -3847,6 +3855,57 @@ registry.registerPath({
|
|||
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/issues/{id}/external-objects",
|
||||
tags: ["issues"],
|
||||
summary: "List external objects mentioned by an issue",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/issues/{id}/external-object-summary",
|
||||
tags: ["issues"],
|
||||
summary: "Get external object status summary for an issue",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/issues/external-object-summaries",
|
||||
tags: ["issues"],
|
||||
summary: "Get external object status summaries for issues",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
body: jsonBody(externalObjectSummariesBodySchema),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/issues/{id}/external-objects/refresh",
|
||||
tags: ["issues"],
|
||||
summary: "Refresh external objects mentioned by an issue",
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: jsonBody(refreshExternalObjectsBodySchema),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/projects/{id}/external-object-summary",
|
||||
tags: ["projects"],
|
||||
summary: "Get external object status summary for a project",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
// ─── Org chart images ─────────────────────────────────────────────────────────
|
||||
|
||||
registry.registerPath({
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
|||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { conflict, forbidden } from "../errors.js";
|
||||
import { externalObjectService } from "../services/external-objects.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import {
|
||||
buildWorkspaceRuntimeDesiredStatePatch,
|
||||
|
|
@ -44,6 +46,10 @@ export function projectRoutes(db: Db) {
|
|||
const access = accessService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
const workspaceOperations = workspaceOperationService(db);
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const externalObjectsSvc = externalObjectService(db, {
|
||||
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
|
||||
});
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
const environmentsSvc = environmentService(db);
|
||||
|
||||
|
|
@ -139,6 +145,18 @@ export function projectRoutes(db: Db) {
|
|||
res.json(project);
|
||||
});
|
||||
|
||||
router.get("/projects/:id/external-object-summary", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const project = await svc.getById(id);
|
||||
if (!project) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, project.companyId);
|
||||
const summary = await externalObjectsSvc.getProjectSummary(project.id);
|
||||
res.json(summary);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,945 @@
|
|||
import { and, asc, eq, inArray, isNull, lte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues, plugins } from "@paperclipai/db";
|
||||
import {
|
||||
formatExternalObjectMentionSourceLabel,
|
||||
type ExternalObjectCanonicalUrl,
|
||||
type ExternalObjectLivenessState,
|
||||
type ExternalObjectMentionConfidence,
|
||||
type ExternalObjectMentionSourceKind,
|
||||
type ExternalObjectStatusCategory,
|
||||
type ExternalObjectStatusTone,
|
||||
type PaperclipPluginManifestV1,
|
||||
} from "@paperclipai/shared";
|
||||
import { extractExternalObjectCanonicalUrls } from "@paperclipai/shared/external-objects-server";
|
||||
import type { PluginExternalObjectRecordSnapshot, PluginExternalObjectResolveResult } from "@paperclipai/plugin-sdk";
|
||||
import { notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
import { createGitHubExternalObjectProvider, type GitHubExternalObjectProviderOptions } from "./github-external-object-provider.js";
|
||||
import { publishLiveEvent } from "./live-events.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
|
||||
export interface ExternalObjectSourceContext {
|
||||
companyId: string;
|
||||
sourceIssueId: string;
|
||||
sourceKind: ExternalObjectMentionSourceKind;
|
||||
sourceRecordId: string | null;
|
||||
documentKey: string | null;
|
||||
propertyKey: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalObjectDetection {
|
||||
canonical: ExternalObjectCanonicalUrl;
|
||||
detectorKey: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
externalId: string;
|
||||
displayKey?: string | null;
|
||||
iconKey?: string | null;
|
||||
displayTitle?: string | null;
|
||||
confidence?: ExternalObjectMentionConfidence;
|
||||
pluginId?: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalObjectDetector {
|
||||
key: string;
|
||||
detect(input: {
|
||||
companyId: string;
|
||||
urls: ExternalObjectCanonicalUrl[];
|
||||
sourceContext: ExternalObjectSourceContext;
|
||||
}): Promise<ExternalObjectDetection[]> | ExternalObjectDetection[];
|
||||
}
|
||||
|
||||
export interface ExternalObjectResolverSnapshot {
|
||||
displayKey?: string | null;
|
||||
iconKey?: string | null;
|
||||
displayTitle?: string | null;
|
||||
statusKey?: string | null;
|
||||
statusLabel?: string | null;
|
||||
statusIconKey?: string | null;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
statusTone: ExternalObjectStatusTone;
|
||||
isTerminal?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
remoteVersion?: string | null;
|
||||
etag?: string | null;
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export type ExternalObjectResolveResult =
|
||||
| { ok: true; snapshot: ExternalObjectResolverSnapshot }
|
||||
| {
|
||||
ok: false;
|
||||
liveness: Extract<ExternalObjectLivenessState, "auth_required" | "unreachable">;
|
||||
errorCode: string;
|
||||
errorMessage?: string | null;
|
||||
retryAfterSeconds?: number;
|
||||
};
|
||||
|
||||
export interface ExternalObjectResolver {
|
||||
providerKey: string;
|
||||
objectType?: string;
|
||||
resolve(input: {
|
||||
companyId: string;
|
||||
object: ExternalObjectRecord;
|
||||
}): Promise<ExternalObjectResolveResult>;
|
||||
}
|
||||
|
||||
type ExternalObjectRecord = typeof externalObjects.$inferSelect;
|
||||
type ExternalObjectMentionRecord = typeof externalObjectMentions.$inferSelect;
|
||||
|
||||
const DEFAULT_REFRESH_TTL_SECONDS = 300;
|
||||
const DEFAULT_RETRY_AFTER_SECONDS = 300;
|
||||
|
||||
function sourceWhere(input: ExternalObjectSourceContext) {
|
||||
const conditions = [
|
||||
eq(externalObjectMentions.companyId, input.companyId),
|
||||
eq(externalObjectMentions.sourceIssueId, input.sourceIssueId),
|
||||
eq(externalObjectMentions.sourceKind, input.sourceKind),
|
||||
];
|
||||
if (input.sourceRecordId) {
|
||||
conditions.push(eq(externalObjectMentions.sourceRecordId, input.sourceRecordId));
|
||||
} else {
|
||||
conditions.push(isNull(externalObjectMentions.sourceRecordId));
|
||||
}
|
||||
if (input.documentKey) {
|
||||
conditions.push(eq(externalObjectMentions.documentKey, input.documentKey));
|
||||
} else {
|
||||
conditions.push(isNull(externalObjectMentions.documentKey));
|
||||
}
|
||||
if (input.propertyKey) {
|
||||
conditions.push(eq(externalObjectMentions.propertyKey, input.propertyKey));
|
||||
} else {
|
||||
conditions.push(isNull(externalObjectMentions.propertyKey));
|
||||
}
|
||||
return and(...conditions);
|
||||
}
|
||||
|
||||
function addSeconds(date: Date, seconds: number) {
|
||||
return new Date(date.getTime() + Math.max(1, seconds) * 1000);
|
||||
}
|
||||
|
||||
function visibleLiveness(object: ExternalObjectRecord, now = new Date()): ExternalObjectLivenessState {
|
||||
if (object.liveness === "fresh" && object.nextRefreshAt && object.nextRefreshAt <= now) {
|
||||
return "stale";
|
||||
}
|
||||
return object.liveness;
|
||||
}
|
||||
|
||||
function objectChanged(before: ExternalObjectRecord, after: ExternalObjectRecord) {
|
||||
return (
|
||||
before.statusKey !== after.statusKey ||
|
||||
before.statusLabel !== after.statusLabel ||
|
||||
before.statusIconKey !== after.statusIconKey ||
|
||||
before.statusCategory !== after.statusCategory ||
|
||||
before.statusTone !== after.statusTone ||
|
||||
before.isTerminal !== after.isTerminal
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeErrorMessage(message: string | null | undefined) {
|
||||
if (!message) return null;
|
||||
return message
|
||||
.replace(/https?:\/\/[^\s<>()]+/gi, "[redacted-url]")
|
||||
.replace(/\b(token|key|secret|authorization|bearer)=\S+/gi, "$1=[redacted]");
|
||||
}
|
||||
|
||||
function genericUrlDetector(): ExternalObjectDetector {
|
||||
return {
|
||||
key: "generic-url",
|
||||
detect({ urls }) {
|
||||
return urls.map((canonical) => ({
|
||||
canonical,
|
||||
detectorKey: "generic-url",
|
||||
providerKey: "url",
|
||||
objectType: "link",
|
||||
externalId: canonical.canonicalIdentityHash,
|
||||
displayTitle: canonical.sanitizedDisplayUrl,
|
||||
confidence: "possible",
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createExternalObjectDetectorRegistry(detectors: ExternalObjectDetector[] = []) {
|
||||
const entries = [...detectors, genericUrlDetector()];
|
||||
|
||||
async function detect(input: {
|
||||
companyId: string;
|
||||
urls: ExternalObjectCanonicalUrl[];
|
||||
sourceContext: ExternalObjectSourceContext;
|
||||
}) {
|
||||
const claimed = new Set<string>();
|
||||
const detections: ExternalObjectDetection[] = [];
|
||||
for (const detector of entries) {
|
||||
const remaining = input.urls.filter((url) => !claimed.has(url.canonicalIdentityHash));
|
||||
if (remaining.length === 0) break;
|
||||
try {
|
||||
const detected = await detector.detect({ ...input, urls: remaining });
|
||||
for (const detection of detected) {
|
||||
if (claimed.has(detection.canonical.canonicalIdentityHash)) continue;
|
||||
claimed.add(detection.canonical.canonicalIdentityHash);
|
||||
detections.push({ ...detection, detectorKey: detection.detectorKey || detector.key });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err, detectorKey: detector.key }, "external object detector failed");
|
||||
}
|
||||
}
|
||||
return detections;
|
||||
}
|
||||
|
||||
return { detect };
|
||||
}
|
||||
|
||||
export function createExternalObjectResolverRegistry(resolvers: ExternalObjectResolver[] = []) {
|
||||
function find(object: Pick<ExternalObjectRecord, "providerKey" | "objectType">) {
|
||||
return resolvers.find(
|
||||
(resolver) =>
|
||||
resolver.providerKey === object.providerKey &&
|
||||
(!resolver.objectType || resolver.objectType === object.objectType),
|
||||
) ?? null;
|
||||
}
|
||||
return { find };
|
||||
}
|
||||
|
||||
function manifestProvidesObject(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
object: Pick<ExternalObjectRecord, "providerKey" | "objectType">,
|
||||
) {
|
||||
return (manifest.objectReferences ?? []).some(
|
||||
(provider) =>
|
||||
provider.providerKey === object.providerKey &&
|
||||
provider.objectTypes.includes(object.objectType),
|
||||
);
|
||||
}
|
||||
|
||||
function objectSnapshot(object: ExternalObjectRecord): PluginExternalObjectRecordSnapshot {
|
||||
return {
|
||||
id: object.id,
|
||||
companyId: object.companyId,
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
externalId: object.externalId,
|
||||
sanitizedCanonicalUrl: object.sanitizedCanonicalUrl,
|
||||
canonicalIdentityHash: object.canonicalIdentityHash,
|
||||
displayKey: object.displayKey,
|
||||
iconKey: object.iconKey,
|
||||
displayTitle: object.displayTitle,
|
||||
statusKey: object.statusKey,
|
||||
statusLabel: object.statusLabel,
|
||||
statusIconKey: object.statusIconKey,
|
||||
statusCategory: object.statusCategory,
|
||||
statusTone: object.statusTone,
|
||||
liveness: object.liveness,
|
||||
isTerminal: object.isTerminal,
|
||||
data: object.data as Record<string, unknown>,
|
||||
remoteVersion: object.remoteVersion,
|
||||
etag: object.etag,
|
||||
};
|
||||
}
|
||||
|
||||
async function readyObjectReferencePlugins(db: Db) {
|
||||
return db
|
||||
.select({
|
||||
id: plugins.id,
|
||||
pluginKey: plugins.pluginKey,
|
||||
manifestJson: plugins.manifestJson,
|
||||
})
|
||||
.from(plugins)
|
||||
.where(eq(plugins.status, "ready"))
|
||||
.orderBy(asc(plugins.installOrder))
|
||||
.then((rows) =>
|
||||
rows.filter((row) => (row.manifestJson.objectReferences?.length ?? 0) > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function createPluginProviderDetector(
|
||||
db: Db,
|
||||
pluginWorkerManager: PluginWorkerManager,
|
||||
): ExternalObjectDetector {
|
||||
return {
|
||||
key: "plugin-object-reference-providers",
|
||||
async detect(input) {
|
||||
const providers = await readyObjectReferencePlugins(db);
|
||||
const detections: ExternalObjectDetection[] = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
const manifest = provider.manifestJson;
|
||||
if (!manifest.capabilities.includes("external.objects.detect")) continue;
|
||||
try {
|
||||
const result = await pluginWorkerManager.call(provider.id, "detectExternalObjects", {
|
||||
companyId: input.companyId,
|
||||
urls: input.urls.map((url) => ({
|
||||
sanitizedCanonicalUrl: url.sanitizedCanonicalUrl,
|
||||
sanitizedDisplayUrl: url.sanitizedDisplayUrl,
|
||||
canonicalIdentityHash: url.canonicalIdentityHash,
|
||||
canonicalIdentity: url.canonicalIdentity as unknown as Record<string, unknown>,
|
||||
redactedMatchedText: url.redactedMatchedText,
|
||||
})),
|
||||
sourceContext: input.sourceContext,
|
||||
});
|
||||
const urlsByHash = new Map(input.urls.map((url) => [url.canonicalIdentityHash, url]));
|
||||
const declaredProviderKeys = new Set((manifest.objectReferences ?? []).map((entry) => entry.providerKey));
|
||||
|
||||
for (const detection of result.detections ?? []) {
|
||||
const canonical = urlsByHash.get(detection.urlIdentityHash);
|
||||
if (!canonical) continue;
|
||||
if (!declaredProviderKeys.has(detection.providerKey)) continue;
|
||||
const declaration = (manifest.objectReferences ?? []).find((entry) => entry.providerKey === detection.providerKey);
|
||||
if (!declaration?.objectTypes.includes(detection.objectType)) continue;
|
||||
detections.push({
|
||||
canonical,
|
||||
detectorKey: `${provider.pluginKey}:${detection.providerKey}`,
|
||||
providerKey: detection.providerKey,
|
||||
objectType: detection.objectType,
|
||||
externalId: detection.externalId,
|
||||
displayKey: detection.displayKey,
|
||||
iconKey: detection.iconKey,
|
||||
displayTitle: detection.displayTitle,
|
||||
confidence: detection.confidence ?? "exact",
|
||||
pluginId: provider.id,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, pluginId: provider.id, pluginKey: provider.pluginKey },
|
||||
"plugin external object detector failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return detections;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveViaPluginProvider(
|
||||
db: Db,
|
||||
pluginWorkerManager: PluginWorkerManager | undefined,
|
||||
object: ExternalObjectRecord,
|
||||
): Promise<PluginExternalObjectResolveResult | null> {
|
||||
if (!pluginWorkerManager) return null;
|
||||
const providers = await readyObjectReferencePlugins(db);
|
||||
for (const provider of providers) {
|
||||
const manifest = provider.manifestJson;
|
||||
if (!manifest.capabilities.includes("external.objects.read")) continue;
|
||||
if (!manifestProvidesObject(manifest, object)) continue;
|
||||
try {
|
||||
return await pluginWorkerManager.call(provider.id, "resolveExternalObject", {
|
||||
companyId: object.companyId,
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
externalId: object.externalId,
|
||||
object: objectSnapshot(object),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, pluginId: provider.id, pluginKey: provider.pluginKey, objectId: object.id },
|
||||
"plugin external object resolver failed",
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
liveness: "unreachable",
|
||||
errorCode: "plugin_resolver_failed",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function externalObjectService(
|
||||
db: Db,
|
||||
opts: {
|
||||
detectors?: ExternalObjectDetector[];
|
||||
resolvers?: ExternalObjectResolver[];
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
github?: GitHubExternalObjectProviderOptions | false;
|
||||
enabled?: boolean | (() => boolean | Promise<boolean>);
|
||||
} = {},
|
||||
) {
|
||||
const githubProvider = opts.github === false ? null : createGitHubExternalObjectProvider(db, opts.github);
|
||||
const pluginProviderDetector = opts.pluginWorkerManager
|
||||
? createPluginProviderDetector(db, opts.pluginWorkerManager)
|
||||
: null;
|
||||
const detectorRegistry = createExternalObjectDetectorRegistry([
|
||||
...(pluginProviderDetector ? [pluginProviderDetector] : []),
|
||||
...(opts.detectors ?? []),
|
||||
...(githubProvider ? [githubProvider.detector] : []),
|
||||
]);
|
||||
const resolverRegistry = createExternalObjectResolverRegistry([
|
||||
...(opts.resolvers ?? []),
|
||||
...(githubProvider?.resolvers ?? []),
|
||||
]);
|
||||
|
||||
async function isEnabled() {
|
||||
if (typeof opts.enabled === "function") return await opts.enabled();
|
||||
return opts.enabled ?? true;
|
||||
}
|
||||
|
||||
function emptySummary() {
|
||||
return summarizeObjectPayloads([]);
|
||||
}
|
||||
|
||||
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 upsertObjectFromDetection(
|
||||
companyId: string,
|
||||
detection: ExternalObjectDetection,
|
||||
dbOrTx: any,
|
||||
): Promise<ExternalObjectRecord> {
|
||||
const now = new Date();
|
||||
const canonical = detection.canonical;
|
||||
const values = {
|
||||
companyId,
|
||||
providerKey: detection.providerKey,
|
||||
pluginId: detection.pluginId ?? null,
|
||||
objectType: detection.objectType,
|
||||
externalId: detection.externalId,
|
||||
sanitizedCanonicalUrl: canonical.sanitizedCanonicalUrl,
|
||||
canonicalIdentityHash: canonical.canonicalIdentityHash,
|
||||
displayKey: detection.displayKey ?? null,
|
||||
iconKey: detection.iconKey ?? null,
|
||||
displayTitle: detection.displayTitle ?? canonical.sanitizedDisplayUrl,
|
||||
nextRefreshAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const inserted = await dbOrTx
|
||||
.insert(externalObjects)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
externalObjects.companyId,
|
||||
externalObjects.providerKey,
|
||||
externalObjects.objectType,
|
||||
externalObjects.externalId,
|
||||
],
|
||||
set: {
|
||||
sanitizedCanonicalUrl: values.sanitizedCanonicalUrl,
|
||||
canonicalIdentityHash: values.canonicalIdentityHash,
|
||||
displayKey: values.displayKey,
|
||||
iconKey: values.iconKey,
|
||||
displayTitle: values.displayTitle,
|
||||
nextRefreshAt: sql`coalesce(${externalObjects.nextRefreshAt}, now())`,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return inserted[0]!;
|
||||
}
|
||||
|
||||
async function replaceSourceMentions(
|
||||
input: ExternalObjectSourceContext & { text: string | null | undefined },
|
||||
dbOrTx: any = db,
|
||||
) {
|
||||
const urls = extractExternalObjectCanonicalUrls(input.text ?? "");
|
||||
await dbOrTx.delete(externalObjectMentions).where(sourceWhere(input));
|
||||
if (urls.length === 0) return;
|
||||
|
||||
const detections = await detectorRegistry.detect({
|
||||
companyId: input.companyId,
|
||||
urls,
|
||||
sourceContext: input,
|
||||
});
|
||||
if (detections.length === 0) return;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const values: Array<typeof externalObjectMentions.$inferInsert> = [];
|
||||
for (const detection of detections) {
|
||||
const canonicalHash = detection.canonical.canonicalIdentityHash;
|
||||
const sourceKey = `${detection.providerKey}:${detection.objectType}:${canonicalHash}`;
|
||||
if (seen.has(sourceKey)) continue;
|
||||
seen.add(sourceKey);
|
||||
const object = await upsertObjectFromDetection(input.companyId, detection, dbOrTx);
|
||||
values.push({
|
||||
companyId: input.companyId,
|
||||
sourceIssueId: input.sourceIssueId,
|
||||
sourceKind: input.sourceKind,
|
||||
sourceRecordId: input.sourceRecordId,
|
||||
documentKey: input.documentKey,
|
||||
propertyKey: input.propertyKey,
|
||||
matchedTextRedacted: detection.canonical.redactedMatchedText,
|
||||
sanitizedDisplayUrl: detection.canonical.sanitizedDisplayUrl,
|
||||
canonicalIdentityHash: canonicalHash,
|
||||
canonicalIdentity: detection.canonical.canonicalIdentity as unknown as Record<string, unknown>,
|
||||
objectId: object.id,
|
||||
providerKey: detection.providerKey,
|
||||
detectorKey: detection.detectorKey,
|
||||
objectType: detection.objectType,
|
||||
confidence: detection.confidence ?? "exact",
|
||||
createdByPluginId: detection.pluginId ?? null,
|
||||
});
|
||||
}
|
||||
if (values.length > 0) {
|
||||
await dbOrTx.insert(externalObjectMentions).values(values);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncIssue(issueId: string, dbOrTx: any = db) {
|
||||
if (!(await isEnabled())) return;
|
||||
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,
|
||||
propertyKey: null,
|
||||
text: issue.title,
|
||||
}, tx);
|
||||
await replaceSourceMentions({
|
||||
companyId: issue.companyId,
|
||||
sourceIssueId: issue.id,
|
||||
sourceKind: "description",
|
||||
sourceRecordId: null,
|
||||
documentKey: null,
|
||||
propertyKey: null,
|
||||
text: issue.description,
|
||||
}, tx);
|
||||
};
|
||||
return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx);
|
||||
}
|
||||
|
||||
async function syncComment(commentId: string, dbOrTx: any = db) {
|
||||
const runSync = async (tx: any) => {
|
||||
if (!(await isEnabled())) return;
|
||||
const comment = await tx
|
||||
.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) {
|
||||
await tx
|
||||
.delete(externalObjectMentions)
|
||||
.where(and(eq(externalObjectMentions.sourceKind, "comment"), eq(externalObjectMentions.sourceRecordId, commentId)));
|
||||
return;
|
||||
}
|
||||
await replaceSourceMentions({
|
||||
companyId: comment.companyId,
|
||||
sourceIssueId: comment.issueId,
|
||||
sourceKind: "comment",
|
||||
sourceRecordId: comment.id,
|
||||
documentKey: null,
|
||||
propertyKey: null,
|
||||
text: comment.body,
|
||||
}, tx);
|
||||
};
|
||||
return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx);
|
||||
}
|
||||
|
||||
async function syncDocument(documentId: string, dbOrTx: any = db) {
|
||||
const runSync = async (tx: any) => {
|
||||
if (!(await isEnabled())) return;
|
||||
const document = await tx
|
||||
.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 tx
|
||||
.delete(externalObjectMentions)
|
||||
.where(and(eq(externalObjectMentions.sourceKind, "document"), eq(externalObjectMentions.sourceRecordId, documentId)));
|
||||
return;
|
||||
}
|
||||
await replaceSourceMentions({
|
||||
companyId: document.companyId,
|
||||
sourceIssueId: document.issueId,
|
||||
sourceKind: "document",
|
||||
sourceRecordId: document.documentId,
|
||||
documentKey: document.key,
|
||||
propertyKey: null,
|
||||
text: document.body,
|
||||
}, tx);
|
||||
};
|
||||
return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx);
|
||||
}
|
||||
|
||||
async function safeSync(label: string, fn: () => Promise<void>) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `external object ${label} sync failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncIssueSafely(issueId: string) {
|
||||
await safeSync("issue", () => syncIssue(issueId));
|
||||
}
|
||||
|
||||
async function syncCommentSafely(commentId: string, dbOrTx: any = db) {
|
||||
await safeSync("comment", () => syncComment(commentId, dbOrTx));
|
||||
}
|
||||
|
||||
async function syncDocumentSafely(documentId: string) {
|
||||
await safeSync("document", () => syncDocument(documentId));
|
||||
}
|
||||
|
||||
function toObjectPayload(object: ExternalObjectRecord, now = new Date()) {
|
||||
return {
|
||||
...object,
|
||||
liveness: visibleLiveness(object, now),
|
||||
};
|
||||
}
|
||||
|
||||
async function listForIssue(issueId: string) {
|
||||
if (!(await isEnabled())) return [];
|
||||
const issue = await issueById(issueId);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
const rows = await db
|
||||
.select({
|
||||
mention: externalObjectMentions,
|
||||
object: externalObjects,
|
||||
})
|
||||
.from(externalObjectMentions)
|
||||
.leftJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id))
|
||||
.where(and(
|
||||
eq(externalObjectMentions.companyId, issue.companyId),
|
||||
eq(externalObjectMentions.sourceIssueId, issue.id),
|
||||
))
|
||||
.orderBy(asc(externalObjectMentions.sourceKind), asc(externalObjectMentions.createdAt));
|
||||
const now = new Date();
|
||||
const grouped = new Map<string, {
|
||||
object: ReturnType<typeof toObjectPayload> | null;
|
||||
mentions: ExternalObjectMentionRecord[];
|
||||
mentionCount: number;
|
||||
sourceLabels: string[];
|
||||
}>();
|
||||
for (const row of rows) {
|
||||
const key = row.object?.id ?? `mention:${row.mention.id}`;
|
||||
const existing = grouped.get(key) ?? {
|
||||
object: row.object ? toObjectPayload(row.object, now) : null,
|
||||
mentions: [],
|
||||
mentionCount: 0,
|
||||
sourceLabels: [],
|
||||
};
|
||||
existing.mentions.push(row.mention);
|
||||
existing.mentionCount += 1;
|
||||
const label = formatExternalObjectMentionSourceLabel({
|
||||
sourceKind: row.mention.sourceKind,
|
||||
documentKey: row.mention.documentKey,
|
||||
propertyKey: row.mention.propertyKey,
|
||||
});
|
||||
if (!existing.sourceLabels.includes(label)) existing.sourceLabels.push(label);
|
||||
grouped.set(key, existing);
|
||||
}
|
||||
return [...grouped.values()];
|
||||
}
|
||||
|
||||
function summarizeObjects(objects: Array<ReturnType<typeof toObjectPayload>>) {
|
||||
const byStatusCategory: Record<string, number> = {};
|
||||
const byLiveness: Record<string, number> = {};
|
||||
let highestSeverity: ExternalObjectStatusTone = "neutral";
|
||||
const severityRank: Record<ExternalObjectStatusTone, number> = {
|
||||
neutral: 0,
|
||||
muted: 0,
|
||||
success: 1,
|
||||
info: 2,
|
||||
warning: 3,
|
||||
danger: 4,
|
||||
};
|
||||
for (const object of objects) {
|
||||
byStatusCategory[object.statusCategory] = (byStatusCategory[object.statusCategory] ?? 0) + 1;
|
||||
byLiveness[object.liveness] = (byLiveness[object.liveness] ?? 0) + 1;
|
||||
const livenessTone = object.liveness === "auth_required" || object.liveness === "unreachable"
|
||||
? "danger"
|
||||
: object.liveness === "stale"
|
||||
? "warning"
|
||||
: object.statusTone;
|
||||
if (severityRank[livenessTone] > severityRank[highestSeverity]) highestSeverity = livenessTone;
|
||||
}
|
||||
return {
|
||||
total: objects.length,
|
||||
byStatusCategory,
|
||||
byLiveness,
|
||||
highestSeverity,
|
||||
staleCount: byLiveness.stale ?? 0,
|
||||
authRequiredCount: byLiveness.auth_required ?? 0,
|
||||
unreachableCount: byLiveness.unreachable ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeObjectPayloads(objects: Array<ReturnType<typeof toObjectPayload>>, objectLimit = objects.length) {
|
||||
return {
|
||||
...summarizeObjects(objects),
|
||||
objects: objects.slice(0, objectLimit).map((object) => ({
|
||||
id: object.id,
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
displayKey: object.displayKey,
|
||||
iconKey: object.iconKey,
|
||||
displayTitle: object.displayTitle,
|
||||
statusIconKey: object.statusIconKey,
|
||||
statusCategory: object.statusCategory,
|
||||
statusTone: object.statusTone,
|
||||
liveness: object.liveness,
|
||||
isTerminal: object.isTerminal,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function getIssueSummary(issueId: string) {
|
||||
if (!(await isEnabled())) return emptySummary();
|
||||
const groups = await listForIssue(issueId);
|
||||
const objects = groups.flatMap((group) => (group.object ? [group.object] : []));
|
||||
return summarizeObjectPayloads(objects);
|
||||
}
|
||||
|
||||
async function getIssueSummaries(companyId: string, issueIds: string[]) {
|
||||
if (!(await isEnabled())) return new Map<string, ReturnType<typeof summarizeObjectPayloads>>();
|
||||
const uniqueIssueIds = [...new Set(issueIds)].filter((id) => id.length > 0);
|
||||
const summaries = new Map<string, ReturnType<typeof summarizeObjectPayloads>>();
|
||||
if (uniqueIssueIds.length === 0) return summaries;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
issueId: externalObjectMentions.sourceIssueId,
|
||||
object: externalObjects,
|
||||
})
|
||||
.from(externalObjectMentions)
|
||||
.innerJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id))
|
||||
.where(and(
|
||||
eq(externalObjectMentions.companyId, companyId),
|
||||
inArray(externalObjectMentions.sourceIssueId, uniqueIssueIds),
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const objectsByIssueId = new Map<string, Map<string, ReturnType<typeof toObjectPayload>>>();
|
||||
for (const row of rows) {
|
||||
const issueObjects = objectsByIssueId.get(row.issueId) ?? new Map<string, ReturnType<typeof toObjectPayload>>();
|
||||
issueObjects.set(row.object.id, toObjectPayload(row.object, now));
|
||||
objectsByIssueId.set(row.issueId, issueObjects);
|
||||
}
|
||||
|
||||
for (const [issueId, issueObjects] of objectsByIssueId) {
|
||||
const objects = [...issueObjects.values()];
|
||||
if (objects.length > 0) summaries.set(issueId, summarizeObjectPayloads(objects));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async function getProjectSummary(projectId: string) {
|
||||
if (!(await isEnabled())) return emptySummary();
|
||||
const projectIssues = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.projectId, projectId), inArray(issues.status, ["todo", "in_progress", "in_review", "blocked"])));
|
||||
if (projectIssues.length === 0) return { ...summarizeObjects([]), objects: [] };
|
||||
const companyIds = new Set(projectIssues.map((issue) => issue.companyId));
|
||||
if (companyIds.size !== 1) return { ...summarizeObjects([]), objects: [] };
|
||||
const issueIds = projectIssues.map((issue) => issue.id);
|
||||
const rows = await db
|
||||
.select({ object: externalObjects })
|
||||
.from(externalObjectMentions)
|
||||
.innerJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id))
|
||||
.where(and(
|
||||
eq(externalObjectMentions.companyId, projectIssues[0]!.companyId),
|
||||
inArray(externalObjectMentions.sourceIssueId, issueIds),
|
||||
));
|
||||
const now = new Date();
|
||||
const objectsById = new Map<string, ReturnType<typeof toObjectPayload>>();
|
||||
for (const row of rows) objectsById.set(row.object.id, toObjectPayload(row.object, now));
|
||||
const objects = [...objectsById.values()];
|
||||
return summarizeObjectPayloads(objects, 25);
|
||||
}
|
||||
|
||||
async function refreshObject(
|
||||
objectId: string,
|
||||
input: {
|
||||
companyId: string;
|
||||
actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId">;
|
||||
force?: boolean;
|
||||
now?: Date;
|
||||
},
|
||||
) {
|
||||
const now = input.now ?? new Date();
|
||||
const object = await db
|
||||
.select()
|
||||
.from(externalObjects)
|
||||
.where(and(eq(externalObjects.id, objectId), eq(externalObjects.companyId, input.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!object) throw notFound("External object not found");
|
||||
if (!input.force && object.nextRefreshAt && object.nextRefreshAt > now) {
|
||||
return { object: toObjectPayload(object, now), refreshed: false, reason: "backoff" as const };
|
||||
}
|
||||
|
||||
const pluginResult = await resolveViaPluginProvider(db, opts.pluginWorkerManager, object);
|
||||
const resolver = pluginResult ? null : resolverRegistry.find(object);
|
||||
if (!pluginResult && !resolver) {
|
||||
const [updated] = await db
|
||||
.update(externalObjects)
|
||||
.set({
|
||||
liveness: visibleLiveness(object, now) === "fresh" ? "stale" : object.liveness,
|
||||
nextRefreshAt: addSeconds(now, DEFAULT_RETRY_AFTER_SECONDS),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
|
||||
.returning();
|
||||
return { object: toObjectPayload(updated ?? object, now), refreshed: false, reason: "no_resolver" as const };
|
||||
}
|
||||
|
||||
const result = pluginResult ?? await resolver!.resolve({ companyId: object.companyId, object });
|
||||
if (!result.ok) {
|
||||
const [updated] = await db
|
||||
.update(externalObjects)
|
||||
.set({
|
||||
liveness: result.liveness,
|
||||
lastErrorAt: now,
|
||||
lastErrorCode: result.errorCode,
|
||||
lastErrorMessage: sanitizeErrorMessage(result.errorMessage),
|
||||
nextRefreshAt: addSeconds(now, result.retryAfterSeconds ?? DEFAULT_RETRY_AFTER_SECONDS),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
|
||||
.returning();
|
||||
publishLiveEvent({
|
||||
companyId: object.companyId,
|
||||
type: "external_object.updated",
|
||||
payload: { objectId: object.id, liveness: result.liveness },
|
||||
});
|
||||
return { object: toObjectPayload(updated ?? object, now), refreshed: true, reason: result.liveness };
|
||||
}
|
||||
|
||||
const snapshot = result.snapshot;
|
||||
const patch = {
|
||||
displayKey: snapshot.displayKey ?? object.displayKey,
|
||||
iconKey: snapshot.iconKey ?? object.iconKey,
|
||||
displayTitle: snapshot.displayTitle ?? object.displayTitle,
|
||||
statusKey: snapshot.statusKey ?? object.statusKey,
|
||||
statusLabel: snapshot.statusLabel ?? object.statusLabel,
|
||||
statusIconKey: snapshot.statusIconKey ?? object.statusIconKey,
|
||||
statusCategory: snapshot.statusCategory,
|
||||
statusTone: snapshot.statusTone,
|
||||
isTerminal: snapshot.isTerminal ?? object.isTerminal,
|
||||
data: snapshot.data ?? object.data,
|
||||
remoteVersion: snapshot.remoteVersion ?? object.remoteVersion,
|
||||
etag: snapshot.etag ?? object.etag,
|
||||
liveness: "fresh" as ExternalObjectLivenessState,
|
||||
lastResolvedAt: now,
|
||||
lastErrorAt: null,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
nextRefreshAt: addSeconds(now, snapshot.ttlSeconds ?? DEFAULT_REFRESH_TTL_SECONDS),
|
||||
updatedAt: now,
|
||||
};
|
||||
const [updated] = await db
|
||||
.update(externalObjects)
|
||||
.set({
|
||||
...patch,
|
||||
lastChangedAt: objectChanged(object, { ...object, ...patch }) ? now : object.lastChangedAt,
|
||||
})
|
||||
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
|
||||
.returning();
|
||||
const next = updated ?? object;
|
||||
if (objectChanged(object, next) && input.actor) {
|
||||
await logActivity(db, {
|
||||
companyId: object.companyId,
|
||||
actorType: input.actor.actorType,
|
||||
actorId: input.actor.actorId,
|
||||
agentId: input.actor.agentId,
|
||||
runId: input.actor.runId,
|
||||
action: "external_object.status_changed",
|
||||
entityType: "external_object",
|
||||
entityId: object.id,
|
||||
details: {
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
statusCategory: next.statusCategory,
|
||||
statusLabel: next.statusLabel,
|
||||
_previous: {
|
||||
statusCategory: object.statusCategory,
|
||||
statusLabel: object.statusLabel,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
publishLiveEvent({
|
||||
companyId: object.companyId,
|
||||
type: "external_object.updated",
|
||||
payload: { objectId: object.id, statusCategory: next.statusCategory, liveness: next.liveness },
|
||||
});
|
||||
return { object: toObjectPayload(next, now), refreshed: true, reason: "resolved" as const };
|
||||
}
|
||||
|
||||
async function refreshIssueObjects(issueId: string, input: {
|
||||
companyId: string;
|
||||
objectIds?: string[];
|
||||
actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId">;
|
||||
}) {
|
||||
if (!(await isEnabled())) return [];
|
||||
const groups = await listForIssue(issueId);
|
||||
const objectIds = groups
|
||||
.flatMap((group) => (group.object ? [group.object.id] : []))
|
||||
.filter((id) => !input.objectIds || input.objectIds.includes(id));
|
||||
const results = [];
|
||||
for (const objectId of objectIds) {
|
||||
results.push(await refreshObject(objectId, { companyId: input.companyId, actor: input.actor }));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function refreshDueObjects(companyId: string, limit = 50, now = new Date()) {
|
||||
if (!(await isEnabled())) return [];
|
||||
const due = await db
|
||||
.select({ id: externalObjects.id })
|
||||
.from(externalObjects)
|
||||
.where(
|
||||
and(
|
||||
eq(externalObjects.companyId, companyId),
|
||||
eq(externalObjects.isTerminal, false),
|
||||
lte(externalObjects.nextRefreshAt, now),
|
||||
),
|
||||
)
|
||||
.limit(limit);
|
||||
const results = [];
|
||||
for (const row of due) {
|
||||
results.push(await refreshObject(row.id, {
|
||||
companyId,
|
||||
actor: { actorType: "system", actorId: "external-object-resolver", agentId: null, runId: null },
|
||||
now,
|
||||
}));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
return {
|
||||
syncIssue,
|
||||
syncComment,
|
||||
syncDocument,
|
||||
syncIssueSafely,
|
||||
syncCommentSafely,
|
||||
syncDocumentSafely,
|
||||
listForIssue,
|
||||
getIssueSummary,
|
||||
getIssueSummaries,
|
||||
getProjectSummary,
|
||||
refreshObject,
|
||||
refreshIssueObjects,
|
||||
refreshDueObjects,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,445 @@
|
|||
import type { Db } from "@paperclipai/db";
|
||||
import type { ExternalObjectCanonicalUrl } from "@paperclipai/shared";
|
||||
import { ghFetch, gitHubApiBase } from "./github-fetch.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import type {
|
||||
ExternalObjectDetection,
|
||||
ExternalObjectDetector,
|
||||
ExternalObjectResolver,
|
||||
ExternalObjectResolverSnapshot,
|
||||
ExternalObjectResolveResult,
|
||||
} from "./external-objects.js";
|
||||
|
||||
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface GitHubExternalObjectProviderOptions {
|
||||
fetch?: FetchLike;
|
||||
tokenProvider?: (companyId: string) => Promise<string | null> | string | null;
|
||||
secretNames?: readonly string[];
|
||||
}
|
||||
|
||||
interface GitHubObjectIdentity {
|
||||
host: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
number: number;
|
||||
objectType: "pull_request" | "issue";
|
||||
pathKind: "pull" | "issues";
|
||||
}
|
||||
|
||||
const DEFAULT_GITHUB_TOKEN_SECRET_NAMES = ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"] as const;
|
||||
const GITHUB_OBJECT_TTL_SECONDS = 300;
|
||||
|
||||
function isGitHubHost(host: string) {
|
||||
const h = host.toLowerCase();
|
||||
return h === "github.com" || h === "www.github.com";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function asString(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown) {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
function asNestedString(record: Record<string, unknown>, key: string, nestedKey: string) {
|
||||
const nested = asRecord(record[key]);
|
||||
return nested ? asString(nested[nestedKey]) : null;
|
||||
}
|
||||
|
||||
function parseGitHubCanonicalUrl(canonical: ExternalObjectCanonicalUrl): GitHubObjectIdentity | null {
|
||||
if (canonical.canonicalIdentity.scheme !== "https") return null;
|
||||
const host = canonical.canonicalIdentity.host.toLowerCase();
|
||||
if (!isGitHubHost(host)) return null;
|
||||
|
||||
const parts = canonical.canonicalIdentity.path.split("/").filter(Boolean);
|
||||
if (parts.length !== 4) return null;
|
||||
const [owner, repo, kind, rawNumber] = parts;
|
||||
if (!owner || !repo || !kind || !rawNumber) return null;
|
||||
if (!/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) return null;
|
||||
if (!/^[1-9][0-9]*$/.test(rawNumber)) return null;
|
||||
if (kind !== "pull" && kind !== "issues") return null;
|
||||
|
||||
return {
|
||||
host: host === "www.github.com" ? "github.com" : host,
|
||||
owner,
|
||||
repo,
|
||||
number: Number(rawNumber),
|
||||
pathKind: kind,
|
||||
objectType: kind === "pull" ? "pull_request" : "issue",
|
||||
};
|
||||
}
|
||||
|
||||
function parseGitHubObject(object: { externalId: string; sanitizedCanonicalUrl: string | null }): GitHubObjectIdentity | null {
|
||||
const match = /^([^/]+)\/([^/]+)#(pull|issues)\/([1-9][0-9]*)$/.exec(object.externalId);
|
||||
if (!match) return null;
|
||||
let host = "github.com";
|
||||
if (object.sanitizedCanonicalUrl) {
|
||||
try {
|
||||
const url = new URL(object.sanitizedCanonicalUrl);
|
||||
if (isGitHubHost(url.hostname)) host = url.hostname === "www.github.com" ? "github.com" : url.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
host,
|
||||
owner: match[1]!,
|
||||
repo: match[2]!,
|
||||
pathKind: match[3] as "pull" | "issues",
|
||||
number: Number(match[4]),
|
||||
objectType: match[3] === "pull" ? "pull_request" : "issue",
|
||||
};
|
||||
}
|
||||
|
||||
function externalIdFor(identity: GitHubObjectIdentity) {
|
||||
return `${identity.owner.toLowerCase()}/${identity.repo.toLowerCase()}#${identity.pathKind}/${identity.number}`;
|
||||
}
|
||||
|
||||
function displayTitleFor(identity: GitHubObjectIdentity) {
|
||||
return `${identity.owner}/${identity.repo}#${identity.number}`;
|
||||
}
|
||||
|
||||
function displayKeyFor(identity: Pick<GitHubObjectIdentity, "objectType">) {
|
||||
return identity.objectType === "pull_request" ? "GitHub Pull Request" : "GitHub Issue";
|
||||
}
|
||||
|
||||
function retryAfterSeconds(response: Response) {
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
if (retryAfter && /^[0-9]+$/.test(retryAfter)) return Number(retryAfter);
|
||||
|
||||
const reset = response.headers.get("x-ratelimit-reset");
|
||||
if (reset && /^[0-9]+$/.test(reset)) {
|
||||
return Math.max(1, Number(reset) - Math.floor(Date.now() / 1000));
|
||||
}
|
||||
|
||||
return 300;
|
||||
}
|
||||
|
||||
function failureFromGitHubResponse(response: Response): ExternalObjectResolveResult | null {
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
ok: false,
|
||||
liveness: "auth_required",
|
||||
errorCode: "github_auth_required",
|
||||
errorMessage: "GitHub authentication is required to refresh this object.",
|
||||
retryAfterSeconds: retryAfterSeconds(response),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
const rateLimitRemaining = response.headers.get("x-ratelimit-remaining");
|
||||
if (rateLimitRemaining === "0") {
|
||||
return {
|
||||
ok: false,
|
||||
liveness: "unreachable",
|
||||
errorCode: "github_rate_limited",
|
||||
errorMessage: "GitHub rate limit reached while refreshing this object.",
|
||||
retryAfterSeconds: retryAfterSeconds(response),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
liveness: "auth_required",
|
||||
errorCode: "github_forbidden",
|
||||
errorMessage: "GitHub rejected the configured credentials for this object.",
|
||||
retryAfterSeconds: retryAfterSeconds(response),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
return {
|
||||
ok: false,
|
||||
liveness: "unreachable",
|
||||
errorCode: response.status === 429 ? "github_rate_limited" : "github_unreachable",
|
||||
errorMessage: `GitHub returned HTTP ${response.status} while refreshing this object.`,
|
||||
retryAfterSeconds: retryAfterSeconds(response),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function notFoundSnapshot(identity: GitHubObjectIdentity, etag: string | null): ExternalObjectResolverSnapshot {
|
||||
return {
|
||||
displayKey: displayKeyFor(identity),
|
||||
iconKey: "github",
|
||||
displayTitle: displayTitleFor(identity),
|
||||
statusKey: "not_found",
|
||||
statusLabel: "Not found",
|
||||
statusIconKey: "archive",
|
||||
statusCategory: "archived",
|
||||
statusTone: "muted",
|
||||
isTerminal: true,
|
||||
etag,
|
||||
ttlSeconds: GITHUB_OBJECT_TTL_SECONDS,
|
||||
data: {
|
||||
provider: "github",
|
||||
owner: identity.owner,
|
||||
repo: identity.repo,
|
||||
number: identity.number,
|
||||
notFound: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pullRequestSnapshot(identity: GitHubObjectIdentity, body: Record<string, unknown>, etag: string | null): ExternalObjectResolverSnapshot {
|
||||
const title = asString(body.title);
|
||||
const state = asString(body.state) ?? "unknown";
|
||||
const draft = asBoolean(body.draft) ?? false;
|
||||
const merged = (asBoolean(body.merged) ?? false) || Boolean(asString(body.merged_at));
|
||||
const authorLogin = asNestedString(body, "user", "login");
|
||||
const headRef = asNestedString(body, "head", "ref");
|
||||
const baseRef = asNestedString(body, "base", "ref");
|
||||
const reviewDecision = asString(body.review_decision);
|
||||
|
||||
let statusKey = state;
|
||||
let statusLabel = state === "open" ? "Open" : state === "closed" ? "Closed" : "Unknown";
|
||||
let statusCategory: ExternalObjectResolverSnapshot["statusCategory"] = state === "open" ? "open" : "unknown";
|
||||
let statusTone: ExternalObjectResolverSnapshot["statusTone"] = state === "open" ? "info" : "neutral";
|
||||
let isTerminal = false;
|
||||
|
||||
if (merged) {
|
||||
statusKey = "merged";
|
||||
statusLabel = "Merged";
|
||||
statusCategory = "succeeded";
|
||||
statusTone = "success";
|
||||
isTerminal = true;
|
||||
} else if (state === "closed") {
|
||||
statusKey = "closed";
|
||||
statusLabel = "Closed";
|
||||
statusCategory = "closed";
|
||||
statusTone = "muted";
|
||||
isTerminal = true;
|
||||
} else if (draft) {
|
||||
statusKey = "draft";
|
||||
statusLabel = "Draft";
|
||||
statusCategory = "waiting";
|
||||
statusTone = "warning";
|
||||
}
|
||||
|
||||
return {
|
||||
displayKey: displayKeyFor(identity),
|
||||
iconKey: "github",
|
||||
displayTitle: title ? `${displayTitleFor(identity)}: ${title}` : displayTitleFor(identity),
|
||||
statusKey,
|
||||
statusLabel,
|
||||
statusIconKey: merged
|
||||
? "git-merge"
|
||||
: state === "closed"
|
||||
? "x-circle"
|
||||
: draft
|
||||
? "clock"
|
||||
: "git-pull-request",
|
||||
statusCategory,
|
||||
statusTone,
|
||||
isTerminal,
|
||||
remoteVersion: asString(body.updated_at),
|
||||
etag,
|
||||
ttlSeconds: GITHUB_OBJECT_TTL_SECONDS,
|
||||
data: {
|
||||
provider: "github",
|
||||
owner: identity.owner,
|
||||
repo: identity.repo,
|
||||
number: identity.number,
|
||||
state,
|
||||
merged,
|
||||
draft,
|
||||
...(authorLogin ? { authorLogin } : {}),
|
||||
...(headRef ? { headRef } : {}),
|
||||
...(baseRef ? { baseRef } : {}),
|
||||
...(reviewDecision ? { reviewDecision } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function issueSnapshot(identity: GitHubObjectIdentity, body: Record<string, unknown>, etag: string | null): ExternalObjectResolverSnapshot {
|
||||
const title = asString(body.title);
|
||||
const state = asString(body.state) ?? "unknown";
|
||||
const stateReason = asString(body.state_reason);
|
||||
const authorLogin = asNestedString(body, "user", "login");
|
||||
const statusKey = state === "closed" && stateReason ? `closed_${stateReason}` : state;
|
||||
const statusLabel = state === "closed"
|
||||
? stateReason
|
||||
? `Closed: ${stateReason.replace(/_/g, " ")}`
|
||||
: "Closed"
|
||||
: state === "open"
|
||||
? "Open"
|
||||
: "Unknown";
|
||||
|
||||
return {
|
||||
displayKey: displayKeyFor(identity),
|
||||
iconKey: "github",
|
||||
displayTitle: title ? `${displayTitleFor(identity)}: ${title}` : displayTitleFor(identity),
|
||||
statusKey,
|
||||
statusLabel,
|
||||
statusIconKey: state === "closed" ? "circle" : "circle-dot",
|
||||
statusCategory: state === "open" ? "open" : state === "closed" ? "closed" : "unknown",
|
||||
statusTone: state === "open" ? "info" : state === "closed" ? "muted" : "neutral",
|
||||
isTerminal: state === "closed",
|
||||
remoteVersion: asString(body.updated_at),
|
||||
etag,
|
||||
ttlSeconds: GITHUB_OBJECT_TTL_SECONDS,
|
||||
data: {
|
||||
provider: "github",
|
||||
owner: identity.owner,
|
||||
repo: identity.repo,
|
||||
number: identity.number,
|
||||
state,
|
||||
...(stateReason ? { stateReason } : {}),
|
||||
...(authorLogin ? { authorLogin } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function safeJson(response: Response) {
|
||||
try {
|
||||
return asRecord(await response.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultTokenProvider(db: Db, companyId: string, secretNames: readonly string[]) {
|
||||
const secrets = secretService(db);
|
||||
for (const secretName of secretNames) {
|
||||
const secret = await secrets.getByName(companyId, secretName);
|
||||
if (!secret) continue;
|
||||
const token = await secrets.resolveSecretValue(companyId, secret.id, "latest");
|
||||
const trimmed = token.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createGitHubExternalObjectProvider(
|
||||
db: Db,
|
||||
opts: GitHubExternalObjectProviderOptions = {},
|
||||
): { detector: ExternalObjectDetector; resolvers: ExternalObjectResolver[] } {
|
||||
const fetchImpl = opts.fetch ?? ghFetch;
|
||||
const secretNames = opts.secretNames ?? DEFAULT_GITHUB_TOKEN_SECRET_NAMES;
|
||||
const tokenProvider = Object.prototype.hasOwnProperty.call(opts, "tokenProvider") && opts.tokenProvider !== undefined
|
||||
? opts.tokenProvider
|
||||
: ((companyId: string) => defaultTokenProvider(db, companyId, secretNames));
|
||||
|
||||
const detector: ExternalObjectDetector = {
|
||||
key: "github",
|
||||
detect({ urls }): ExternalObjectDetection[] {
|
||||
return urls.flatMap((canonical) => {
|
||||
const identity = parseGitHubCanonicalUrl(canonical);
|
||||
if (!identity) return [];
|
||||
return [{
|
||||
canonical,
|
||||
detectorKey: "github",
|
||||
providerKey: "github",
|
||||
objectType: identity.objectType,
|
||||
externalId: externalIdFor(identity),
|
||||
displayKey: displayKeyFor(identity),
|
||||
iconKey: "github",
|
||||
displayTitle: displayTitleFor(identity),
|
||||
confidence: "exact",
|
||||
}];
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function resolver(objectType: GitHubObjectIdentity["objectType"]): ExternalObjectResolver {
|
||||
return {
|
||||
providerKey: "github",
|
||||
objectType,
|
||||
async resolve({ companyId, object }) {
|
||||
const identity = parseGitHubObject(object);
|
||||
if (!identity || identity.objectType !== objectType) {
|
||||
return {
|
||||
ok: false,
|
||||
liveness: "unreachable",
|
||||
errorCode: "github_invalid_identity",
|
||||
errorMessage: "GitHub object identity is invalid.",
|
||||
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = typeof tokenProvider === "function" ? await tokenProvider(companyId) : tokenProvider;
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
liveness: "auth_required",
|
||||
errorCode: "github_token_unavailable",
|
||||
errorMessage: "Configured GitHub credentials could not be resolved.",
|
||||
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
token = token?.trim() || null;
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/vnd.github+json",
|
||||
"user-agent": "paperclip-external-object-resolver",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
};
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
|
||||
const apiKind = objectType === "pull_request" ? "pulls" : "issues";
|
||||
const url = `${gitHubApiBase(identity.host)}/repos/${encodeURIComponent(identity.owner)}/${encodeURIComponent(identity.repo)}/${apiKind}/${identity.number}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(url, { headers });
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
liveness: "unreachable",
|
||||
errorCode: "github_fetch_failed",
|
||||
errorMessage: "GitHub could not be reached while refreshing this object.",
|
||||
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
const etag = response.headers.get("etag");
|
||||
if (response.status === 404) {
|
||||
return { ok: true, snapshot: notFoundSnapshot(identity, etag) };
|
||||
}
|
||||
|
||||
const failure = failureFromGitHubResponse(response);
|
||||
if (failure) return failure;
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
liveness: "unreachable",
|
||||
errorCode: "github_unexpected_response",
|
||||
errorMessage: `GitHub returned HTTP ${response.status} while refreshing this object.`,
|
||||
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
const body = await safeJson(response);
|
||||
if (!body) {
|
||||
return {
|
||||
ok: false,
|
||||
liveness: "unreachable",
|
||||
errorCode: "github_invalid_response",
|
||||
errorMessage: "GitHub returned an invalid object response.",
|
||||
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: objectType === "pull_request"
|
||||
? pullRequestSnapshot(identity, body, etag)
|
||||
: issueSnapshot(identity, body, etag),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
detector,
|
||||
resolvers: [resolver("pull_request"), resolver("issue")],
|
||||
};
|
||||
}
|
||||
|
|
@ -33,6 +33,15 @@ export {
|
|||
resolveTaskWatchdogMutationScope,
|
||||
taskWatchdogScopeAllowsIssueMutation,
|
||||
} from "./task-watchdog-scope.js";
|
||||
export {
|
||||
createExternalObjectDetectorRegistry,
|
||||
createExternalObjectResolverRegistry,
|
||||
externalObjectService,
|
||||
type ExternalObjectDetector,
|
||||
type ExternalObjectResolver,
|
||||
type ExternalObjectResolveResult,
|
||||
type ExternalObjectResolverSnapshot,
|
||||
} from "./external-objects.js";
|
||||
export { goalService } from "./goals.js";
|
||||
export { activityService, type ActivityFilters } from "./activity.js";
|
||||
export { approvalService } from "./approvals.js";
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
|
||||
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
|
||||
enableCloudSync: parsed.data.enableCloudSync ?? false,
|
||||
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours:
|
||||
|
|
@ -68,6 +69,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableCloudSync: false,
|
||||
enableExternalObjects: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours:
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
|||
"telemetry.track": ["telemetry.track"],
|
||||
"db.migrate": ["database.namespace.migrate"],
|
||||
"db.execute": ["database.namespace.write"],
|
||||
"external.objects.detect": ["external.objects.detect"],
|
||||
"external.objects.read": ["external.objects.read"],
|
||||
"external.objects.write": ["external.objects.write"],
|
||||
"external.objects.refresh": ["external.objects.refresh"],
|
||||
|
||||
// Plugin state operations
|
||||
"plugin.state.get": ["plugin.state.read"],
|
||||
|
|
@ -188,6 +192,7 @@ const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
|
|||
agents: "agents.managed",
|
||||
projects: "projects.managed",
|
||||
routines: "routines.managed",
|
||||
objectReferences: "external.objects.detect",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -452,6 +457,14 @@ export function pluginCapabilityValidator(): PluginCapabilityValidator {
|
|||
}
|
||||
}
|
||||
|
||||
if ((manifest.objectReferences?.length ?? 0) > 0) {
|
||||
for (const requiredCap of ["external.objects.detect", "external.objects.read"] as const) {
|
||||
if (!declared.has(requiredCap) && !allMissing.includes(requiredCap)) {
|
||||
allMissing.push(requiredCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check UI slots → required capabilities
|
||||
const uiSlots = manifest.ui?.slots ?? [];
|
||||
if (uiSlots.length > 0) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import type { ExternalObjectMentionGroup, ExternalObjectSummary } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const externalObjectsApi = {
|
||||
listForIssue: (issueId: string) =>
|
||||
api.get<ExternalObjectMentionGroup[]>(`/issues/${issueId}/external-objects`),
|
||||
getIssueSummary: (issueId: string) =>
|
||||
api.get<ExternalObjectSummary>(`/issues/${issueId}/external-object-summary`),
|
||||
getIssueSummaries: (companyId: string, issueIds: string[]) =>
|
||||
api.post<{ summaries: Record<string, ExternalObjectSummary> }>(
|
||||
`/companies/${companyId}/issues/external-object-summaries`,
|
||||
{ issueIds },
|
||||
),
|
||||
refreshIssueObjects: (issueId: string, data?: { objectIds?: string[] }) =>
|
||||
api.post<{ refreshed: unknown[] }>(`/issues/${issueId}/external-objects/refresh`, data ?? {}),
|
||||
getProjectSummary: (projectId: string) =>
|
||||
api.get<ExternalObjectSummary>(`/projects/${projectId}/external-object-summary`),
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@ export { companiesApi } from "./companies";
|
|||
export { agentsApi } from "./agents";
|
||||
export { projectsApi } from "./projects";
|
||||
export { issuesApi } from "./issues";
|
||||
export { externalObjectsApi } from "./externalObjects";
|
||||
export { routinesApi } from "./routines";
|
||||
export { goalsApi } from "./goals";
|
||||
export { approvalsApi } from "./approvals";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Approval } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { CommentThread } from "./CommentThread";
|
||||
|
||||
vi.mock("./MarkdownEditor", () => ({
|
||||
MarkdownEditor: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({
|
||||
InlineEntitySelector: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./ApprovalCard", () => ({
|
||||
ApprovalCard: ({ approval }: { approval: Approval }) => <div>{approval.type}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
PluginSlotOutlet: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("CommentThread external object decoration (integration)", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("decorates a resolved URL and leaves an unknown URL unchanged when rendered through the real MarkdownBody", () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MemoryRouter>
|
||||
<CommentThread
|
||||
comments={[{
|
||||
id: "comment-mixed",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
authorType: "user",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
body: [
|
||||
"Tracked: https://github.com/example/repo/pull/77",
|
||||
"Untracked: https://elsewhere.example.com/page",
|
||||
].join("\n\n"),
|
||||
createdAt: new Date("2026-04-24T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-24T12:00:00.000Z"),
|
||||
}]}
|
||||
externalReferences={{
|
||||
"https://github.com/example/repo/pull/77": {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "open",
|
||||
liveness: "fresh",
|
||||
statusLabel: "Open",
|
||||
displayTitle: "PR #77",
|
||||
},
|
||||
}}
|
||||
onAdd={async () => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const resolvedLink = container.querySelector(
|
||||
'a[href="https://github.com/example/repo/pull/77"]',
|
||||
);
|
||||
expect(resolvedLink, "resolved URL should be wrapped by the external-link decorator").not.toBeNull();
|
||||
expect(resolvedLink?.getAttribute("data-external-link")).toBe("resolved");
|
||||
expect(resolvedLink?.getAttribute("data-external-status")).toBe("open");
|
||||
expect(resolvedLink?.classList.contains("paperclip-markdown-external-ref")).toBe(true);
|
||||
|
||||
const unknownLink = container.querySelector(
|
||||
'a[href="https://elsewhere.example.com/page"]',
|
||||
);
|
||||
expect(unknownLink, "unknown URL should still render as a plain link").not.toBeNull();
|
||||
expect(unknownLink?.getAttribute("data-external-link")).toBeNull();
|
||||
expect(unknownLink?.classList.contains("paperclip-markdown-external-ref")).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,8 +9,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { CommentThread } from "./CommentThread";
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
MarkdownBody: ({
|
||||
children,
|
||||
className,
|
||||
externalReferences,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
externalReferences?: Record<string, unknown>;
|
||||
}) => (
|
||||
<div
|
||||
className={className}
|
||||
data-testid="markdown-body"
|
||||
data-external-reference-keys={externalReferences ? Object.keys(externalReferences).join(",") : ""}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
|
|
@ -395,4 +409,51 @@ describe("CommentThread", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("passes externalReferences to MarkdownBody for comment bodies", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<CommentThread
|
||||
comments={[{
|
||||
id: "comment-ref",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
authorType: "user",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
body: "See https://github.com/example/repo/pull/42 for context.",
|
||||
createdAt: new Date("2026-03-11T11:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T11:00:00.000Z"),
|
||||
}]}
|
||||
externalReferences={{
|
||||
"https://github.com/example/repo/pull/42": {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "open",
|
||||
liveness: "fresh",
|
||||
statusLabel: "Open",
|
||||
displayTitle: "PR #42",
|
||||
},
|
||||
}}
|
||||
onAdd={async () => {}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const commentRow = container.querySelector("#comment-comment-ref") as HTMLDivElement | null;
|
||||
expect(commentRow).not.toBeNull();
|
||||
const markdownBody = commentRow?.querySelector('[data-testid="markdown-body"]') as HTMLElement | null;
|
||||
expect(markdownBody?.getAttribute("data-external-reference-keys"))
|
||||
.toContain("https://github.com/example/repo/pull/42");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { ArrowRight, Check, Copy, Paperclip } from "lucide-react";
|
|||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Identity } from "./Identity";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||
import { ApprovalCard } from "./ApprovalCard";
|
||||
|
|
@ -105,6 +105,7 @@ interface CommentThreadProps {
|
|||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
composerDisabledReason?: string | null;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}
|
||||
|
||||
const DRAFT_DEBOUNCE_MS = 800;
|
||||
|
|
@ -324,6 +325,7 @@ function CommentCard({
|
|||
voting = false,
|
||||
highlightCommentId,
|
||||
queued = false,
|
||||
externalReferences,
|
||||
}: {
|
||||
comment: CommentWithRunMeta;
|
||||
agentMap?: Map<string, Agent>;
|
||||
|
|
@ -339,6 +341,7 @@ function CommentCard({
|
|||
voting?: boolean;
|
||||
highlightCommentId?: string | null;
|
||||
queued?: boolean;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
const isHighlighted = highlightCommentId === comment.id;
|
||||
const isPending = comment.clientStatus === "pending";
|
||||
|
|
@ -412,7 +415,7 @@ function CommentCard({
|
|||
{isDeleted ? (
|
||||
<div className="text-sm italic text-muted-foreground">Comment deleted</div>
|
||||
) : (
|
||||
<MarkdownBody className="text-sm" softBreaks>{comment.body}</MarkdownBody>
|
||||
<MarkdownBody className="text-sm" softBreaks externalReferences={externalReferences}>{comment.body}</MarkdownBody>
|
||||
)}
|
||||
{companyId && !isPending && !isDeleted ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
|
|
@ -575,6 +578,7 @@ const TimelineList = memo(function TimelineList({
|
|||
onVote,
|
||||
votingTargetId,
|
||||
highlightCommentId,
|
||||
externalReferences,
|
||||
}: {
|
||||
timeline: TimelineItem[];
|
||||
agentMap?: Map<string, Agent>;
|
||||
|
|
@ -597,6 +601,7 @@ const TimelineList = memo(function TimelineList({
|
|||
) => Promise<void>;
|
||||
votingTargetId?: string | null;
|
||||
highlightCommentId?: string | null;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
if (timeline.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No timeline entries yet.</p>;
|
||||
|
|
@ -718,6 +723,7 @@ const TimelineList = memo(function TimelineList({
|
|||
onVote={onVote ? (vote, options) => onVote(comment.id, vote, options) : undefined}
|
||||
voting={votingTargetId === comment.id}
|
||||
highlightCommentId={highlightCommentId}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -756,6 +762,7 @@ export function CommentThread({
|
|||
onInterruptQueued,
|
||||
interruptingQueuedRunId = null,
|
||||
composerDisabledReason = null,
|
||||
externalReferences,
|
||||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
|
@ -974,6 +981,7 @@ export function CommentThread({
|
|||
votingTargetId={votingTargetId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
feedbackTermsUrl={feedbackTermsUrl}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
|
||||
{liveRunSlot}
|
||||
|
|
@ -1006,6 +1014,7 @@ export function CommentThread({
|
|||
projectId={projectId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
queued
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ExternalObjectPill } from "./ExternalObjectPill";
|
||||
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
|
||||
|
||||
describe("ExternalObjectPill", () => {
|
||||
it("renders a clickable anchor when a URL is provided", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "succeeded",
|
||||
liveness: "fresh",
|
||||
displayTitle: "Add external refs",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
}}
|
||||
sourceCount={4}
|
||||
sourceSummary="description, 3 comments"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('href="https://github.com/acme/web/pull/241"');
|
||||
expect(html).toContain('data-mention-kind="external-object"');
|
||||
expect(html).toContain('data-external-status="succeeded"');
|
||||
expect(html).toContain('data-external-liveness="fresh"');
|
||||
expect(html).toContain("×4");
|
||||
expect(html).toContain('aria-label="GitHub pull request — Succeeded: Add external refs"');
|
||||
});
|
||||
|
||||
it("falls back to a non-interactive span when no URL is supplied", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: null,
|
||||
objectType: null,
|
||||
statusCategory: "unknown",
|
||||
liveness: "unknown",
|
||||
url: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-mention-kind="external-object"');
|
||||
expect(html).not.toContain("<a ");
|
||||
expect(html).toContain('aria-label="External object — Not yet resolved"');
|
||||
});
|
||||
|
||||
it("applies the dashed-border liveness overlay when stale or auth_required", () => {
|
||||
const stale = renderToStaticMarkup(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "failed",
|
||||
liveness: "stale",
|
||||
url: "https://github.com/acme/web/pull/242",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(stale).toContain("opacity-70");
|
||||
expect(stale).toContain("[border-style:dashed]");
|
||||
|
||||
const auth = renderToStaticMarkup(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "hubspot",
|
||||
objectType: "lead",
|
||||
statusCategory: "auth_required",
|
||||
liveness: "auth_required",
|
||||
url: "https://app.hubspot.com/leads/99",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(auth).toContain("[border-style:dashed]");
|
||||
});
|
||||
|
||||
it("does not show a source count when only a single mention is present", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "succeeded",
|
||||
liveness: "fresh",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
}}
|
||||
sourceCount={1}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("×");
|
||||
});
|
||||
|
||||
it("uses the object link label, provider icon, and visible status when supplied", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
displayKey: "Github Pull Request",
|
||||
iconKey: "github",
|
||||
statusCategory: "succeeded",
|
||||
statusIconKey: null,
|
||||
liveness: "fresh",
|
||||
statusLabel: "Merged",
|
||||
displayTitle: "acme/web#241: Add rich object presentation metadata",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Merged");
|
||||
expect(html).toContain("PR 241 - Merged");
|
||||
expect(html).not.toContain("acme/web#241</span>");
|
||||
expect(html).toContain("text-violet-600");
|
||||
expect(html).not.toContain("Github Pull Request");
|
||||
expect(html).toContain('aria-label="GitHub pull request — Merged: acme/web#241: Add rich object presentation metadata"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalObjectStatusSummary", () => {
|
||||
it("hides itself when there are no external objects", () => {
|
||||
const html = renderToStaticMarkup(<ExternalObjectStatusSummary summary={null} />);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("hides itself when the highest severity is muted", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ExternalObjectStatusSummary
|
||||
summary={{
|
||||
total: 2,
|
||||
byStatusCategory: { closed: 2 },
|
||||
byLiveness: { fresh: 2 },
|
||||
highestSeverity: "muted",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("shows the dominant-severity icon and count", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ExternalObjectStatusSummary
|
||||
summary={{
|
||||
total: 5,
|
||||
byStatusCategory: { failed: 3, succeeded: 2 },
|
||||
byLiveness: { fresh: 5 },
|
||||
highestSeverity: "danger",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [
|
||||
{ id: "a", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
|
||||
{ id: "b", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
|
||||
{ id: "c", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
|
||||
{ id: "d", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded", statusTone: "success", liveness: "fresh", isTerminal: true },
|
||||
{ id: "e", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded", statusTone: "success", liveness: "fresh", isTerminal: true },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-external-status="failed"');
|
||||
expect(html).toContain('data-external-tone="danger"');
|
||||
expect(html).toContain(">3<");
|
||||
expect(html).toContain("aria-label=\"External objects: 3 failed, 2 succeeded, 5 total\"");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
ExternalObjectLivenessState,
|
||||
ExternalObjectStatusCategory,
|
||||
} from "@paperclipai/shared";
|
||||
import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon";
|
||||
import {
|
||||
externalObjectStatusIcon,
|
||||
externalObjectStatusIconDefault,
|
||||
externalObjectLivenessOverlay,
|
||||
} from "../lib/status-colors";
|
||||
import {
|
||||
externalObjectCategoryLabel,
|
||||
externalObjectLivenessLabel,
|
||||
externalObjectIconForKey,
|
||||
externalObjectProviderLabel,
|
||||
externalObjectTypeLabel,
|
||||
} from "../lib/external-objects";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface ExternalObjectPillData {
|
||||
providerKey: string | null;
|
||||
objectType: string | null;
|
||||
displayKey?: string | null;
|
||||
iconKey?: string | null;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
liveness: ExternalObjectLivenessState;
|
||||
displayTitle?: string | null;
|
||||
statusLabel?: string | null;
|
||||
statusIconKey?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
function githubObjectLabel(url: string | null | undefined): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.hostname !== "github.com") return null;
|
||||
const [, owner, repo, kind, number] = parsed.pathname.split("/");
|
||||
if (!owner || !repo || !number) return null;
|
||||
if (kind === "pull") return `PR ${number}`;
|
||||
if (kind === "issues") return `Issue ${number}`;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function externalObjectValueLabel(
|
||||
object: ExternalObjectPillData,
|
||||
fallback: string,
|
||||
statusLabel: string,
|
||||
): string {
|
||||
const githubLabel = object.providerKey === "github" ? githubObjectLabel(object.url) : null;
|
||||
const base = githubLabel ?? object.displayTitle?.trim() ?? fallback;
|
||||
return statusLabel ? `${base} - ${statusLabel}` : base;
|
||||
}
|
||||
|
||||
function isMergedExternalObject(object: ExternalObjectPillData, statusLabel: string): boolean {
|
||||
return object.statusIconKey === "git-merge" || statusLabel.toLowerCase() === "merged";
|
||||
}
|
||||
|
||||
function externalObjectPillTone(object: ExternalObjectPillData, statusLabel: string): string {
|
||||
if (isMergedExternalObject(object, statusLabel)) {
|
||||
return "text-violet-600 border-violet-600 dark:text-violet-400 dark:border-violet-400";
|
||||
}
|
||||
return externalObjectStatusIcon[object.statusCategory] ?? externalObjectStatusIconDefault;
|
||||
}
|
||||
|
||||
function externalObjectStatusIconKey(
|
||||
object: ExternalObjectPillData,
|
||||
statusLabel: string,
|
||||
): string | null | undefined {
|
||||
if (isMergedExternalObject(object, statusLabel)) return object.statusIconKey ?? "git-merge";
|
||||
return object.statusIconKey;
|
||||
}
|
||||
|
||||
interface ExternalObjectPillProps {
|
||||
object: ExternalObjectPillData;
|
||||
/** Optional external mention count (renders as `×N` superscript when > 1). */
|
||||
sourceCount?: number;
|
||||
/** Optional source-mention summary used as the pill's `title` attribute. */
|
||||
sourceSummary?: string | null;
|
||||
className?: string;
|
||||
/** Optional rendered label override. Defaults to `provider object-type`. */
|
||||
children?: ReactNode;
|
||||
/**
|
||||
* If true the pill renders without a hover treatment (used inside
|
||||
* non-interactive contexts like the property panel). Defaults to false.
|
||||
*/
|
||||
inert?: boolean;
|
||||
/**
|
||||
* Hide the provider icon when the surrounding UI already names the provider.
|
||||
* The status icon still renders as the single state glyph inside the pill.
|
||||
*/
|
||||
showProviderIcon?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* External-object equivalent of `IssueReferencePill`. Same `paperclip-mention-chip`
|
||||
* base so external references feel native to readers (Jakob's Law).
|
||||
*/
|
||||
export function ExternalObjectPill({
|
||||
object,
|
||||
sourceCount,
|
||||
sourceSummary,
|
||||
className,
|
||||
children,
|
||||
inert,
|
||||
showProviderIcon = true,
|
||||
}: ExternalObjectPillProps) {
|
||||
const overlay = externalObjectLivenessOverlay[object.liveness] ?? "";
|
||||
const providerLabel = externalObjectProviderLabel(object.providerKey);
|
||||
const typeLabel = externalObjectTypeLabel(object.objectType);
|
||||
const displayKey = object.displayKey?.trim() || `${providerLabel} ${typeLabel}`;
|
||||
const statusLabel = object.statusLabel ?? externalObjectCategoryLabel(object.statusCategory);
|
||||
const tone = externalObjectPillTone(object, statusLabel);
|
||||
const valueLabel = externalObjectValueLabel(object, displayKey, statusLabel);
|
||||
const statusIconKey = externalObjectStatusIconKey(object, statusLabel);
|
||||
const livenessLabel = externalObjectLivenessLabel(object.liveness);
|
||||
const ProviderIcon = externalObjectIconForKey(object.iconKey);
|
||||
const ariaLabel = `${providerLabel} ${typeLabel} — ${statusLabel}${
|
||||
object.liveness === "fresh" || object.liveness === "unknown" ? "" : ` (${livenessLabel})`
|
||||
}${object.displayTitle ? `: ${object.displayTitle}` : ""}`;
|
||||
|
||||
const interactive = !inert && Boolean(object.url);
|
||||
const classNames = cn(
|
||||
"paperclip-mention-chip paperclip-mention-chip--external-object",
|
||||
"inline-flex max-w-full items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
|
||||
// Tone is applied as text classes only — the border style comes from the
|
||||
// overlay (dashed for stale/auth/unreachable).
|
||||
tone.split(" ").filter((c) => c.startsWith("text-")).join(" "),
|
||||
overlay,
|
||||
interactive
|
||||
&& "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring",
|
||||
className,
|
||||
);
|
||||
const titleAttr = sourceSummary
|
||||
? `${object.displayTitle ?? displayKey} — ${sourceSummary}`
|
||||
: object.displayTitle ?? displayKey;
|
||||
const labelText = children ?? (
|
||||
<>
|
||||
<ExternalObjectStatusIcon
|
||||
category={object.statusCategory}
|
||||
liveness={object.liveness}
|
||||
statusIconKey={statusIconKey}
|
||||
sizeClassName="h-3 w-3"
|
||||
label={`${providerLabel}: ${statusLabel}`}
|
||||
/>
|
||||
<span className="max-w-[16rem] truncate font-medium">{valueLabel}</span>
|
||||
</>
|
||||
);
|
||||
const countSuffix = typeof sourceCount === "number" && sourceCount > 1 ? (
|
||||
<span className="tabular-nums text-[10px] font-medium opacity-80">×{sourceCount}</span>
|
||||
) : null;
|
||||
const innerContent = (
|
||||
<>
|
||||
{showProviderIcon && ProviderIcon ? (
|
||||
<ProviderIcon aria-hidden="true" className="h-3 w-3 shrink-0" />
|
||||
) : null}
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
{labelText}
|
||||
</span>
|
||||
{countSuffix}
|
||||
</>
|
||||
);
|
||||
|
||||
if (interactive && object.url) {
|
||||
return (
|
||||
<a
|
||||
href={object.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-mention-kind="external-object"
|
||||
data-external-status={object.statusCategory}
|
||||
data-external-liveness={object.liveness}
|
||||
className={classNames}
|
||||
title={titleAttr}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{innerContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-mention-kind="external-object"
|
||||
data-external-status={object.statusCategory}
|
||||
data-external-liveness={object.liveness}
|
||||
className={classNames}
|
||||
title={titleAttr}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{innerContent}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import type {
|
||||
ExternalObjectLivenessState,
|
||||
ExternalObjectStatusCategory,
|
||||
} from "@paperclipai/shared";
|
||||
import { Clock } from "lucide-react";
|
||||
import {
|
||||
externalObjectStatusIcon,
|
||||
externalObjectStatusIconDefault,
|
||||
} from "../lib/status-colors";
|
||||
import {
|
||||
externalObjectCategoryLabel,
|
||||
externalObjectIconForCategory,
|
||||
externalObjectIconForKey,
|
||||
externalObjectLivenessLabel,
|
||||
} from "../lib/external-objects";
|
||||
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface ExternalObjectStatusIconProps {
|
||||
category: ExternalObjectStatusCategory;
|
||||
liveness: ExternalObjectLivenessState;
|
||||
statusIconKey?: string | null;
|
||||
/** Optional override label used in `aria-label` (e.g. provider-specific copy). */
|
||||
label?: string | null;
|
||||
className?: string;
|
||||
/** Tailwind size class — defaults to `h-3.5 w-3.5` (compact pill use). */
|
||||
sizeClassName?: string;
|
||||
/**
|
||||
* When true the icon renders inline at 12 px with the `mr-1`/`align`
|
||||
* adjustments used by `MarkdownIssueLink`. Used by the markdown decorator.
|
||||
*/
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure presentational icon for an external object's status. Combines:
|
||||
*
|
||||
* - A category icon (lucide) from `externalObjectIconForCategory`.
|
||||
* - Tone classes (text + border) from `externalObjectStatusIcon`.
|
||||
* - An overlaid clock micro-mark when liveness is `stale`.
|
||||
* - Reduced-motion-aware spinner for the `running` category.
|
||||
*
|
||||
* Never mounts plugin React; the host is the sole renderer of identity glyphs.
|
||||
*/
|
||||
export function ExternalObjectStatusIcon({
|
||||
category,
|
||||
liveness,
|
||||
statusIconKey,
|
||||
label,
|
||||
className,
|
||||
sizeClassName = "h-3.5 w-3.5",
|
||||
inline = false,
|
||||
}: ExternalObjectStatusIconProps) {
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const Icon = externalObjectIconForKey(statusIconKey) ?? externalObjectIconForCategory(category);
|
||||
const tone = statusIconKey === "git-merge"
|
||||
? "text-violet-600 border-violet-600 dark:text-violet-400 dark:border-violet-400"
|
||||
: externalObjectStatusIcon[category] ?? externalObjectStatusIconDefault;
|
||||
const livenessSuffix = liveness === "fresh" || liveness === "unknown"
|
||||
? ""
|
||||
: ` (${externalObjectLivenessLabel(liveness)})`;
|
||||
const ariaLabel = `${label ?? externalObjectCategoryLabel(category)}${livenessSuffix}`;
|
||||
|
||||
// The clock overlay needs a positioned wrapper. Inline mode keeps the icon
|
||||
// tight to the surrounding text; pill mode expects to size by sizeClassName.
|
||||
const wrapperBase = inline
|
||||
? "relative mr-1 inline-flex shrink-0 align-[-0.125em]"
|
||||
: "relative inline-flex shrink-0";
|
||||
const iconSize = inline ? "h-3 w-3" : sizeClassName;
|
||||
const isSpinner = category === "running";
|
||||
const animateClass = isSpinner && !reducedMotion ? "animate-spin" : "";
|
||||
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(wrapperBase, className)}
|
||||
>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={cn(iconSize, tone.split(" ").filter((c) => c.startsWith("text-")).join(" "), animateClass)}
|
||||
/>
|
||||
{liveness === "stale" ? (
|
||||
<Clock
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full bg-background text-muted-foreground",
|
||||
inline ? "" : "p-px",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import type { ExternalObjectSummary } from "@paperclipai/shared";
|
||||
import {
|
||||
dominantExternalObjectTone,
|
||||
externalObjectCategoryLabel,
|
||||
externalObjectDominantCount,
|
||||
externalObjectIconForCategory,
|
||||
externalObjectIconForKey,
|
||||
} from "../lib/external-objects";
|
||||
import { externalObjectStatusBadge, externalObjectStatusBadgeDefault } from "../lib/status-colors";
|
||||
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface ExternalObjectStatusSummaryProps {
|
||||
summary: ExternalObjectSummary | null | undefined;
|
||||
/** Compact mode trims everything down to the icon + count, no label text. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function dominantCategory(summary: ExternalObjectSummary): string {
|
||||
// Prefer the first object that matches the highestSeverity tone, since the
|
||||
// server has already ranked them server-side.
|
||||
const match = summary.objects.find((object) => object.statusTone === summary.highestSeverity);
|
||||
return match?.statusCategory ?? "unknown";
|
||||
}
|
||||
|
||||
function dominantObject(summary: ExternalObjectSummary) {
|
||||
return summary.objects.find((object) => object.statusTone === summary.highestSeverity) ?? null;
|
||||
}
|
||||
|
||||
function buildBreakdownTitle(summary: ExternalObjectSummary): string {
|
||||
const parts: string[] = [];
|
||||
for (const [category, count] of Object.entries(summary.byStatusCategory)) {
|
||||
if (!count) continue;
|
||||
parts.push(`${count} ${externalObjectCategoryLabel(category).toLowerCase()}`);
|
||||
}
|
||||
if (summary.staleCount > 0) parts.push(`${summary.staleCount} stale`);
|
||||
parts.push(`${summary.total} total`);
|
||||
return `External objects: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact rollup marker used by sidebar projects and issue list rows. Renders
|
||||
* the dominant severity icon plus a count badge. Hidden when there are zero
|
||||
* external objects or every object is in a muted tone.
|
||||
*/
|
||||
export function ExternalObjectStatusSummary({
|
||||
summary,
|
||||
compact,
|
||||
className,
|
||||
}: ExternalObjectStatusSummaryProps) {
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const tone = dominantExternalObjectTone(summary);
|
||||
const total = summary?.total ?? 0;
|
||||
if (!summary || total === 0 || !tone) return null;
|
||||
|
||||
const object = dominantObject(summary);
|
||||
const category = object?.statusCategory ?? dominantCategory(summary);
|
||||
const Icon = externalObjectIconForKey(object?.statusIconKey) ?? externalObjectIconForCategory(category);
|
||||
const badgeClass = externalObjectStatusBadge[category] ?? externalObjectStatusBadgeDefault;
|
||||
const dominantCount = externalObjectDominantCount(summary);
|
||||
const title = buildBreakdownTitle(summary);
|
||||
const animateClass = category === "running" && !reducedMotion ? "animate-spin" : "";
|
||||
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={title}
|
||||
title={title}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none",
|
||||
badgeClass,
|
||||
compact && "px-1 py-0.5",
|
||||
className,
|
||||
)}
|
||||
data-external-status={category}
|
||||
data-external-tone={tone}
|
||||
>
|
||||
<Icon aria-hidden="true" className={cn("h-3 w-3 shrink-0", animateClass)} />
|
||||
<span>{dominantCount > 0 ? dominantCount : total}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
import { FoldCurtain } from "./FoldCurtain";
|
||||
|
|
@ -19,6 +19,11 @@ interface InlineEditorProps {
|
|||
nullable?: boolean;
|
||||
/** When true, long display-mode markdown is clipped with a fade curtain that expands on click. */
|
||||
foldable?: boolean;
|
||||
/**
|
||||
* Optional host-resolved external object metadata. Forwarded to the read-mode
|
||||
* `MarkdownBody` so resolved URLs render with the inline status icon prefix.
|
||||
*/
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}
|
||||
|
||||
/** Shared padding so display and edit modes occupy the exact same box. */
|
||||
|
|
@ -55,6 +60,7 @@ export function InlineEditor({
|
|||
onDropFile,
|
||||
mentions,
|
||||
foldable = false,
|
||||
externalReferences,
|
||||
}: InlineEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [multilineEditing, setMultilineEditing] = useState(false);
|
||||
|
|
@ -288,12 +294,18 @@ export function InlineEditor({
|
|||
>
|
||||
{foldable ? (
|
||||
<FoldCurtain>
|
||||
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
|
||||
<MarkdownBody
|
||||
className={cn("paperclip-edit-in-place-content", className)}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
{previewValue}
|
||||
</MarkdownBody>
|
||||
</FoldCurtain>
|
||||
) : (
|
||||
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
|
||||
<MarkdownBody
|
||||
className={cn("paperclip-edit-in-place-content", className)}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
{previewValue}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
||||
import { WorkspaceFileMarkdownBody } from "./WorkspaceFileMarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||
import { Identity } from "./Identity";
|
||||
|
|
@ -217,6 +217,7 @@ interface IssueChatMessageContext {
|
|||
onUploadImage?: (file: File) => Promise<string>;
|
||||
issueStatus?: string;
|
||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}
|
||||
|
||||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||
|
|
@ -450,6 +451,7 @@ interface IssueChatThreadProps {
|
|||
* comment is in the loaded set before we scroll to it.
|
||||
*/
|
||||
onRefreshLatestComments?: () => Promise<unknown> | void;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}
|
||||
|
||||
type IssueChatErrorBoundaryProps = {
|
||||
|
|
@ -457,6 +459,7 @@ type IssueChatErrorBoundaryProps = {
|
|||
messages: readonly ThreadMessage[];
|
||||
emptyMessage: string;
|
||||
variant: "full" | "embedded";
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
|
|
@ -491,6 +494,7 @@ class IssueChatErrorBoundary extends Component<IssueChatErrorBoundaryProps, Issu
|
|||
messages={this.props.messages}
|
||||
emptyMessage={this.props.emptyMessage}
|
||||
variant={this.props.variant}
|
||||
externalReferences={this.props.externalReferences}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -555,10 +559,12 @@ function IssueChatFallbackThread({
|
|||
messages,
|
||||
emptyMessage,
|
||||
variant,
|
||||
externalReferences,
|
||||
}: {
|
||||
messages: readonly ThreadMessage[];
|
||||
emptyMessage: string;
|
||||
variant: "full" | "embedded";
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
||||
|
|
@ -599,7 +605,9 @@ function IssueChatFallbackThread({
|
|||
</div>
|
||||
<div className="space-y-2">
|
||||
{lines.length > 0 ? lines.map((line, index) => (
|
||||
<MarkdownBody key={`${message.id}:fallback:${index}`}>{line}</MarkdownBody>
|
||||
<MarkdownBody key={`${message.id}:fallback:${index}`} externalReferences={externalReferences}>
|
||||
{line}
|
||||
</MarkdownBody>
|
||||
)) : (
|
||||
<p className="text-sm text-muted-foreground">No message content.</p>
|
||||
)}
|
||||
|
|
@ -705,7 +713,7 @@ function commentDateLabel(date: Date | string | undefined): string {
|
|||
}
|
||||
|
||||
const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed, onAccent }: { text: string; recessed?: boolean; onAccent?: boolean }) {
|
||||
const { onImageClick } = useContext(IssueChatCtx);
|
||||
const { onImageClick, externalReferences } = useContext(IssueChatCtx);
|
||||
if (isSuccessfulRunHandoffComment(text)) {
|
||||
return <SuccessfulRunHandoffCommentCallout text={text} recessed={recessed} onImageClick={onImageClick} />;
|
||||
}
|
||||
|
|
@ -717,6 +725,7 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed, onAc
|
|||
style={recessed ? { opacity: 0.55 } : undefined}
|
||||
softBreaks
|
||||
onImageClick={onImageClick}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
{text}
|
||||
</WorkspaceFileMarkdownBody>
|
||||
|
|
@ -2113,6 +2122,7 @@ function ExpiredRequestConfirmationActivity({
|
|||
onRejectInteraction,
|
||||
onCancelInteraction,
|
||||
onUploadImage,
|
||||
externalReferences,
|
||||
} = useContext(IssueChatCtx);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasResolvedActor = Boolean(interaction.resolvedByAgentId || interaction.resolvedByUserId);
|
||||
|
|
@ -2193,6 +2203,7 @@ function ExpiredRequestConfirmationActivity({
|
|||
onRejectInteraction={onRejectInteraction}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
onUploadImage={onUploadImage}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -2620,6 +2631,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
|||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
onUploadImage,
|
||||
externalReferences,
|
||||
} = useContext(IssueChatCtx);
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
|
|
@ -2677,6 +2689,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
|||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
onUploadImage={onUploadImage}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4094,6 +4107,7 @@ export function IssueChatThread({
|
|||
assigneeUserId = null,
|
||||
onResumeFromBacklog,
|
||||
resumeFromBacklogPending = false,
|
||||
externalReferences,
|
||||
}: IssueChatThreadProps) {
|
||||
const location = useLocation();
|
||||
const lastScrolledHashRef = useRef<string | null>(null);
|
||||
|
|
@ -4618,6 +4632,7 @@ export function IssueChatThread({
|
|||
onUploadImage: stableOnUploadImage,
|
||||
issueStatus,
|
||||
successfulRunHandoff,
|
||||
externalReferences,
|
||||
}),
|
||||
[
|
||||
feedbackDataSharingPreference,
|
||||
|
|
@ -4643,6 +4658,7 @@ export function IssueChatThread({
|
|||
stableOnUploadImage,
|
||||
issueStatus,
|
||||
successfulRunHandoff,
|
||||
externalReferences,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -4680,6 +4696,7 @@ export function IssueChatThread({
|
|||
messages={messages}
|
||||
emptyMessage={resolvedEmptyMessage}
|
||||
variant={variant}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
<div data-testid="thread-root">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -3,17 +3,19 @@ import type { IssueDocument } from "@paperclipai/shared";
|
|||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
||||
import { Check, ChevronDown, ChevronRight, Copy, History } from "lucide-react";
|
||||
|
||||
type IssueContinuationHandoffProps = {
|
||||
document: IssueDocument | null | undefined;
|
||||
focusSignal?: number;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
};
|
||||
|
||||
export function IssueContinuationHandoff({
|
||||
document,
|
||||
focusSignal = 0,
|
||||
externalReferences,
|
||||
}: IssueContinuationHandoffProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
|
@ -91,7 +93,11 @@ export function IssueContinuationHandoff({
|
|||
</div>
|
||||
{expanded ? (
|
||||
<div className="mt-3 rounded-md border border-border bg-background/80 p-3">
|
||||
<MarkdownBody className="paperclip-edit-in-place-content text-sm leading-6" softBreaks={false}>
|
||||
<MarkdownBody
|
||||
className="paperclip-edit-in-place-content text-sm leading-6"
|
||||
softBreaks={false}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
{document.body}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,8 +43,22 @@ vi.mock("@/lib/router", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children, className }: { children: string; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
MarkdownBody: ({
|
||||
children,
|
||||
className,
|
||||
externalReferences,
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
externalReferences?: Record<string, unknown>;
|
||||
}) => (
|
||||
<div
|
||||
className={className}
|
||||
data-testid="markdown-body"
|
||||
data-external-reference-keys={externalReferences ? Object.keys(externalReferences).join(",") : ""}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
|
|
@ -684,6 +698,61 @@ describe("IssueDocumentsSection", () => {
|
|||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("forwards externalReferences to the rendered document body so URL decoration applies", async () => {
|
||||
const issue = createIssue();
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
mockIssuesApi.listDocuments.mockResolvedValue([
|
||||
createIssueDocument({
|
||||
body: "Linked work: https://github.com/example/repo/pull/99",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentsSection
|
||||
issue={issue}
|
||||
canDeleteDocuments={false}
|
||||
externalReferences={{
|
||||
"https://github.com/example/repo/pull/99": {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "open",
|
||||
liveness: "fresh",
|
||||
statusLabel: "Open",
|
||||
displayTitle: "PR #99",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const markdownBodies = Array.from(
|
||||
container.querySelectorAll('[data-testid="markdown-body"]'),
|
||||
) as HTMLElement[];
|
||||
expect(markdownBodies.length).toBeGreaterThan(0);
|
||||
const rendered = markdownBodies.find((element) =>
|
||||
(element.textContent ?? "").includes("Linked work"),
|
||||
);
|
||||
expect(rendered?.getAttribute("data-external-reference-keys"))
|
||||
.toContain("https://github.com/example/repo/pull/99");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("wraps the documents header actions so mobile layouts do not overflow", async () => {
|
||||
const issue = createIssue();
|
||||
const root = createRoot(container);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { FoldCurtain } from "./FoldCurtain";
|
||||
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -75,10 +75,16 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
|
|||
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
|
||||
}
|
||||
|
||||
function renderFoldableBody(body: string, className?: string) {
|
||||
function renderFoldableBody(
|
||||
body: string,
|
||||
className?: string,
|
||||
externalReferences?: MarkdownExternalReferenceMap,
|
||||
) {
|
||||
return (
|
||||
<FoldCurtain>
|
||||
<MarkdownBody className={className} softBreaks={false}>{body}</MarkdownBody>
|
||||
<MarkdownBody className={className} softBreaks={false} externalReferences={externalReferences}>
|
||||
{body}
|
||||
</MarkdownBody>
|
||||
</FoldCurtain>
|
||||
);
|
||||
}
|
||||
|
|
@ -161,6 +167,7 @@ export function IssueDocumentsSection({
|
|||
defaultAnnotationPanelOpenKeys,
|
||||
defaultAnnotationFocusedThreadIds,
|
||||
forceEditDocumentKey,
|
||||
externalReferences,
|
||||
}: {
|
||||
issue: Issue;
|
||||
canDeleteDocuments: boolean;
|
||||
|
|
@ -187,6 +194,7 @@ export function IssueDocumentsSection({
|
|||
defaultAnnotationFocusedThreadIds?: Readonly<Record<string, string>>;
|
||||
/** Force a doc into edit mode on mount (Storybook-only). */
|
||||
forceEditDocumentKey?: string | null;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
|
|
@ -755,6 +763,7 @@ export function IssueDocumentsSection({
|
|||
}, [autosaveState, commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
|
||||
|
||||
const documentBodyShellClassName = "mt-3";
|
||||
const documentBodyPaddingClassName = "";
|
||||
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
|
||||
const toggleFoldedDocument = (key: string) => {
|
||||
setFoldedDocumentKeys((current) =>
|
||||
|
|
@ -878,7 +887,9 @@ export function IssueDocumentsSection({
|
|||
PLAN
|
||||
</span>
|
||||
</div>
|
||||
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
|
||||
<div className={documentBodyPaddingClassName}>
|
||||
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName, externalReferences)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1197,7 +1208,7 @@ export function IssueDocumentsSection({
|
|||
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
|
||||
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
|
||||
) : null}
|
||||
{renderFoldableBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
|
||||
{renderFoldableBody(activeConflict.serverDocument.body, "text-[14px] leading-7", externalReferences)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1235,7 +1246,7 @@ export function IssueDocumentsSection({
|
|||
defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]}
|
||||
>
|
||||
{isHistoricalPreview ? (
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences)
|
||||
) : activeDraft ? (
|
||||
<MarkdownEditor
|
||||
value={displayedBody}
|
||||
|
|
@ -1257,7 +1268,7 @@ export function IssueDocumentsSection({
|
|||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
) : (
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences)
|
||||
)}
|
||||
</IssueDocumentAnnotations>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { PriorityIcon } from "./PriorityIcon";
|
|||
import { StatusIcon } from "./StatusIcon";
|
||||
import {
|
||||
defaultIssueFilterState,
|
||||
externalObjectFilterLabel,
|
||||
externalObjectFilterOrder,
|
||||
issueFilterArraysEqual,
|
||||
issueFilterLabel,
|
||||
issuePriorityOrder,
|
||||
|
|
@ -17,6 +19,8 @@ import {
|
|||
toggleIssueFilterValue,
|
||||
type IssueFilterState,
|
||||
} from "../lib/issue-filters";
|
||||
import { externalObjectIconForCategory } from "../lib/external-objects";
|
||||
import { externalObjectStatusIcon } from "../lib/status-colors";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
|
||||
type AgentOption = {
|
||||
|
|
@ -55,6 +59,7 @@ export function IssueFiltersPopover({
|
|||
projects,
|
||||
labels,
|
||||
currentUserId,
|
||||
enableExternalObjectFilters = true,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
buttonVariant = "ghost",
|
||||
iconOnly = false,
|
||||
|
|
@ -68,6 +73,7 @@ export function IssueFiltersPopover({
|
|||
projects?: ProjectOption[];
|
||||
labels?: LabelOption[];
|
||||
currentUserId?: string | null;
|
||||
enableExternalObjectFilters?: boolean;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
buttonVariant?: "ghost" | "outline";
|
||||
iconOnly?: boolean;
|
||||
|
|
@ -344,6 +350,39 @@ export function IssueFiltersPopover({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{enableExternalObjectFilters ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">External object status</span>
|
||||
<div className="space-y-0.5">
|
||||
{externalObjectFilterOrder.map((value) => {
|
||||
const iconCategory = value === "failed" ? "failed"
|
||||
: value === "waiting" ? "waiting"
|
||||
: value === "running" ? "running"
|
||||
: value === "auth_required" ? "auth_required"
|
||||
: value === "unreachable" ? "unreachable"
|
||||
: value === "stale" ? "unknown"
|
||||
: "closed";
|
||||
const Icon = externalObjectIconForCategory(iconCategory);
|
||||
const tone = externalObjectStatusIcon[iconCategory] ?? "";
|
||||
const textTone = tone.split(" ").filter((c) => c.startsWith("text-")).join(" ");
|
||||
return (
|
||||
<label
|
||||
key={value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={state.externalObjectStatuses.includes(value)}
|
||||
onCheckedChange={() => onChange({ externalObjectStatuses: toggleIssueFilterValue(state.externalObjectStatuses, value) })}
|
||||
/>
|
||||
<Icon className={`h-3.5 w-3.5 shrink-0 ${textTone}`} aria-hidden="true" />
|
||||
<span className="text-sm">{externalObjectFilterLabel(value)}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Visibility</span>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
|
|
|
|||
|
|
@ -1797,4 +1797,81 @@ describe("IssueProperties", () => {
|
|||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders each external object as its own properties row using display metadata", async () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue(),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
inline: true,
|
||||
externalObjects: [
|
||||
{
|
||||
mentionCount: 1,
|
||||
sourceLabels: ["Description"],
|
||||
pill: {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
displayKey: null,
|
||||
iconKey: "github",
|
||||
statusCategory: "succeeded",
|
||||
statusIconKey: null,
|
||||
statusLabel: "Merged",
|
||||
liveness: "fresh",
|
||||
displayTitle: "acme/web#241: Add rich object presentation metadata",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
},
|
||||
group: {
|
||||
object: null,
|
||||
mentions: [],
|
||||
mentionCount: 1,
|
||||
sourceLabels: ["Description"],
|
||||
},
|
||||
},
|
||||
{
|
||||
mentionCount: 1,
|
||||
sourceLabels: ["Comment"],
|
||||
pill: {
|
||||
providerKey: "github",
|
||||
objectType: "issue",
|
||||
displayKey: "Github Issue",
|
||||
iconKey: "github",
|
||||
statusCategory: "open",
|
||||
statusIconKey: "circle-dot",
|
||||
statusLabel: "Open",
|
||||
liveness: "fresh",
|
||||
displayTitle: "acme/web#12: Follow-up",
|
||||
url: "https://github.com/acme/web/issues/12",
|
||||
},
|
||||
group: {
|
||||
object: null,
|
||||
mentions: [],
|
||||
mentionCount: 1,
|
||||
sourceLabels: ["Comment"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Github Pull Request");
|
||||
expect(container.textContent).toContain("Github Issue");
|
||||
expect(container.textContent).toContain("PR 241 - Merged");
|
||||
expect(container.textContent).toContain("Merged");
|
||||
expect(container.textContent).toContain("Open");
|
||||
expect(container.textContent).not.toContain("External objects");
|
||||
const label = Array.from(container.querySelectorAll("span"))
|
||||
.find((span) => span.textContent === "Github Pull Request");
|
||||
expect(label?.querySelector("svg")).toBeTruthy();
|
||||
const pullRequestLink = Array.from(container.querySelectorAll("a"))
|
||||
.find((anchor) => anchor.getAttribute("href") === "https://github.com/acme/web/pull/241");
|
||||
expect(pullRequestLink?.textContent).toContain("PR 241 - Merged");
|
||||
expect(pullRequestLink?.textContent).not.toContain("acme/web#241");
|
||||
expect(pullRequestLink?.textContent).not.toContain("Github Pull Request");
|
||||
expect(pullRequestLink?.querySelectorAll("svg")).toHaveLength(1);
|
||||
expect(pullRequestLink?.className).not.toContain("paperclip-mention-chip");
|
||||
expect(pullRequestLink?.className).not.toContain("rounded-full");
|
||||
expect(pullRequestLink?.className).not.toContain("border");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,6 +35,19 @@ import { PriorityIcon } from "./PriorityIcon";
|
|||
import { Identity } from "./Identity";
|
||||
import { IssueReferencePill } from "./IssueReferencePill";
|
||||
import { formatDate, formatDateTime, cn, projectUrl } from "../lib/utils";
|
||||
import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon";
|
||||
import type { IssueExternalObjectGroup } from "../hooks/useIssueExternalObjects";
|
||||
import {
|
||||
externalObjectCategoryLabel,
|
||||
externalObjectIconForKey,
|
||||
externalObjectProviderLabel,
|
||||
externalObjectToneSeverity,
|
||||
externalObjectTypeLabel,
|
||||
} from "../lib/external-objects";
|
||||
import {
|
||||
externalObjectStatusIcon,
|
||||
externalObjectStatusIconDefault,
|
||||
} from "../lib/status-colors";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
|
|
@ -153,20 +166,173 @@ interface IssuePropertiesProps {
|
|||
/** Whether an agent run is currently in flight on this issue, so the assignee
|
||||
* picker can warn that reassigning will interrupt it. */
|
||||
hasActiveRun?: boolean;
|
||||
externalObjects?: IssueExternalObjectGroup[];
|
||||
externalObjectsLoading?: boolean;
|
||||
externalObjectsError?: boolean;
|
||||
onRetryExternalObjects?: () => void;
|
||||
}
|
||||
|
||||
const ISSUE_BLOCKER_SEARCH_LIMIT = 50;
|
||||
const ISSUE_PROPERTY_RELATION_PREVIEW_COUNT = 5;
|
||||
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function PropertyRow({
|
||||
label,
|
||||
children,
|
||||
labelClassName,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
labelClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20 mt-0.5">{label}</span>
|
||||
<span className={cn("text-xs text-muted-foreground shrink-0 w-20 mt-0.5", labelClassName)}>{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1 flex-wrap">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sortExternalObjectGroups(groups: IssueExternalObjectGroup[]) {
|
||||
return [...groups].sort((a, b) => {
|
||||
const aTone = externalObjectToneSeverity(a.group.object?.statusTone);
|
||||
const bTone = externalObjectToneSeverity(b.group.object?.statusTone);
|
||||
return bTone - aTone;
|
||||
});
|
||||
}
|
||||
|
||||
function externalObjectRowDisplayKey(group: IssueExternalObjectGroup): string {
|
||||
const { pill } = group;
|
||||
const displayKey = pill.displayKey?.trim();
|
||||
if (displayKey) return displayKey;
|
||||
if (pill.providerKey === "github") {
|
||||
if (pill.objectType === "pull_request") return "Github Pull Request";
|
||||
if (pill.objectType === "issue") return "Github Issue";
|
||||
}
|
||||
return `${externalObjectProviderLabel(pill.providerKey)} ${externalObjectTypeLabel(pill.objectType)}`;
|
||||
}
|
||||
|
||||
function externalObjectRowLabel(group: IssueExternalObjectGroup): React.ReactNode {
|
||||
const { pill } = group;
|
||||
const displayKey = externalObjectRowDisplayKey(group);
|
||||
const Icon = externalObjectIconForKey(pill.iconKey);
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-start gap-1">
|
||||
{Icon ? <Icon aria-hidden="true" className="h-3 w-3 shrink-0 mt-0.5" /> : null}
|
||||
<span className="whitespace-normal break-words leading-tight">{displayKey}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function githubObjectPropertyValue(url: string | null | undefined): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.hostname !== "github.com") return null;
|
||||
const [, owner, repo, kind, number] = parsed.pathname.split("/");
|
||||
if (!owner || !repo || !number) return null;
|
||||
if (kind === "pull") return `PR ${number}`;
|
||||
if (kind === "issues") return `Issue ${number}`;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function externalObjectPropertyStatusLabel(group: IssueExternalObjectGroup): string {
|
||||
return group.pill.statusLabel ?? externalObjectCategoryLabel(group.pill.statusCategory);
|
||||
}
|
||||
|
||||
function externalObjectPropertyValue(group: IssueExternalObjectGroup): string {
|
||||
const { pill } = group;
|
||||
const statusLabel = externalObjectPropertyStatusLabel(group);
|
||||
const githubLabel = pill.providerKey === "github" ? githubObjectPropertyValue(pill.url) : null;
|
||||
const base = githubLabel ?? pill.displayTitle?.trim() ?? externalObjectRowDisplayKey(group);
|
||||
return statusLabel ? `${base} - ${statusLabel}` : base;
|
||||
}
|
||||
|
||||
function isMergedExternalObject(group: IssueExternalObjectGroup): boolean {
|
||||
const statusLabel = externalObjectPropertyStatusLabel(group);
|
||||
return group.pill.statusIconKey === "git-merge" || statusLabel.toLowerCase() === "merged";
|
||||
}
|
||||
|
||||
function externalObjectPropertyTone(group: IssueExternalObjectGroup): string {
|
||||
if (isMergedExternalObject(group)) {
|
||||
return "text-violet-600 dark:text-violet-400";
|
||||
}
|
||||
const tone = externalObjectStatusIcon[group.pill.statusCategory] ?? externalObjectStatusIconDefault;
|
||||
return tone.split(" ").filter((c) => c.startsWith("text-")).join(" ");
|
||||
}
|
||||
|
||||
function externalObjectPropertyStatusIconKey(group: IssueExternalObjectGroup): string | null | undefined {
|
||||
if (isMergedExternalObject(group)) return group.pill.statusIconKey ?? "git-merge";
|
||||
return group.pill.statusIconKey;
|
||||
}
|
||||
|
||||
function externalObjectPropertyTitle(group: IssueExternalObjectGroup): string {
|
||||
const { pill, sourceLabels } = group;
|
||||
const base = pill.displayTitle ?? externalObjectPropertyValue(group);
|
||||
return sourceLabels.length > 0 ? `${base} - ${sourceLabels.join(", ")}` : base;
|
||||
}
|
||||
|
||||
function ExternalObjectPropertyValue({ group }: { group: IssueExternalObjectGroup }) {
|
||||
const { pill, mentionCount } = group;
|
||||
const statusLabel = externalObjectPropertyStatusLabel(group);
|
||||
const providerLabel = externalObjectProviderLabel(pill.providerKey);
|
||||
const typeLabel = externalObjectTypeLabel(pill.objectType);
|
||||
const value = externalObjectPropertyValue(group);
|
||||
const content = (
|
||||
<>
|
||||
<ExternalObjectStatusIcon
|
||||
category={pill.statusCategory}
|
||||
liveness={pill.liveness}
|
||||
statusIconKey={externalObjectPropertyStatusIconKey(group)}
|
||||
sizeClassName="h-3 w-3"
|
||||
label={`${providerLabel}: ${statusLabel}`}
|
||||
/>
|
||||
<span className="min-w-0 truncate">{value}</span>
|
||||
{mentionCount > 1 ? (
|
||||
<span className="tabular-nums text-[10px] font-medium opacity-80">x{mentionCount}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const className = cn(
|
||||
"inline-flex min-w-0 max-w-full items-center gap-1 text-xs font-medium no-underline",
|
||||
externalObjectPropertyTone(group),
|
||||
pill.url ? "hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring" : "",
|
||||
);
|
||||
|
||||
if (pill.url) {
|
||||
return (
|
||||
<a
|
||||
href={pill.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-mention-kind="external-object"
|
||||
data-external-status={pill.statusCategory}
|
||||
data-external-liveness={pill.liveness}
|
||||
className={className}
|
||||
title={externalObjectPropertyTitle(group)}
|
||||
aria-label={`${providerLabel} ${typeLabel} - ${statusLabel}: ${pill.displayTitle ?? value}`}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-mention-kind="external-object"
|
||||
data-external-status={pill.statusCategory}
|
||||
data-external-liveness={pill.liveness}
|
||||
className={className}
|
||||
title={externalObjectPropertyTitle(group)}
|
||||
aria-label={`${providerLabel} ${typeLabel} - ${statusLabel}: ${pill.displayTitle ?? value}`}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
||||
claude_local: [
|
||||
{ value: "", label: "Default" },
|
||||
|
|
@ -414,6 +580,10 @@ export function IssueProperties({
|
|||
onUpdate,
|
||||
inline,
|
||||
hasActiveRun = false,
|
||||
externalObjects,
|
||||
externalObjectsLoading,
|
||||
externalObjectsError,
|
||||
onRetryExternalObjects,
|
||||
}: IssuePropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -2168,6 +2338,46 @@ export function IssueProperties({
|
|||
{projectContent}
|
||||
</PropertyPicker>
|
||||
|
||||
{externalObjectsError ? (
|
||||
<PropertyRow label="External objects">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Couldn't load external objects.
|
||||
{onRetryExternalObjects ? (
|
||||
<>
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
onClick={onRetryExternalObjects}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</PropertyRow>
|
||||
) : externalObjectsLoading ? (
|
||||
<PropertyRow label="External objects">
|
||||
<span className="h-4 w-24 animate-pulse rounded bg-muted/40" />
|
||||
</PropertyRow>
|
||||
) : externalObjects && externalObjects.length > 0 ? (
|
||||
<>
|
||||
{sortExternalObjectGroups(externalObjects)
|
||||
.map((externalObject) => {
|
||||
const { pill, group } = externalObject;
|
||||
return (
|
||||
<PropertyRow
|
||||
key={group.object?.id ?? `${pill.providerKey}:${pill.objectType}:${pill.url ?? "anon"}`}
|
||||
label={externalObjectRowLabel(externalObject)}
|
||||
labelClassName="w-20 max-w-20 whitespace-normal leading-tight"
|
||||
>
|
||||
<ExternalObjectPropertyValue group={externalObject} />
|
||||
</PropertyRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Parent"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type { IssueRelatedWorkItem, IssueRelatedWorkSummary } from "@paperclipai/shared";
|
||||
import { IssueReferencePill } from "./IssueReferencePill";
|
||||
import { ExternalObjectPill } from "./ExternalObjectPill";
|
||||
import type { IssueExternalObjectGroup } from "../hooks/useIssueExternalObjects";
|
||||
import { externalObjectToneSeverity } from "../lib/external-objects";
|
||||
|
||||
type GroupedSource = {
|
||||
label: string;
|
||||
|
|
@ -83,10 +86,103 @@ function Section({
|
|||
);
|
||||
}
|
||||
|
||||
function ExternalObjectsSection({
|
||||
groups,
|
||||
isLoading,
|
||||
isError,
|
||||
onRetry,
|
||||
}: {
|
||||
groups: IssueExternalObjectGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
// Severity-first sort with most-recently-changed as the secondary sort.
|
||||
const sorted = [...groups].sort((a, b) => {
|
||||
const aTone = externalObjectToneSeverity(a.pill.statusCategory ? a.group.object?.statusTone ?? null : null);
|
||||
const bTone = externalObjectToneSeverity(b.pill.statusCategory ? b.group.object?.statusTone ?? null : null);
|
||||
if (aTone !== bTone) return bTone - aTone;
|
||||
const aChanged = a.group.object?.lastChangedAt ?? a.group.object?.lastResolvedAt ?? "";
|
||||
const bChanged = b.group.object?.lastChangedAt ?? b.group.object?.lastResolvedAt ?? "";
|
||||
return aChanged < bChanged ? 1 : aChanged > bChanged ? -1 : 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border border-border p-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">External objects</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remote work referenced from this issue — pull requests, deployments, tickets in other systems, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Couldn't load external objects.{" "}
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Loading external objects…</p>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This issue does not reference any external objects yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="-mx-1 flex flex-col">
|
||||
{sorted.map(({ pill, mentionCount, sourceLabels, group }) => {
|
||||
const object = group.object;
|
||||
return (
|
||||
<li
|
||||
key={object?.id ?? `${pill.providerKey}:${pill.objectType}:${pill.url ?? "anon"}`}
|
||||
className="flex flex-wrap items-center gap-x-2 gap-y-1.5 rounded-md px-1 py-1.5 hover:bg-accent/40"
|
||||
>
|
||||
<ExternalObjectPill object={pill} sourceCount={mentionCount} sourceSummary={sourceLabels.join(", ")} />
|
||||
{pill.displayTitle ? (
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||
{pill.displayTitle}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{sourceLabels.map((label) => (
|
||||
<span
|
||||
key={`${object?.id ?? pill.url ?? label}:${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"
|
||||
>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueRelatedWorkPanel({
|
||||
relatedWork,
|
||||
externalObjectsEnabled = true,
|
||||
externalObjects,
|
||||
externalObjectsLoading,
|
||||
externalObjectsError,
|
||||
onRetryExternalObjects,
|
||||
}: {
|
||||
relatedWork?: IssueRelatedWorkSummary | null;
|
||||
externalObjectsEnabled?: boolean;
|
||||
externalObjects?: IssueExternalObjectGroup[];
|
||||
externalObjectsLoading?: boolean;
|
||||
externalObjectsError?: boolean;
|
||||
onRetryExternalObjects?: () => void;
|
||||
}) {
|
||||
const outbound = relatedWork?.outbound ?? [];
|
||||
const inbound = relatedWork?.inbound ?? [];
|
||||
|
|
@ -99,6 +195,14 @@ export function IssueRelatedWorkPanel({
|
|||
items={outbound}
|
||||
emptyLabel="This task does not reference any other tasks yet."
|
||||
/>
|
||||
{externalObjectsEnabled ? (
|
||||
<ExternalObjectsSection
|
||||
groups={externalObjects ?? []}
|
||||
isLoading={Boolean(externalObjectsLoading)}
|
||||
isError={Boolean(externalObjectsError)}
|
||||
onRetry={onRetryExternalObjects}
|
||||
/>
|
||||
) : null}
|
||||
<Section
|
||||
title="Referenced by"
|
||||
description="Other tasks that currently point at this task."
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { Issue, IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import type { ExternalObjectSummary, Issue, IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Eye, Flag, X } from "lucide-react";
|
||||
import {
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { StatusIcon } from "./StatusIcon";
|
||||
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
|
||||
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
|
||||
|
||||
type UnreadState = "hidden" | "visible" | "fading";
|
||||
|
||||
|
|
@ -28,6 +29,11 @@ interface IssueRowProps {
|
|||
desktopLeadingSpacer?: boolean;
|
||||
mobileMeta?: ReactNode;
|
||||
desktopTrailing?: ReactNode;
|
||||
/**
|
||||
* Optional pre-fetched external-object summary. Renders a compact severity
|
||||
* marker before the rest of `desktopTrailing` on desktop only.
|
||||
*/
|
||||
externalObjectSummary?: ExternalObjectSummary | null;
|
||||
trailingMeta?: ReactNode;
|
||||
titleSuffix?: ReactNode;
|
||||
titleClassName?: string;
|
||||
|
|
@ -51,6 +57,7 @@ export function IssueRow({
|
|||
desktopLeadingSpacer = false,
|
||||
mobileMeta,
|
||||
desktopTrailing,
|
||||
externalObjectSummary,
|
||||
trailingMeta,
|
||||
titleSuffix,
|
||||
titleClassName,
|
||||
|
|
@ -162,8 +169,11 @@ export function IssueRow({
|
|||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
{(desktopTrailing || trailingMeta) ? (
|
||||
{(desktopTrailing || trailingMeta || externalObjectSummary) ? (
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
|
||||
{externalObjectSummary ? (
|
||||
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
|
||||
) : null}
|
||||
{desktopTrailing}
|
||||
{trailingMeta ? (
|
||||
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
type SuggestedTaskTreeNode,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
||||
import { Button } from "./ui/button";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
|
|
@ -58,6 +58,7 @@ interface IssueThreadInteractionCardProps {
|
|||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
onUploadImage?: (file: File) => Promise<string>;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}
|
||||
|
||||
function resolveActorLabel(args: {
|
||||
|
|
@ -706,6 +707,7 @@ function AskUserQuestionsCard({
|
|||
interaction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
externalReferences,
|
||||
}: {
|
||||
interaction: AskUserQuestionsInteraction;
|
||||
onSubmitInteractionAnswers?: (
|
||||
|
|
@ -715,6 +717,7 @@ function AskUserQuestionsCard({
|
|||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
const [draftAnswers, setDraftAnswers] = useState<Record<string, string[]>>(() =>
|
||||
Object.fromEntries(
|
||||
|
|
@ -1013,7 +1016,7 @@ function AskUserQuestionsCard({
|
|||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700">
|
||||
Submitted summary
|
||||
</div>
|
||||
<MarkdownBody>{interaction.result.summaryMarkdown}</MarkdownBody>
|
||||
<MarkdownBody externalReferences={externalReferences}>{interaction.result.summaryMarkdown}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1173,6 +1176,7 @@ function RequestConfirmationCard({
|
|||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onUploadImage,
|
||||
externalReferences,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
isPlan?: boolean;
|
||||
|
|
@ -1184,6 +1188,7 @@ function RequestConfirmationCard({
|
|||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
onUploadImage?: (file: File) => Promise<string>;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
|
||||
|
|
@ -1283,7 +1288,7 @@ function RequestConfirmationCard({
|
|||
</div>
|
||||
{interaction.payload.detailsMarkdown ? (
|
||||
<div className="border-t border-border/60 pt-3 text-sm">
|
||||
<MarkdownBody>{interaction.payload.detailsMarkdown}</MarkdownBody>
|
||||
<MarkdownBody externalReferences={externalReferences}>{interaction.payload.detailsMarkdown}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
<RequestConfirmationTargetChip
|
||||
|
|
@ -1575,6 +1580,7 @@ function RequestCheckboxConfirmationCard({
|
|||
interaction,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
externalReferences,
|
||||
}: {
|
||||
interaction: RequestCheckboxConfirmationInteraction;
|
||||
onAcceptInteraction?: (
|
||||
|
|
@ -1586,6 +1592,7 @@ function RequestCheckboxConfirmationCard({
|
|||
interaction: RequestCheckboxConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
const options = interaction.payload.options;
|
||||
const optionIds = useMemo(() => options.map((option) => option.id), [options]);
|
||||
|
|
@ -1721,7 +1728,7 @@ function RequestCheckboxConfirmationCard({
|
|||
<div className="text-sm leading-6 text-foreground">{interaction.payload.prompt}</div>
|
||||
{interaction.payload.detailsMarkdown ? (
|
||||
<div className="border-t border-border/60 pt-3 text-sm">
|
||||
<MarkdownBody>{interaction.payload.detailsMarkdown}</MarkdownBody>
|
||||
<MarkdownBody externalReferences={externalReferences}>{interaction.payload.detailsMarkdown}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
<RequestConfirmationTargetChip
|
||||
|
|
@ -1881,6 +1888,7 @@ export function IssueThreadInteractionCard({
|
|||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
onUploadImage,
|
||||
externalReferences,
|
||||
}: IssueThreadInteractionCardProps) {
|
||||
const isPlan = isPlanConfirmation(interaction);
|
||||
const planStyles = isPlan ? planStatusClasses(interaction.status) : null;
|
||||
|
|
@ -1973,12 +1981,14 @@ export function IssueThreadInteractionCard({
|
|||
interaction={interaction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : interaction.kind === "request_checkbox_confirmation" ? (
|
||||
<RequestCheckboxConfirmationCard
|
||||
interaction={interaction}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : (
|
||||
<RequestConfirmationCard
|
||||
|
|
@ -1987,6 +1997,7 @@ export function IssueThreadInteractionCard({
|
|||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onUploadImage={onUploadImage}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExternalObjectsApi = vi.hoisted(() => ({
|
||||
getIssueSummaries: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => companyState,
|
||||
}));
|
||||
|
|
@ -87,6 +91,10 @@ vi.mock("../api/instanceSettings", () => ({
|
|||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/externalObjects", () => ({
|
||||
externalObjectsApi: mockExternalObjectsApi,
|
||||
}));
|
||||
|
||||
vi.mock("./IssueRow", () => ({
|
||||
IssueRow: ({
|
||||
issue,
|
||||
|
|
@ -97,6 +105,7 @@ vi.mock("./IssueRow", () => ({
|
|||
checklistCurrentStep,
|
||||
checklistDependencyChips,
|
||||
checklistRowId,
|
||||
externalObjectSummary,
|
||||
}: {
|
||||
issue: Issue;
|
||||
desktopMetaLeading?: ReactNode;
|
||||
|
|
@ -106,6 +115,7 @@ vi.mock("./IssueRow", () => ({
|
|||
checklistCurrentStep?: boolean;
|
||||
checklistDependencyChips?: ReactNode;
|
||||
checklistRowId?: string;
|
||||
externalObjectSummary?: { total: number } | null;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="issue-row"
|
||||
|
|
@ -115,6 +125,9 @@ vi.mock("./IssueRow", () => ({
|
|||
data-title-class={titleClassName ?? undefined}
|
||||
>
|
||||
<span>{issue.title}</span>
|
||||
{externalObjectSummary ? (
|
||||
<span data-testid="external-object-summary">{externalObjectSummary.total}</span>
|
||||
) : null}
|
||||
{desktopMetaLeading}
|
||||
{desktopTrailing}
|
||||
{checklistDependencyChips}
|
||||
|
|
@ -286,6 +299,7 @@ describe("IssuesList", () => {
|
|||
mockExecutionWorkspacesApi.list.mockReset();
|
||||
mockExecutionWorkspacesApi.listSummaries.mockReset();
|
||||
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||
mockExternalObjectsApi.getIssueSummaries.mockReset();
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||
|
|
@ -293,8 +307,12 @@ describe("IssuesList", () => {
|
|||
mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] });
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableExternalObjects: false,
|
||||
});
|
||||
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 });
|
||||
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
|
|
@ -303,6 +321,130 @@ describe("IssuesList", () => {
|
|||
container.remove();
|
||||
});
|
||||
|
||||
it("forwards external-object summaries into issue rows", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableExternalObjects: true,
|
||||
});
|
||||
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({
|
||||
summaries: {
|
||||
"issue-1": {
|
||||
total: 2,
|
||||
byStatusCategory: { failed: 1, succeeded: 1 },
|
||||
byLiveness: { fresh: 2 },
|
||||
highestSeverity: "danger",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[createIssue()]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith("company-1", ["issue-1"]);
|
||||
expect(container.querySelector("[data-testid='external-object-summary']")?.textContent).toBe("2");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not load external-object summaries when the experimental flag is disabled", async () => {
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[createIssue()]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockInstanceSettingsApi.getExperimental).toHaveBeenCalled();
|
||||
expect(container.querySelector("[data-testid='issue-row']")).not.toBeNull();
|
||||
});
|
||||
expect(mockExternalObjectsApi.getIssueSummaries).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters issue rows by external-object status summaries", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableExternalObjects: true,
|
||||
});
|
||||
const failedIssue = createIssue({ id: "issue-failed", identifier: "PAP-10", title: "Failed external object" });
|
||||
const freshIssue = createIssue({ id: "issue-fresh", identifier: "PAP-11", title: "Fresh external object" });
|
||||
const noObjectIssue = createIssue({ id: "issue-none", identifier: "PAP-12", title: "No external object" });
|
||||
localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] }));
|
||||
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({
|
||||
summaries: {
|
||||
"issue-failed": {
|
||||
total: 1,
|
||||
byStatusCategory: { failed: 1 },
|
||||
byLiveness: { fresh: 1 },
|
||||
highestSeverity: "danger",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [],
|
||||
},
|
||||
"issue-fresh": {
|
||||
total: 1,
|
||||
byStatusCategory: { succeeded: 1 },
|
||||
byLiveness: { fresh: 1 },
|
||||
highestSeverity: "success",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[failedIssue, freshIssue, noObjectIssue]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
["issue-failed", "issue-fresh", "issue-none"],
|
||||
);
|
||||
expect(container.textContent).toContain("Failed external object");
|
||||
expect(container.textContent).not.toContain("Fresh external object");
|
||||
expect(container.textContent).not.toContain("No external object");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders server search results instead of filtering the full issue list locally", async () => {
|
||||
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
|
||||
const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" });
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { issuesApi } from "../api/issues";
|
|||
import { authApi } from "../api/auth";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects";
|
||||
import {
|
||||
shouldBlurPageSearchOnEnter,
|
||||
shouldBlurPageSearchOnEscape,
|
||||
|
|
@ -642,7 +643,9 @@ export function IssuesList({
|
|||
retry: false,
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const experimentalSettingsLoaded = experimentalSettings !== undefined;
|
||||
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true;
|
||||
|
||||
// Scope the storage key per company so folding/view state is independent across companies.
|
||||
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
||||
|
|
@ -692,6 +695,11 @@ export function IssuesList({
|
|||
});
|
||||
}, [scopedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!experimentalSettingsLoaded || externalObjectsEnabled || viewState.externalObjectStatuses.length === 0) return;
|
||||
updateView({ externalObjectStatuses: [] });
|
||||
}, [experimentalSettingsLoaded, externalObjectsEnabled, updateView, viewState.externalObjectStatuses.length]);
|
||||
|
||||
// Prune stale IDs from collapsedParents whenever the issue list changes.
|
||||
// Deleted or reassigned issues leave orphan IDs in localStorage; this keeps
|
||||
// the stored array bounded to only current parent IDs.
|
||||
|
|
@ -967,32 +975,58 @@ export function IssuesList({
|
|||
[boardIssueQueries, searchWithinLoadedIssues, viewState.viewMode],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const sourceIssues = useMemo(() => {
|
||||
const useRemoteSearch = normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues;
|
||||
const sourceIssues = boardIssues ?? (useRemoteSearch ? searchedIssues : issues);
|
||||
const searchScopedIssues = normalizedIssueSearch.length > 0 && searchWithinLoadedIssues
|
||||
return boardIssues ?? (useRemoteSearch ? searchedIssues : issues);
|
||||
}, [boardIssues, issues, normalizedIssueSearch, searchedIssues, searchWithinLoadedIssues]);
|
||||
|
||||
const searchScopedIssues = useMemo(
|
||||
() => normalizedIssueSearch.length > 0 && searchWithinLoadedIssues
|
||||
? sourceIssues.filter((issue) => issueMatchesLocalSearch(issue, normalizedIssueSearch))
|
||||
: sourceIssues;
|
||||
: sourceIssues,
|
||||
[normalizedIssueSearch, searchWithinLoadedIssues, sourceIssues],
|
||||
);
|
||||
const hasExternalObjectStatusFilters = viewState.externalObjectStatuses.length > 0;
|
||||
const issueIdsForExternalObjectSummaries = useMemo(
|
||||
() => (viewState.viewMode === "list" || hasExternalObjectStatusFilters
|
||||
? searchScopedIssues.map((issue) => issue.id)
|
||||
: []),
|
||||
[hasExternalObjectStatusFilters, searchScopedIssues, viewState.viewMode],
|
||||
);
|
||||
const {
|
||||
summaries: externalObjectSummaryByIssueId,
|
||||
isLoading: externalObjectSummariesLoading,
|
||||
isReady: externalObjectSummariesReady,
|
||||
} = useIssueExternalObjectSummaries(
|
||||
selectedCompanyId,
|
||||
issueIdsForExternalObjectSummaries,
|
||||
);
|
||||
const issueFilterContext = useMemo(() => ({
|
||||
...issueFilterWorkspaceContext,
|
||||
externalObjectSummaryByIssueId,
|
||||
externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading,
|
||||
}), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId, issueFilterWorkspaceContext]);
|
||||
const externalObjectFilterLoading = hasExternalObjectStatusFilters
|
||||
&& externalObjectSummariesLoading
|
||||
&& !externalObjectSummariesReady;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filteredByControls = applyIssueFilters(
|
||||
searchScopedIssues,
|
||||
viewState,
|
||||
currentUserId,
|
||||
enableRoutineVisibilityFilter,
|
||||
liveIssueIds,
|
||||
issueFilterWorkspaceContext,
|
||||
issueFilterContext,
|
||||
);
|
||||
return sortIssues(filteredByControls, viewState);
|
||||
}, [
|
||||
boardIssues,
|
||||
issues,
|
||||
searchedIssues,
|
||||
searchWithinLoadedIssues,
|
||||
searchScopedIssues,
|
||||
viewState,
|
||||
normalizedIssueSearch,
|
||||
currentUserId,
|
||||
enableRoutineVisibilityFilter,
|
||||
liveIssueIds,
|
||||
issueFilterWorkspaceContext,
|
||||
issueFilterContext,
|
||||
]);
|
||||
|
||||
const progressSummary = useMemo(
|
||||
|
|
@ -1479,6 +1513,7 @@ export function IssuesList({
|
|||
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
|
||||
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId={currentUserId}
|
||||
enableExternalObjectFilters={externalObjectsEnabled}
|
||||
enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
|
||||
iconOnly
|
||||
workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined}
|
||||
|
|
@ -1565,7 +1600,7 @@ export function IssuesList({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && <PageSkeleton variant="issues-list" />}
|
||||
{(isLoading || externalObjectFilterLoading) && <PageSkeleton variant="issues-list" />}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
{!searchWithinLoadedIssues && normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
@ -1577,7 +1612,7 @@ export function IssuesList({
|
|||
Some board columns are showing up to {ISSUE_BOARD_COLUMN_RESULT_LIMIT} tasks. Refine filters or search to reveal the rest.
|
||||
</p>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
|
||||
{!isLoading && !externalObjectFilterLoading && filtered.length === 0 && viewState.viewMode === "list" && (
|
||||
<EmptyState
|
||||
icon={CircleDot}
|
||||
message="No tasks match the current filters or search."
|
||||
|
|
@ -1749,6 +1784,7 @@ export function IssuesList({
|
|||
checklistDependencyChips={checklistDependencyChips}
|
||||
checklistRowId={checklistRowId}
|
||||
titleClassName={doneRowTitleClass}
|
||||
externalObjectSummary={externalObjectSummaryByIssueId.get(issue.id) ?? null}
|
||||
titleSuffix={(
|
||||
<>
|
||||
{hasChildren && !isExpanded ? (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { isValidElement, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react";
|
||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||
|
|
@ -15,6 +15,36 @@ import { parseWorkspaceFileHref, remarkWorkspaceFileRefs, WORKSPACE_FILE_HREF_PR
|
|||
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { WorkspaceFileLink } from "./WorkspaceFileLink";
|
||||
import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon";
|
||||
import {
|
||||
externalObjectCategoryLabel,
|
||||
externalObjectLivenessLabel,
|
||||
externalObjectProviderLabel,
|
||||
} from "../lib/external-objects";
|
||||
import { normalizeExternalObjectHref } from "../lib/external-object-href";
|
||||
import type {
|
||||
ExternalObjectLivenessState,
|
||||
ExternalObjectStatusCategory,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Host-resolved external-object metadata for inline markdown decoration.
|
||||
* The renderer only consumes the host normalized fields here — plugin React
|
||||
* is never mounted inline (Phase 1B security review).
|
||||
*/
|
||||
export interface MarkdownExternalReference {
|
||||
providerKey: string | null;
|
||||
objectType: string | null;
|
||||
displayKey?: string | null;
|
||||
iconKey?: string | null;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
liveness: ExternalObjectLivenessState;
|
||||
statusLabel?: string | null;
|
||||
statusIconKey?: string | null;
|
||||
displayTitle?: string | null;
|
||||
}
|
||||
|
||||
export type MarkdownExternalReferenceMap = Record<string, MarkdownExternalReference>;
|
||||
|
||||
interface MarkdownBodyProps {
|
||||
children: string;
|
||||
|
|
@ -28,6 +58,12 @@ interface MarkdownBodyProps {
|
|||
wikiLinkRoot?: string;
|
||||
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
/**
|
||||
* Optional map of `normalizeExternalObjectHref(href)` → host-resolved metadata.
|
||||
* Hrefs in the markdown that resolve to one of these keys get the inline
|
||||
* status icon prefix used by §2 of the UX spec.
|
||||
*/
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||
resolveImageSrc?: (src: string) => string | null;
|
||||
/** Called when a user clicks an inline image */
|
||||
|
|
@ -72,6 +108,51 @@ function MarkdownIssueLink({
|
|||
);
|
||||
}
|
||||
|
||||
function MarkdownExternalLink({
|
||||
href,
|
||||
reference,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
reference: MarkdownExternalReference;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const provider = externalObjectProviderLabel(reference.providerKey);
|
||||
const displayKey = reference.displayKey?.trim() || provider;
|
||||
const statusLabel = reference.statusLabel ?? externalObjectCategoryLabel(reference.statusCategory);
|
||||
const livenessLabel = externalObjectLivenessLabel(reference.liveness);
|
||||
const livenessSuffix = reference.liveness === "fresh" || reference.liveness === "unknown"
|
||||
? ""
|
||||
: ` (${livenessLabel})`;
|
||||
const titleParts = [
|
||||
reference.displayTitle ?? `${displayKey} ${statusLabel}`,
|
||||
`${displayKey} — ${statusLabel}${livenessSuffix}`,
|
||||
];
|
||||
const title = titleParts.filter(Boolean).join(" · ");
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-external-link="resolved"
|
||||
data-external-status={reference.statusCategory}
|
||||
data-external-liveness={reference.liveness}
|
||||
title={title}
|
||||
aria-label={`${displayKey} ${statusLabel}${livenessSuffix}: ${reference.displayTitle ?? href}`}
|
||||
className="paperclip-markdown-external-ref"
|
||||
>
|
||||
<ExternalObjectStatusIcon
|
||||
category={reference.statusCategory}
|
||||
liveness={reference.liveness}
|
||||
statusIconKey={reference.statusIconKey}
|
||||
label={`${displayKey}: ${statusLabel}`}
|
||||
inline
|
||||
/>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function loadMermaid() {
|
||||
if (!mermaidLoaderPromise) {
|
||||
mermaidLoaderPromise = import("mermaid").then((module) => module.default);
|
||||
|
|
@ -572,6 +653,7 @@ export function MarkdownBody({
|
|||
enableWikiLinks = false,
|
||||
wikiLinkRoot,
|
||||
resolveWikiLinkHref,
|
||||
externalReferences,
|
||||
resolveImageSrc,
|
||||
onImageClick,
|
||||
linkWorkspaceFileRefs = false,
|
||||
|
|
@ -584,6 +666,15 @@ export function MarkdownBody({
|
|||
const knownPrefixes = company?.companies.length
|
||||
? company.companies.map((c) => c.issuePrefix)
|
||||
: undefined;
|
||||
const externalReferenceLookup = useMemo<MarkdownExternalReferenceMap | null>(() => {
|
||||
if (!externalReferences) return null;
|
||||
const lookup: MarkdownExternalReferenceMap = {};
|
||||
for (const [key, value] of Object.entries(externalReferences)) {
|
||||
const normalized = normalizeExternalObjectHref(key) ?? key;
|
||||
lookup[normalized] = value;
|
||||
}
|
||||
return lookup;
|
||||
}, [externalReferences]);
|
||||
const remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
||||
if (enableWikiLinks) {
|
||||
remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
||||
|
|
@ -706,6 +797,17 @@ export function MarkdownBody({
|
|||
</a>
|
||||
);
|
||||
}
|
||||
const externalReference = href && externalReferenceLookup
|
||||
? externalReferenceLookup[normalizeExternalObjectHref(href) ?? ""] ?? null
|
||||
: null;
|
||||
if (externalReference && href) {
|
||||
return (
|
||||
<MarkdownExternalLink href={href} reference={externalReference}>
|
||||
{linkChildren}
|
||||
</MarkdownExternalLink>
|
||||
);
|
||||
}
|
||||
|
||||
const isGitHubLink = isGitHubUrl(href);
|
||||
const isExternal = isExternalHttpUrl(href);
|
||||
const leadingIcon = isGitHubLink ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
...props
|
||||
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
function render(children: string, externalReferences?: MarkdownExternalReferenceMap) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody externalReferences={externalReferences}>{children}</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MarkdownBody external object decoration", () => {
|
||||
const successReference: MarkdownExternalReferenceMap = {
|
||||
"https://github.com/acme/web/pull/241": {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
displayKey: "Github Pull Request",
|
||||
iconKey: "github",
|
||||
statusCategory: "succeeded",
|
||||
statusIconKey: "git-merge",
|
||||
liveness: "fresh",
|
||||
statusLabel: "Merged",
|
||||
displayTitle: "Add external refs",
|
||||
},
|
||||
};
|
||||
|
||||
it("decorates a known URL with the external status icon and metadata attributes", () => {
|
||||
const html = render("Take a look: https://github.com/acme/web/pull/241", successReference);
|
||||
expect(html).toContain('class="paperclip-markdown-external-ref"');
|
||||
expect(html).toContain('data-external-link="resolved"');
|
||||
expect(html).toContain('data-external-status="succeeded"');
|
||||
expect(html).toContain('data-external-liveness="fresh"');
|
||||
expect(html).toContain('aria-label="Github Pull Request Merged: Add external refs"');
|
||||
expect(html).toContain("github.com/acme/web/pull/241");
|
||||
});
|
||||
|
||||
it("matches a URL even when the user pasted it with different host case or trailing punctuation", () => {
|
||||
const html = render("see HTTPS://Github.com/acme/web/pull/241#frag.", successReference);
|
||||
expect(html).toContain('data-external-status="succeeded"');
|
||||
});
|
||||
|
||||
it("renders an unresolved URL as a plain external link with no status affordance", () => {
|
||||
const html = render("https://random.example.com/path");
|
||||
expect(html).not.toContain("paperclip-markdown-external-ref");
|
||||
expect(html).toContain('href="https://random.example.com/path"');
|
||||
});
|
||||
|
||||
it("never decorates a URL that lives inside a fenced or inline code block", () => {
|
||||
const html = render(
|
||||
"```\nhttps://github.com/acme/web/pull/241\n```\n\nInline: `https://github.com/acme/web/pull/241`",
|
||||
successReference,
|
||||
);
|
||||
// The fenced/inline literal should still be present as text but the
|
||||
// decorated anchor should not appear since no `<a>` is created in code.
|
||||
expect(html).not.toContain('class="paperclip-markdown-external-ref"');
|
||||
});
|
||||
|
||||
it("shows liveness suffix in the aria-label when the object is stale or auth_required", () => {
|
||||
const html = render("Auth-blocked: https://app.hubspot.com/leads/99", {
|
||||
"https://app.hubspot.com/leads/99": {
|
||||
providerKey: "hubspot",
|
||||
objectType: "lead",
|
||||
statusCategory: "auth_required",
|
||||
liveness: "auth_required",
|
||||
statusLabel: "Reconnect",
|
||||
displayTitle: "Acme deal",
|
||||
},
|
||||
});
|
||||
expect(html).toContain('aria-label="HubSpot Reconnect (Requires auth): Acme deal"');
|
||||
});
|
||||
});
|
||||
|
|
@ -22,7 +22,9 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||
import { useProjectExternalObjectSummary } from "../hooks/useIssueExternalObjects";
|
||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -122,6 +124,7 @@ function ProjectItem({
|
|||
isDragging = false,
|
||||
}: ProjectItemProps) {
|
||||
const routeRef = projectRouteRef(project);
|
||||
const { summary: externalObjectsSummary } = useProjectExternalObjectSummary(project.id);
|
||||
|
||||
const link = (
|
||||
<NavLink
|
||||
|
|
@ -143,6 +146,7 @@ function ProjectItem({
|
|||
>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
|
||||
{!rail ? <ExternalObjectStatusSummary summary={externalObjectsSummary} compact /> : null}
|
||||
{!rail && project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||
</NavLink>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TranscriptEntry } from "../../adapters";
|
||||
import { MarkdownBody } from "../MarkdownBody";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "../MarkdownBody";
|
||||
import { cn, formatTokens } from "../../lib/utils";
|
||||
import {
|
||||
Check,
|
||||
|
|
@ -31,6 +31,7 @@ interface RunTranscriptViewProps {
|
|||
emptyMessage?: string;
|
||||
className?: string;
|
||||
thinkingClassName?: string;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}
|
||||
|
||||
type TranscriptBlock =
|
||||
|
|
@ -636,9 +637,11 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||
function TranscriptMessageBlock({
|
||||
block,
|
||||
density,
|
||||
externalReferences,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "message" }>;
|
||||
density: TranscriptDensity;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
const isAssistant = block.role === "assistant";
|
||||
const compact = density === "compact";
|
||||
|
|
@ -656,6 +659,7 @@ function TranscriptMessageBlock({
|
|||
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
compact ? "text-xs leading-5 text-foreground/85" : "text-sm",
|
||||
)}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
{block.text}
|
||||
</MarkdownBody>
|
||||
|
|
@ -676,10 +680,12 @@ function TranscriptThinkingBlock({
|
|||
block,
|
||||
density,
|
||||
className,
|
||||
externalReferences,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "thinking" }>;
|
||||
density: TranscriptDensity;
|
||||
className?: string;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
return (
|
||||
<MarkdownBody
|
||||
|
|
@ -688,6 +694,7 @@ function TranscriptThinkingBlock({
|
|||
density === "compact" ? "text-[11px] leading-5" : "text-sm leading-6",
|
||||
className,
|
||||
)}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
{block.text}
|
||||
</MarkdownBody>
|
||||
|
|
@ -1090,9 +1097,11 @@ function TranscriptActivityRow({
|
|||
function TranscriptEventRow({
|
||||
block,
|
||||
density,
|
||||
externalReferences,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "event" }>;
|
||||
density: TranscriptDensity;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
const compact = density === "compact";
|
||||
const toneClasses =
|
||||
|
|
@ -1121,6 +1130,7 @@ function TranscriptEventRow({
|
|||
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 text-sky-700 dark:text-sky-300",
|
||||
compact ? "text-[11px] leading-5" : "text-xs leading-5",
|
||||
)}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
{block.text}
|
||||
</MarkdownBody>
|
||||
|
|
@ -1473,6 +1483,7 @@ export function RunTranscriptView({
|
|||
emptyMessage = "No transcript yet.",
|
||||
className,
|
||||
thinkingClassName,
|
||||
externalReferences,
|
||||
}: RunTranscriptViewProps) {
|
||||
const blocks = useMemo(
|
||||
() => (mode === "raw" ? [] : normalizeTranscript(entries, streaming)),
|
||||
|
|
@ -1504,9 +1515,20 @@ export function RunTranscriptView({
|
|||
key={`${block.type}-${block.ts}-${index}`}
|
||||
className={cn(index === visibleBlocks.length - 1 && streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300")}
|
||||
>
|
||||
{block.type === "message" && <TranscriptMessageBlock block={block} density={density} />}
|
||||
{block.type === "message" && (
|
||||
<TranscriptMessageBlock
|
||||
block={block}
|
||||
density={density}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
)}
|
||||
{block.type === "thinking" && (
|
||||
<TranscriptThinkingBlock block={block} density={density} className={thinkingClassName} />
|
||||
<TranscriptThinkingBlock
|
||||
block={block}
|
||||
density={density}
|
||||
className={thinkingClassName}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
)}
|
||||
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
||||
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
|
||||
|
|
@ -1518,7 +1540,13 @@ export function RunTranscriptView({
|
|||
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
|
||||
)}
|
||||
{block.type === "activity" && <TranscriptActivityRow block={block} density={density} />}
|
||||
{block.type === "event" && <TranscriptEventRow block={block} density={density} />}
|
||||
{block.type === "event" && (
|
||||
<TranscriptEventRow
|
||||
block={block}
|
||||
density={density}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,250 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
ExternalObjectMention,
|
||||
ExternalObjectMentionGroup,
|
||||
ExternalObjectSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { externalObjectsApi } from "../api/externalObjects";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { normalizeExternalObjectHref } from "../lib/external-object-href";
|
||||
import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody";
|
||||
import type { ExternalObjectPillData } from "../components/ExternalObjectPill";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
|
||||
export const EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE = 500;
|
||||
|
||||
export async function fetchIssueExternalObjectSummariesInBatches(
|
||||
companyId: string,
|
||||
issueIds: readonly string[],
|
||||
) {
|
||||
const summaries: Record<string, ExternalObjectSummary> = {};
|
||||
for (let index = 0; index < issueIds.length; index += EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE) {
|
||||
const batch = issueIds.slice(index, index + EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE);
|
||||
const response = await externalObjectsApi.getIssueSummaries(companyId, batch);
|
||||
Object.assign(summaries, response.summaries);
|
||||
}
|
||||
return { summaries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-side mention-source label. Keep in sync with the shared formatter
|
||||
* without coupling this hook to the server-only URL canonicalization helpers.
|
||||
*/
|
||||
function formatMentionSourceLabel(mention: ExternalObjectMention): string {
|
||||
switch (mention.sourceKind) {
|
||||
case "title":
|
||||
return "Title";
|
||||
case "description":
|
||||
return "Description";
|
||||
case "comment":
|
||||
return "Comment";
|
||||
case "document":
|
||||
return mention.documentKey ? `Document: ${mention.documentKey}` : "Document";
|
||||
case "property":
|
||||
return mention.propertyKey ? `Property: ${mention.propertyKey}` : "Property";
|
||||
case "plugin":
|
||||
return "Plugin";
|
||||
default:
|
||||
return "Source";
|
||||
}
|
||||
}
|
||||
|
||||
export interface IssueExternalObjectGroup {
|
||||
pill: ExternalObjectPillData;
|
||||
mentionCount: number;
|
||||
sourceLabels: string[];
|
||||
group: ExternalObjectMentionGroup;
|
||||
}
|
||||
|
||||
export interface IssueExternalObjectsResult {
|
||||
isEnabled: boolean;
|
||||
groups: IssueExternalObjectGroup[];
|
||||
/** Lookup map for `MarkdownBody`'s `externalReferences` prop. */
|
||||
markdownReferences: MarkdownExternalReferenceMap;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
function useExternalObjectsFeature() {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
return {
|
||||
isEnabled: query.data?.enableExternalObjects === true,
|
||||
isLoaded: query.data !== undefined || query.isError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads `external_objects` for an issue and produces both the per-group rows
|
||||
* (used by the property panel and related-work section) and the markdown URL
|
||||
* lookup map (used by inline decoration). Single source of truth so every
|
||||
* surface reads from the same query result.
|
||||
*/
|
||||
export function useIssueExternalObjects(issueId: string | null | undefined): IssueExternalObjectsResult {
|
||||
const externalObjectsFeature = useExternalObjectsFeature();
|
||||
const enabled = externalObjectsFeature.isEnabled && Boolean(issueId);
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.externalObjects.byIssue(issueId ?? "__none__"),
|
||||
queryFn: () => externalObjectsApi.listForIssue(issueId!),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const groups = useMemo<IssueExternalObjectGroup[]>(() => {
|
||||
const data = query.data ?? [];
|
||||
return data
|
||||
.filter((entry): entry is ExternalObjectMentionGroup => Boolean(entry.object))
|
||||
.map((entry) => {
|
||||
const object = entry.object!;
|
||||
const sourceLabels = entry.sourceLabels && entry.sourceLabels.length > 0
|
||||
? entry.sourceLabels
|
||||
: Array.from(new Set(entry.mentions.map(formatMentionSourceLabel)));
|
||||
return {
|
||||
group: entry,
|
||||
mentionCount: entry.mentionCount ?? entry.mentions.length,
|
||||
sourceLabels,
|
||||
pill: {
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
displayKey: object.displayKey,
|
||||
iconKey: object.iconKey,
|
||||
statusCategory: object.statusCategory,
|
||||
liveness: object.liveness,
|
||||
displayTitle: object.displayTitle,
|
||||
statusLabel: object.statusLabel,
|
||||
statusIconKey: object.statusIconKey,
|
||||
url: object.sanitizedCanonicalUrl,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [query.data]);
|
||||
|
||||
const markdownReferences = useMemo<MarkdownExternalReferenceMap>(() => {
|
||||
const result: MarkdownExternalReferenceMap = {};
|
||||
for (const { group } of groups) {
|
||||
const object = group.object;
|
||||
if (!object) continue;
|
||||
// Index by the object's canonical URL.
|
||||
const canonical = normalizeExternalObjectHref(object.sanitizedCanonicalUrl ?? null);
|
||||
if (canonical) {
|
||||
result[canonical] = {
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
displayKey: object.displayKey,
|
||||
iconKey: object.iconKey,
|
||||
statusCategory: object.statusCategory,
|
||||
liveness: object.liveness,
|
||||
statusLabel: object.statusLabel,
|
||||
statusIconKey: object.statusIconKey,
|
||||
displayTitle: object.displayTitle,
|
||||
};
|
||||
}
|
||||
// Also index by every mention's sanitized display URL so user-pasted
|
||||
// hrefs that differ only in case/punctuation still resolve.
|
||||
for (const mention of group.mentions) {
|
||||
const normalizedMention = normalizeExternalObjectHref(
|
||||
mention.sanitizedDisplayUrl ?? null,
|
||||
);
|
||||
if (normalizedMention && !result[normalizedMention]) {
|
||||
result[normalizedMention] = {
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
displayKey: object.displayKey,
|
||||
iconKey: object.iconKey,
|
||||
statusCategory: object.statusCategory,
|
||||
liveness: object.liveness,
|
||||
statusLabel: object.statusLabel,
|
||||
statusIconKey: object.statusIconKey,
|
||||
displayTitle: object.displayTitle,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [groups]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
void query.refetch();
|
||||
}, [query.refetch]);
|
||||
|
||||
return {
|
||||
isEnabled: externalObjectsFeature.isEnabled,
|
||||
groups,
|
||||
markdownReferences,
|
||||
isLoading: enabled && query.isLoading,
|
||||
isError: query.isError,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function useIssueExternalObjectSummary(issueId: string | null | undefined): {
|
||||
summary: ExternalObjectSummary | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const externalObjectsFeature = useExternalObjectsFeature();
|
||||
const enabled = externalObjectsFeature.isEnabled && Boolean(issueId);
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.externalObjects.issueSummary(issueId ?? "__none__"),
|
||||
queryFn: () => externalObjectsApi.getIssueSummary(issueId!),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return {
|
||||
summary: query.data ?? null,
|
||||
isLoading: enabled && query.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useIssueExternalObjectSummaries(
|
||||
companyId: string | null | undefined,
|
||||
issueIds: readonly string[],
|
||||
): {
|
||||
summaries: Map<string, ExternalObjectSummary>;
|
||||
isLoading: boolean;
|
||||
isReady: boolean;
|
||||
} {
|
||||
const externalObjectsFeature = useExternalObjectsFeature();
|
||||
const normalizedIssueIds = useMemo(
|
||||
() => [...new Set(issueIds.filter((issueId) => issueId.length > 0))].sort(),
|
||||
[issueIds],
|
||||
);
|
||||
const enabled = externalObjectsFeature.isEnabled && Boolean(companyId) && normalizedIssueIds.length > 0;
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.externalObjects.issueSummaries(companyId ?? "__none__", normalizedIssueIds),
|
||||
queryFn: () => fetchIssueExternalObjectSummariesInBatches(companyId!, normalizedIssueIds),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const summaries = useMemo(
|
||||
() => new Map(Object.entries(query.data?.summaries ?? {})),
|
||||
[query.data?.summaries],
|
||||
);
|
||||
return {
|
||||
summaries,
|
||||
isLoading: enabled && query.isLoading,
|
||||
isReady: externalObjectsFeature.isLoaded && (!enabled || query.isSuccess),
|
||||
};
|
||||
}
|
||||
|
||||
export function useProjectExternalObjectSummary(projectId: string | null | undefined): {
|
||||
summary: ExternalObjectSummary | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const externalObjectsFeature = useExternalObjectsFeature();
|
||||
const enabled = externalObjectsFeature.isEnabled && Boolean(projectId);
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.externalObjects.projectSummary(projectId ?? "__none__"),
|
||||
queryFn: () => externalObjectsApi.getProjectSummary(projectId!),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return {
|
||||
summary: query.data ?? null,
|
||||
isLoading: enabled && query.isLoading,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
const QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
function getInitialValue(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia(QUERY).matches;
|
||||
}
|
||||
|
||||
export function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState<boolean>(getInitialValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||
const media = window.matchMedia(QUERY);
|
||||
const handler = (event: MediaQueryListEvent) => setReduced(event.matches);
|
||||
setReduced(media.matches);
|
||||
media.addEventListener("change", handler);
|
||||
return () => media.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
|
|
@ -1097,6 +1097,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* External object URL decorations follow the same pattern: the host renders
|
||||
the icon prefix, the link text is left untouched (Postel's Law). */
|
||||
.paperclip-markdown-external-ref {
|
||||
display: inline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dark .paperclip-markdown a {
|
||||
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
|
||||
}
|
||||
|
|
@ -1206,6 +1213,25 @@ span.paperclip-project-mention-chip {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Touch target floor: WCAG 2.5.5 expects ≥24px on coarse pointers. The base
|
||||
chip is 22px; bump it on touch viewports without disturbing inline metrics. */
|
||||
@media (pointer: coarse) {
|
||||
a.paperclip-mention-chip,
|
||||
a.paperclip-project-mention-chip,
|
||||
span.paperclip-mention-chip,
|
||||
span.paperclip-project-mention-chip {
|
||||
min-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* External object pills: variant marker the host renders for known-resolved
|
||||
external URLs. Inline status icon is rendered by ExternalObjectStatusIcon. */
|
||||
a.paperclip-mention-chip[data-mention-kind="external-object"],
|
||||
span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
/* Inherit base chip styling; status colour comes from inline classes so the
|
||||
host can vary tone per category without defining a CSS variant per state. */
|
||||
}
|
||||
|
||||
/* When the identifier inside a chip is backtick-wrapped in markdown, strip the
|
||||
inline-code monospace/gray styling so the pill label reads cleanly. */
|
||||
.paperclip-markdown a.paperclip-mention-chip code,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Browser-safe URL key for matching markdown hrefs against the
|
||||
* `external_objects.sanitizedCanonicalUrl` returned by the host API.
|
||||
*
|
||||
* The shared/server canonicalizer is the source of truth for the canonical
|
||||
* URL string, but it imports from `node:crypto` (it also produces an identity
|
||||
* hash) and therefore cannot run in the browser. We replicate just the URL
|
||||
* normalization here:
|
||||
*
|
||||
* - protocol must be http or https
|
||||
* - reject userinfo (`username:password@`)
|
||||
* - lowercase host
|
||||
* - drop query string + fragment
|
||||
* - default empty pathname to `/`
|
||||
*/
|
||||
export function normalizeExternalObjectHref(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
if (url.username || url.password) return null;
|
||||
|
||||
const scheme = url.protocol === "https:" ? "https" : "http";
|
||||
const path = url.pathname || "/";
|
||||
return `${scheme}://${url.host.toLowerCase()}${path}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ExternalObjectSummary } from "@paperclipai/shared";
|
||||
import { externalObjectsApi } from "../api/externalObjects";
|
||||
import {
|
||||
EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE,
|
||||
fetchIssueExternalObjectSummariesInBatches,
|
||||
} from "../hooks/useIssueExternalObjects";
|
||||
import {
|
||||
dominantExternalObjectTone,
|
||||
externalObjectCategoryLabel,
|
||||
externalObjectDominantCount,
|
||||
externalObjectFallbackTone,
|
||||
externalObjectIconForCategory,
|
||||
externalObjectIconForKey,
|
||||
externalObjectLivenessLabel,
|
||||
externalObjectProviderLabel,
|
||||
externalObjectToneSeverity,
|
||||
externalObjectTypeLabel,
|
||||
sortExternalObjectsBySeverity,
|
||||
} from "./external-objects";
|
||||
import { normalizeExternalObjectHref } from "./external-object-href";
|
||||
|
||||
vi.mock("../api/externalObjects", () => ({
|
||||
externalObjectsApi: {
|
||||
getIssueSummaries: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const emptySummary: ExternalObjectSummary = {
|
||||
total: 0,
|
||||
byStatusCategory: {},
|
||||
byLiveness: {},
|
||||
highestSeverity: "neutral",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [],
|
||||
};
|
||||
|
||||
describe("normalizeExternalObjectHref", () => {
|
||||
it("lowercases the host (case preserving the path) and drops query/fragment", () => {
|
||||
expect(
|
||||
normalizeExternalObjectHref("HTTPS://Github.com/Acme/Web/pull/241?token=abc#frag"),
|
||||
).toBe("https://github.com/Acme/Web/pull/241");
|
||||
});
|
||||
|
||||
it("rejects non-http(s) and userinfo-bearing URLs", () => {
|
||||
expect(normalizeExternalObjectHref("javascript:alert(1)")).toBeNull();
|
||||
expect(normalizeExternalObjectHref("ftp://example.com/file")).toBeNull();
|
||||
expect(normalizeExternalObjectHref("https://user:pass@example.com/")).toBeNull();
|
||||
expect(normalizeExternalObjectHref(null)).toBeNull();
|
||||
expect(normalizeExternalObjectHref(undefined)).toBeNull();
|
||||
expect(normalizeExternalObjectHref("not a url")).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults pathless URLs to /", () => {
|
||||
expect(normalizeExternalObjectHref("https://example.com")).toBe("https://example.com/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchIssueExternalObjectSummariesInBatches", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(externalObjectsApi.getIssueSummaries).mockReset();
|
||||
});
|
||||
|
||||
it("chunks bulk summary requests below the server validation cap and merges results", async () => {
|
||||
const issueIds = Array.from(
|
||||
{ length: EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE * 2 + 3 },
|
||||
(_entry, index) => `issue-${index}`,
|
||||
);
|
||||
vi.mocked(externalObjectsApi.getIssueSummaries).mockImplementation(async (_companyId, ids) => ({
|
||||
summaries: Object.fromEntries(ids.map((id) => [id, { ...emptySummary, total: id.endsWith("-0") ? 1 : 0 }])),
|
||||
}));
|
||||
|
||||
const result = await fetchIssueExternalObjectSummariesInBatches("company-1", issueIds);
|
||||
|
||||
expect(externalObjectsApi.getIssueSummaries).toHaveBeenCalledTimes(3);
|
||||
expect(vi.mocked(externalObjectsApi.getIssueSummaries).mock.calls.map((call) => call[1].length)).toEqual([
|
||||
EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE,
|
||||
EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE,
|
||||
3,
|
||||
]);
|
||||
expect(Object.keys(result.summaries)).toHaveLength(issueIds.length);
|
||||
expect(result.summaries["issue-0"]?.total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("external-objects helpers", () => {
|
||||
it("labels categories with copy from the UX spec", () => {
|
||||
expect(externalObjectCategoryLabel("auth_required")).toBe("Authorization required");
|
||||
expect(externalObjectCategoryLabel("succeeded")).toBe("Succeeded");
|
||||
expect(externalObjectCategoryLabel("unknown")).toBe("Not yet resolved");
|
||||
});
|
||||
|
||||
it("labels liveness states with non-tech copy", () => {
|
||||
expect(externalObjectLivenessLabel("stale")).toBe("Stale");
|
||||
expect(externalObjectLivenessLabel("auth_required")).toBe("Requires auth");
|
||||
expect(externalObjectLivenessLabel("fresh")).toBe("Fresh");
|
||||
});
|
||||
|
||||
it("falls back to a humanised label for unknown providers and types", () => {
|
||||
expect(externalObjectProviderLabel("github")).toBe("GitHub");
|
||||
expect(externalObjectProviderLabel("hubspot_marketing")).toBe("Hubspot Marketing");
|
||||
expect(externalObjectProviderLabel(null)).toBe("External");
|
||||
expect(externalObjectTypeLabel("workflow_run")).toBe("workflow run");
|
||||
expect(externalObjectTypeLabel(null)).toBe("object");
|
||||
});
|
||||
|
||||
it("orders tones from danger down to muted", () => {
|
||||
expect(externalObjectToneSeverity("danger")).toBeGreaterThan(externalObjectToneSeverity("warning"));
|
||||
expect(externalObjectToneSeverity("warning")).toBeGreaterThan(externalObjectToneSeverity("info"));
|
||||
expect(externalObjectToneSeverity("info")).toBeGreaterThan(externalObjectToneSeverity("success"));
|
||||
expect(externalObjectToneSeverity("success")).toBeGreaterThan(externalObjectToneSeverity("muted"));
|
||||
expect(externalObjectToneSeverity(null)).toBe(0);
|
||||
expect(externalObjectToneSeverity("nonsense")).toBe(0);
|
||||
});
|
||||
|
||||
it("maps every spec category to a fallback tone", () => {
|
||||
expect(externalObjectFallbackTone("failed")).toBe("danger");
|
||||
expect(externalObjectFallbackTone("waiting")).toBe("warning");
|
||||
expect(externalObjectFallbackTone("running")).toBe("info");
|
||||
expect(externalObjectFallbackTone("succeeded")).toBe("success");
|
||||
expect(externalObjectFallbackTone("auth_required")).toBe("warning");
|
||||
expect(externalObjectFallbackTone("unreachable")).toBe("danger");
|
||||
});
|
||||
|
||||
it("returns the spec lucide icon names for every category", () => {
|
||||
expect(externalObjectIconForCategory("succeeded").displayName ?? "").toMatch(/CheckCircle2|Check/);
|
||||
expect(externalObjectIconForCategory("failed").displayName ?? "").toMatch(/XCircle|X/);
|
||||
expect(externalObjectIconForCategory("auth_required").displayName ?? "").toMatch(/KeyRound|Key/);
|
||||
expect(externalObjectIconForCategory("unreachable").displayName ?? "").toMatch(/CloudOff|Cloud/);
|
||||
expect(externalObjectIconForCategory("running").displayName ?? "").toMatch(/Loader2|Loader/);
|
||||
});
|
||||
|
||||
it("maps provider-controlled icon keys through host-owned icons", () => {
|
||||
expect(externalObjectIconForKey("github")?.displayName ?? "").toMatch(/Github/i);
|
||||
expect(externalObjectIconForKey("git-pull-request")?.displayName ?? "").toMatch(/GitPullRequest/i);
|
||||
expect(externalObjectIconForKey("unknown-provider-icon")).toBeNull();
|
||||
});
|
||||
|
||||
it("sorts items by severity first, preserving insertion order within a tone", () => {
|
||||
const items = [
|
||||
{ id: "a", statusTone: "info", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "running", liveness: "fresh", isTerminal: false },
|
||||
{ id: "b", statusTone: "danger", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", liveness: "fresh", isTerminal: false },
|
||||
{ id: "c", statusTone: "warning", providerKey: "hubspot", objectType: "lead", displayTitle: null, statusCategory: "waiting", liveness: "fresh", isTerminal: false },
|
||||
{ id: "d", statusTone: "danger", providerKey: "github", objectType: "issue", displayTitle: null, statusCategory: "blocked", liveness: "fresh", isTerminal: false },
|
||||
] as const;
|
||||
const sorted = sortExternalObjectsBySeverity(items as never);
|
||||
expect(sorted.map((item) => item.id)).toEqual(["b", "d", "c", "a"]);
|
||||
});
|
||||
|
||||
it("hides the rollup when every item is in a muted tone", () => {
|
||||
const summary = {
|
||||
total: 2,
|
||||
byStatusCategory: { closed: 2 },
|
||||
byLiveness: { fresh: 2 },
|
||||
highestSeverity: "muted" as const,
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [
|
||||
{ id: "a", providerKey: "x", objectType: "y", displayTitle: null, statusCategory: "closed" as const, statusTone: "muted" as const, liveness: "fresh" as const, isTerminal: true },
|
||||
{ id: "b", providerKey: "x", objectType: "y", displayTitle: null, statusCategory: "closed" as const, statusTone: "muted" as const, liveness: "fresh" as const, isTerminal: true },
|
||||
],
|
||||
};
|
||||
expect(dominantExternalObjectTone(summary)).toBeNull();
|
||||
expect(externalObjectDominantCount(summary)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts only the dominant-severity items in the rollup", () => {
|
||||
const summary = {
|
||||
total: 5,
|
||||
byStatusCategory: { failed: 3, succeeded: 2 },
|
||||
byLiveness: { fresh: 5 },
|
||||
highestSeverity: "danger" as const,
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [
|
||||
{ id: "a", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false },
|
||||
{ id: "b", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false },
|
||||
{ id: "c", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false },
|
||||
{ id: "d", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded" as const, statusTone: "success" as const, liveness: "fresh" as const, isTerminal: true },
|
||||
{ id: "e", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded" as const, statusTone: "success" as const, liveness: "fresh" as const, isTerminal: true },
|
||||
],
|
||||
};
|
||||
expect(dominantExternalObjectTone(summary)).toBe("danger");
|
||||
expect(externalObjectDominantCount(summary)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
import {
|
||||
AlertCircle,
|
||||
AlertOctagon,
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
CircleDashed,
|
||||
CircleDot,
|
||||
Clock,
|
||||
CloudOff,
|
||||
GitMerge,
|
||||
Github,
|
||||
GitPullRequest,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ExternalObjectLivenessState,
|
||||
ExternalObjectStatusCategory,
|
||||
ExternalObjectStatusTone,
|
||||
ExternalObjectSummary,
|
||||
ExternalObjectSummaryItem,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Lucide icon for each status category. The mapping is host-owned per the
|
||||
* Phase 1B security review — providers never inject inline React.
|
||||
*/
|
||||
export const externalObjectCategoryIcon: Record<string, LucideIcon> = {
|
||||
unknown: CircleDashed,
|
||||
open: CircleDot,
|
||||
waiting: Clock,
|
||||
running: Loader2,
|
||||
succeeded: CheckCircle2,
|
||||
failed: XCircle,
|
||||
blocked: AlertOctagon,
|
||||
closed: Circle,
|
||||
archived: Archive,
|
||||
auth_required: KeyRound,
|
||||
unreachable: CloudOff,
|
||||
};
|
||||
|
||||
export const externalObjectCategoryIconDefault: LucideIcon = CircleDashed;
|
||||
|
||||
export function externalObjectIconForCategory(category: string): LucideIcon {
|
||||
return externalObjectCategoryIcon[category] ?? externalObjectCategoryIconDefault;
|
||||
}
|
||||
|
||||
const EXTERNAL_OBJECT_ICON_KEYS: Record<string, LucideIcon> = {
|
||||
archive: Archive,
|
||||
check: CheckCircle2,
|
||||
"check-circle": CheckCircle2,
|
||||
circle: Circle,
|
||||
"circle-dot": CircleDot,
|
||||
clock: Clock,
|
||||
github: Github,
|
||||
"git-merge": GitMerge,
|
||||
"git-pull-request": GitPullRequest,
|
||||
key: KeyRound,
|
||||
loader: Loader2,
|
||||
"x-circle": XCircle,
|
||||
};
|
||||
|
||||
export function externalObjectIconForKey(iconKey: string | null | undefined): LucideIcon | null {
|
||||
if (!iconKey) return null;
|
||||
return EXTERNAL_OBJECT_ICON_KEYS[iconKey] ?? null;
|
||||
}
|
||||
|
||||
export function externalObjectIconForLiveness(liveness: string): LucideIcon | null {
|
||||
if (liveness === "auth_required") return KeyRound;
|
||||
if (liveness === "unreachable") return CloudOff;
|
||||
return null;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
unknown: "Not yet resolved",
|
||||
open: "Open",
|
||||
waiting: "Waiting",
|
||||
running: "Running",
|
||||
succeeded: "Succeeded",
|
||||
failed: "Failed",
|
||||
blocked: "Blocked",
|
||||
closed: "Closed",
|
||||
archived: "Archived",
|
||||
auth_required: "Authorization required",
|
||||
unreachable: "Unreachable",
|
||||
};
|
||||
|
||||
export function externalObjectCategoryLabel(category: string): string {
|
||||
return CATEGORY_LABELS[category] ?? category.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
const LIVENESS_LABELS: Record<string, string> = {
|
||||
unknown: "Not yet refreshed",
|
||||
fresh: "Fresh",
|
||||
stale: "Stale",
|
||||
auth_required: "Requires auth",
|
||||
unreachable: "Unreachable",
|
||||
};
|
||||
|
||||
export function externalObjectLivenessLabel(liveness: string): string {
|
||||
return LIVENESS_LABELS[liveness] ?? liveness.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher number = more attention-worthy. The rollups in §5 sort by tone first.
|
||||
* Mirrors `externalObjectStatusToneSeverity` in `status-colors.ts`.
|
||||
*/
|
||||
const TONE_SEVERITY: Record<string, number> = {
|
||||
muted: 0,
|
||||
neutral: 1,
|
||||
success: 2,
|
||||
info: 3,
|
||||
warning: 4,
|
||||
danger: 5,
|
||||
};
|
||||
|
||||
export function externalObjectToneSeverity(tone: string | null | undefined): number {
|
||||
if (!tone) return 0;
|
||||
return TONE_SEVERITY[tone] ?? 0;
|
||||
}
|
||||
|
||||
const CATEGORY_TONE_FALLBACK: Record<string, ExternalObjectStatusTone> = {
|
||||
unknown: "muted",
|
||||
open: "info",
|
||||
waiting: "warning",
|
||||
running: "info",
|
||||
succeeded: "success",
|
||||
failed: "danger",
|
||||
blocked: "danger",
|
||||
closed: "muted",
|
||||
archived: "muted",
|
||||
auth_required: "warning",
|
||||
unreachable: "danger",
|
||||
};
|
||||
|
||||
export function externalObjectFallbackTone(
|
||||
category: ExternalObjectStatusCategory,
|
||||
): ExternalObjectStatusTone {
|
||||
return CATEGORY_TONE_FALLBACK[category] ?? "neutral";
|
||||
}
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
github: "GitHub",
|
||||
github_pull_request: "GitHub",
|
||||
github_issue: "GitHub",
|
||||
hubspot: "HubSpot",
|
||||
linear: "Linear",
|
||||
jira: "Jira",
|
||||
notion: "Notion",
|
||||
asana: "Asana",
|
||||
};
|
||||
|
||||
export function externalObjectProviderLabel(providerKey: string | null | undefined): string {
|
||||
if (!providerKey) return "External";
|
||||
const lookup = PROVIDER_LABELS[providerKey];
|
||||
if (lookup) return lookup;
|
||||
return providerKey
|
||||
.split(/[._-]/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const OBJECT_TYPE_LABELS: Record<string, string> = {
|
||||
pull_request: "pull request",
|
||||
issue: "issue",
|
||||
deployment: "deployment",
|
||||
workflow_run: "workflow run",
|
||||
ticket: "ticket",
|
||||
lead: "lead",
|
||||
};
|
||||
|
||||
export function externalObjectTypeLabel(objectType: string | null | undefined): string {
|
||||
if (!objectType) return "object";
|
||||
return OBJECT_TYPE_LABELS[objectType] ?? objectType.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort summary items by severity-first ordering: danger → warning → info →
|
||||
* success → muted/neutral. Within a tone, items keep their incoming order so
|
||||
* server-side ordering (e.g. most recent change first) is preserved.
|
||||
*/
|
||||
export function sortExternalObjectsBySeverity<T extends ExternalObjectSummaryItem>(
|
||||
items: readonly T[],
|
||||
): T[] {
|
||||
return [...items]
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => {
|
||||
const aTone = externalObjectToneSeverity(a.item.statusTone);
|
||||
const bTone = externalObjectToneSeverity(b.item.statusTone);
|
||||
if (aTone !== bTone) return bTone - aTone;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the dominant tone in a summary — used by sidebar / list rollups.
|
||||
* Falls back to `null` when no objects are present or every tone is `muted`.
|
||||
*/
|
||||
export function dominantExternalObjectTone(
|
||||
summary: Pick<ExternalObjectSummary, "highestSeverity" | "objects"> | null | undefined,
|
||||
): ExternalObjectStatusTone | null {
|
||||
if (!summary) return null;
|
||||
const tone = summary.highestSeverity;
|
||||
if (!tone) return null;
|
||||
if (externalObjectToneSeverity(tone) <= TONE_SEVERITY.muted) return null;
|
||||
return tone;
|
||||
}
|
||||
|
||||
/**
|
||||
* For the sidebar / list rollup we want the count of objects matching the
|
||||
* dominant severity (e.g. "3 failed PRs"), not the global total. Returns 0
|
||||
* whenever the dominant tone is muted so callers can render based on the
|
||||
* count without double-checking the rollup-hide rule.
|
||||
*/
|
||||
export function externalObjectDominantCount(
|
||||
summary: Pick<ExternalObjectSummary, "highestSeverity" | "objects"> | null | undefined,
|
||||
): number {
|
||||
if (!summary) return 0;
|
||||
const tone = dominantExternalObjectTone(summary);
|
||||
if (!tone) return 0;
|
||||
return summary.objects.filter((object) => object.statusTone === tone).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduced motion support — match `prefers-reduced-motion: reduce` so the
|
||||
* spinning Loader2 stays static when requested. Hooks consume this via React
|
||||
* to react to runtime changes; non-hook callers can use the helper directly.
|
||||
*/
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
Approval,
|
||||
DashboardSummary,
|
||||
ExecutionWorkspace,
|
||||
ExternalObjectSummary,
|
||||
HeartbeatRun,
|
||||
Issue,
|
||||
JoinRequest,
|
||||
|
|
@ -909,6 +910,7 @@ describe("inbox helpers", () => {
|
|||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
}).map((issue) => issue.id),
|
||||
|
|
@ -929,6 +931,7 @@ describe("inbox helpers", () => {
|
|||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
}),
|
||||
|
|
@ -949,12 +952,65 @@ describe("inbox helpers", () => {
|
|||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies external-object filters to remote inbox search supplements", () => {
|
||||
const failedMatch = makeIssue("failed-match", false);
|
||||
const freshMatch = makeIssue("fresh-match", false);
|
||||
const summaries = new Map<string, ExternalObjectSummary>([
|
||||
["failed-match", {
|
||||
total: 1,
|
||||
byStatusCategory: { failed: 1 },
|
||||
byLiveness: { fresh: 1 },
|
||||
highestSeverity: "danger",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [],
|
||||
}],
|
||||
["fresh-match", {
|
||||
total: 1,
|
||||
byStatusCategory: { succeeded: 1 },
|
||||
byLiveness: { fresh: 1 },
|
||||
highestSeverity: "success",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [],
|
||||
}],
|
||||
]);
|
||||
|
||||
expect(
|
||||
getInboxSearchSupplementIssues({
|
||||
query: "github",
|
||||
filteredWorkItems: [],
|
||||
archivedSearchIssues: [],
|
||||
remoteIssues: [failedMatch, freshMatch],
|
||||
issueFilters: {
|
||||
statuses: [],
|
||||
priorities: [],
|
||||
assignees: [],
|
||||
creators: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: ["failed"],
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
issueFilterContext: {
|
||||
externalObjectSummaryByIssueId: summaries,
|
||||
externalObjectSummariesReady: true,
|
||||
},
|
||||
}).map((issue) => issue.id),
|
||||
).toEqual(["failed-match"]);
|
||||
});
|
||||
|
||||
it("keeps inbox search matches ahead of archived and other result sections", () => {
|
||||
const inboxIssue = makeIssue("inbox", false);
|
||||
inboxIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
|
|
@ -1021,6 +1077,7 @@ describe("inbox helpers", () => {
|
|||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
liveOnly: true,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
|
|
@ -1036,6 +1093,7 @@ describe("inbox helpers", () => {
|
|||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -1052,6 +1110,7 @@ describe("inbox helpers", () => {
|
|||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
liveOnly: true,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
|
|
@ -1067,6 +1126,7 @@ describe("inbox helpers", () => {
|
|||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -1101,6 +1161,7 @@ describe("inbox helpers", () => {
|
|||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
defaultIssueFilterState,
|
||||
normalizeIssueFilterState,
|
||||
type IssueFilterState,
|
||||
type IssueFilterWorkspaceContext,
|
||||
} from "./issue-filters";
|
||||
import { formatAssigneeUserLabel } from "./assignees";
|
||||
|
||||
|
|
@ -462,6 +463,7 @@ export function getInboxSearchSupplementIssues({
|
|||
currentUserId,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
liveIssueIds,
|
||||
issueFilterContext = {},
|
||||
}: {
|
||||
query: string;
|
||||
filteredWorkItems: InboxWorkItem[];
|
||||
|
|
@ -471,6 +473,7 @@ export function getInboxSearchSupplementIssues({
|
|||
currentUserId?: string | null;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
liveIssueIds?: ReadonlySet<string>;
|
||||
issueFilterContext?: IssueFilterWorkspaceContext;
|
||||
}): Issue[] {
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) return [];
|
||||
|
|
@ -480,7 +483,14 @@ export function getInboxSearchSupplementIssues({
|
|||
.map((item) => item.issue.id),
|
||||
...archivedSearchIssues.map((issue) => issue.id),
|
||||
]);
|
||||
return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter, liveIssueIds)
|
||||
return applyIssueFilters(
|
||||
remoteIssues,
|
||||
issueFilters,
|
||||
currentUserId,
|
||||
enableRoutineVisibilityFilter,
|
||||
liveIssueIds,
|
||||
issueFilterContext,
|
||||
)
|
||||
.filter((issue) => !visibleIssueIds.has(issue.id));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { ExternalObjectSummary, Issue } from "@paperclipai/shared";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
countActiveIssueFilters,
|
||||
|
|
@ -51,6 +51,20 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
|||
};
|
||||
}
|
||||
|
||||
function makeExternalObjectSummary(overrides: Partial<ExternalObjectSummary> = {}): ExternalObjectSummary {
|
||||
return {
|
||||
total: 1,
|
||||
byStatusCategory: {},
|
||||
byLiveness: { fresh: 1 },
|
||||
highestSeverity: "neutral",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue filters", () => {
|
||||
it("filters issues by creator across agents and users", () => {
|
||||
const issues = [
|
||||
|
|
@ -169,4 +183,62 @@ describe("issue filters", () => {
|
|||
new Set(["workspace-default"]),
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["failed", ["failed-issue", "blocked-issue"]],
|
||||
["auth_required", ["auth-issue"]],
|
||||
["stale", ["stale-issue"]],
|
||||
["none", ["none-issue"]],
|
||||
])("filters issues by external-object status token %s", (externalObjectStatus, expectedIssueIds) => {
|
||||
const issues = [
|
||||
makeIssue({ id: "failed-issue" }),
|
||||
makeIssue({ id: "blocked-issue" }),
|
||||
makeIssue({ id: "auth-issue" }),
|
||||
makeIssue({ id: "stale-issue" }),
|
||||
makeIssue({ id: "none-issue" }),
|
||||
makeIssue({ id: "fresh-issue" }),
|
||||
];
|
||||
const summaries = new Map<string, ExternalObjectSummary>([
|
||||
["failed-issue", makeExternalObjectSummary({ byStatusCategory: { failed: 1 }, highestSeverity: "danger" })],
|
||||
["blocked-issue", makeExternalObjectSummary({ byStatusCategory: { blocked: 1 }, highestSeverity: "danger" })],
|
||||
["auth-issue", makeExternalObjectSummary({
|
||||
byLiveness: { auth_required: 1 },
|
||||
highestSeverity: "danger",
|
||||
authRequiredCount: 1,
|
||||
})],
|
||||
["stale-issue", makeExternalObjectSummary({
|
||||
byLiveness: { stale: 1 },
|
||||
highestSeverity: "warning",
|
||||
staleCount: 1,
|
||||
})],
|
||||
["fresh-issue", makeExternalObjectSummary({ byStatusCategory: { succeeded: 1 }, highestSeverity: "success" })],
|
||||
]);
|
||||
|
||||
const filtered = applyIssueFilters(
|
||||
issues,
|
||||
{ ...defaultIssueFilterState, externalObjectStatuses: [externalObjectStatus] },
|
||||
null,
|
||||
false,
|
||||
undefined,
|
||||
{
|
||||
externalObjectSummaryByIssueId: summaries,
|
||||
externalObjectSummariesReady: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(filtered.map((issue) => issue.id)).toEqual(expectedIssueIds);
|
||||
});
|
||||
|
||||
it("does not apply external-object filters before summaries are ready", () => {
|
||||
const filtered = applyIssueFilters(
|
||||
[makeIssue({ id: "issue-1" })],
|
||||
{ ...defaultIssueFilterState, externalObjectStatuses: ["none"] },
|
||||
null,
|
||||
false,
|
||||
undefined,
|
||||
{ externalObjectSummaryByIssueId: new Map(), externalObjectSummariesReady: false },
|
||||
);
|
||||
|
||||
expect(filtered).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { ExternalObjectSummary, Issue } from "@paperclipai/shared";
|
||||
|
||||
export type IssueFilterWorkspaceLookup = {
|
||||
mode?: string | null;
|
||||
|
|
@ -8,6 +8,8 @@ export type IssueFilterWorkspaceLookup = {
|
|||
export type IssueFilterWorkspaceContext = {
|
||||
executionWorkspaceById?: ReadonlyMap<string, IssueFilterWorkspaceLookup>;
|
||||
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
|
||||
externalObjectSummaryByIssueId?: ReadonlyMap<string, ExternalObjectSummary>;
|
||||
externalObjectSummariesReady?: boolean;
|
||||
};
|
||||
|
||||
export type IssueFilterState = {
|
||||
|
|
@ -19,6 +21,21 @@ export type IssueFilterState = {
|
|||
projects: string[];
|
||||
workspaces: string[];
|
||||
liveOnly?: boolean;
|
||||
/**
|
||||
* External object status filter. Values are special tokens that map to
|
||||
* properties of the issue's external-object summary (rather than to a
|
||||
* single category) so the filter UI can describe intent rather than every
|
||||
* possible permutation.
|
||||
*
|
||||
* - `failed` — any external object with `statusCategory in (failed, blocked)`
|
||||
* - `waiting` — any external object with `statusCategory in (waiting)`
|
||||
* - `running` — any external object with `statusCategory in (running)`
|
||||
* - `auth_required` — any external object with `liveness == auth_required`
|
||||
* - `unreachable` — any external object with `liveness == unreachable`
|
||||
* - `stale` — any external object with `liveness == stale`
|
||||
* - `none` — issues with zero external objects
|
||||
*/
|
||||
externalObjectStatuses: string[];
|
||||
hideRoutineExecutions: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -31,9 +48,34 @@ export const defaultIssueFilterState: IssueFilterState = {
|
|||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: false,
|
||||
};
|
||||
|
||||
export const externalObjectFilterOrder = [
|
||||
"failed",
|
||||
"waiting",
|
||||
"running",
|
||||
"auth_required",
|
||||
"unreachable",
|
||||
"stale",
|
||||
"none",
|
||||
];
|
||||
|
||||
const EXTERNAL_OBJECT_FILTER_LABELS: Record<string, string> = {
|
||||
failed: "Any failed",
|
||||
waiting: "Any waiting",
|
||||
running: "Any running",
|
||||
auth_required: "Auth required",
|
||||
unreachable: "Unreachable",
|
||||
stale: "Stale",
|
||||
none: "No external objects",
|
||||
};
|
||||
|
||||
export function externalObjectFilterLabel(value: string): string {
|
||||
return EXTERNAL_OBJECT_FILTER_LABELS[value] ?? issueFilterLabel(value);
|
||||
}
|
||||
|
||||
export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||
export const issuePriorityOrder = ["critical", "high", "medium", "low"];
|
||||
|
||||
|
|
@ -72,6 +114,7 @@ export function normalizeIssueFilterState(value: unknown): IssueFilterState {
|
|||
projects: normalizeIssueFilterValueArray(candidate.projects),
|
||||
workspaces: normalizeIssueFilterValueArray(candidate.workspaces),
|
||||
liveOnly: candidate.liveOnly === true,
|
||||
externalObjectStatuses: normalizeIssueFilterValueArray(candidate.externalObjectStatuses),
|
||||
hideRoutineExecutions: candidate.hideRoutineExecutions === true,
|
||||
};
|
||||
}
|
||||
|
|
@ -118,6 +161,39 @@ export function shouldIncludeIssueFilterWorkspaceOption(
|
|||
&& defaultProjectWorkspaceIds.has(workspace.projectWorkspaceId));
|
||||
}
|
||||
|
||||
function summaryRecordCount(record: Record<string, number> | undefined, key: string): number {
|
||||
return record?.[key] ?? 0;
|
||||
}
|
||||
|
||||
function issueMatchesExternalObjectStatusFilter(
|
||||
summary: ExternalObjectSummary | null | undefined,
|
||||
value: string,
|
||||
): boolean {
|
||||
const total = summary?.total ?? 0;
|
||||
switch (value) {
|
||||
case "failed":
|
||||
return summaryRecordCount(summary?.byStatusCategory, "failed") > 0
|
||||
|| summaryRecordCount(summary?.byStatusCategory, "blocked") > 0;
|
||||
case "waiting":
|
||||
return summaryRecordCount(summary?.byStatusCategory, "waiting") > 0;
|
||||
case "running":
|
||||
return summaryRecordCount(summary?.byStatusCategory, "running") > 0;
|
||||
case "auth_required":
|
||||
return (summary?.authRequiredCount ?? 0) > 0
|
||||
|| summaryRecordCount(summary?.byLiveness, "auth_required") > 0;
|
||||
case "unreachable":
|
||||
return (summary?.unreachableCount ?? 0) > 0
|
||||
|| summaryRecordCount(summary?.byLiveness, "unreachable") > 0;
|
||||
case "stale":
|
||||
return (summary?.staleCount ?? 0) > 0
|
||||
|| summaryRecordCount(summary?.byLiveness, "stale") > 0;
|
||||
case "none":
|
||||
return total === 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyIssueFilters(
|
||||
issues: Issue[],
|
||||
state: IssueFilterState,
|
||||
|
|
@ -166,6 +242,16 @@ export function applyIssueFilters(
|
|||
return workspaceId != null && state.workspaces.includes(workspaceId);
|
||||
});
|
||||
}
|
||||
if (state.externalObjectStatuses.length > 0) {
|
||||
const summaries = workspaceContext.externalObjectSummaryByIssueId;
|
||||
if (!summaries || workspaceContext.externalObjectSummariesReady !== true) return [];
|
||||
result = result.filter((issue) => {
|
||||
const summary = summaries.get(issue.id) ?? null;
|
||||
return state.externalObjectStatuses.some((status) =>
|
||||
issueMatchesExternalObjectStatusFilter(summary, status),
|
||||
);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -182,6 +268,7 @@ export function countActiveIssueFilters(
|
|||
if (state.projects.length > 0) count += 1;
|
||||
if (state.workspaces.length > 0) count += 1;
|
||||
if (state.liveOnly) count += 1;
|
||||
if (state.externalObjectStatuses.length > 0) count += 1;
|
||||
if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,13 @@ export const queryKeys = {
|
|||
list: (companyId: string) => ["projects", companyId] as const,
|
||||
detail: (id: string) => ["projects", "detail", id] as const,
|
||||
},
|
||||
externalObjects: {
|
||||
byIssue: (issueId: string) => ["external-objects", "by-issue", issueId] as const,
|
||||
issueSummary: (issueId: string) => ["external-objects", "issue-summary", issueId] as const,
|
||||
issueSummaries: (companyId: string, issueIds: readonly string[]) =>
|
||||
["external-objects", "issue-summaries", companyId, issueIds] as const,
|
||||
projectSummary: (projectId: string) => ["external-objects", "project-summary", projectId] as const,
|
||||
},
|
||||
goals: {
|
||||
list: (companyId: string) => ["goals", companyId] as const,
|
||||
detail: (id: string) => ["goals", "detail", id] as const,
|
||||
|
|
|
|||
|
|
@ -217,3 +217,86 @@ export const priorityColor: Record<string, string> = {
|
|||
};
|
||||
|
||||
export const priorityColorDefault = "text-yellow-600 dark:text-yellow-400";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External object status — colors & severity ranking
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Categories come from `EXTERNAL_OBJECT_STATUS_CATEGORIES` in @paperclipai/shared.
|
||||
// The map keys here intentionally mirror the union — keep them in sync.
|
||||
//
|
||||
// Tone reuse rationale (see UX spec §1):
|
||||
// unknown → backlog hue (muted, dashed circle)
|
||||
// open → todo / blue
|
||||
// waiting → amber (distinct from internal in_progress yellow)
|
||||
// running → cyan, animated when motion is allowed
|
||||
// succeeded → done / green
|
||||
// failed → red
|
||||
// blocked → red
|
||||
// closed → muted neutral
|
||||
// archived → muted neutral
|
||||
// auth_required → amber + dashed
|
||||
// unreachable → red + dashed
|
||||
|
||||
export const externalObjectStatusIcon: Record<string, string> = {
|
||||
unknown: "text-muted-foreground border-muted-foreground",
|
||||
open: "text-blue-600 border-blue-600 dark:text-blue-400 dark:border-blue-400",
|
||||
waiting: "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400",
|
||||
running: "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400",
|
||||
succeeded: "text-green-600 border-green-600 dark:text-green-400 dark:border-green-400",
|
||||
failed: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400",
|
||||
blocked: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400",
|
||||
closed: "text-neutral-500 border-neutral-500",
|
||||
archived: "text-neutral-500 border-neutral-500",
|
||||
auth_required: "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400",
|
||||
unreachable: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400",
|
||||
};
|
||||
|
||||
export const externalObjectStatusIconDefault = "text-muted-foreground border-muted-foreground";
|
||||
|
||||
export const externalObjectStatusBadge: Record<string, string> = {
|
||||
unknown: "bg-muted text-muted-foreground",
|
||||
open: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
|
||||
waiting: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||
running: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300",
|
||||
succeeded: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||
failed: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
blocked: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
closed: "bg-muted text-muted-foreground",
|
||||
archived: "bg-muted text-muted-foreground",
|
||||
auth_required: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||
unreachable: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
};
|
||||
|
||||
export const externalObjectStatusBadgeDefault = "bg-muted text-muted-foreground";
|
||||
|
||||
/**
|
||||
* Liveness overlay applied on top of the base status tone. We deliberately
|
||||
* encode it as utility classes (not a tone change) so callers can append the
|
||||
* overlay to any pill, icon, or marker without redefining colors.
|
||||
*
|
||||
* The dashed border + reduced opacity guarantees a non-color differentiator
|
||||
* for stale / auth_required / unreachable per WCAG 1.4.1.
|
||||
*/
|
||||
export const externalObjectLivenessOverlay: Record<string, string> = {
|
||||
unknown: "",
|
||||
fresh: "",
|
||||
stale: "opacity-70 [border-style:dashed]",
|
||||
auth_required: "[border-style:dashed]",
|
||||
unreachable: "[border-style:dashed]",
|
||||
};
|
||||
|
||||
/**
|
||||
* Severity ranking used by sidebar/list rollups. Higher number = more
|
||||
* attention-worthy. Anything ≤ `muted` should be hidden when summarising.
|
||||
*/
|
||||
export const externalObjectStatusToneSeverity: Record<string, number> = {
|
||||
muted: 0,
|
||||
neutral: 1,
|
||||
success: 2,
|
||||
info: 3,
|
||||
warning: 4,
|
||||
danger: 5,
|
||||
};
|
||||
|
||||
export const externalObjectStatusToneSeverityDefault = 0;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useGeneralSettings } from "../context/GeneralSettingsContext";
|
|||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
countActiveIssueFilters,
|
||||
|
|
@ -732,6 +733,7 @@ export function Inbox() {
|
|||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true;
|
||||
const { data: executionWorkspaces = [] } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.executionWorkspaces.summaryList(selectedCompanyId)
|
||||
|
|
@ -863,13 +865,49 @@ export function Inbox() {
|
|||
|
||||
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
||||
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
|
||||
const shouldUseIssueSearchSupplement =
|
||||
!!selectedCompanyId
|
||||
&& normalizedSearchQuery.length > 0;
|
||||
const { data: remoteIssueSearchResults = [] } = useQuery({
|
||||
queryKey: [
|
||||
...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25),
|
||||
"inbox-supplement",
|
||||
],
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
q: normalizedSearchQuery,
|
||||
limit: 25,
|
||||
includeRoutineExecutions: true,
|
||||
}),
|
||||
enabled: shouldUseIssueSearchSupplement,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
const inboxIssueIdsForExternalObjectSummaries = useMemo(() => {
|
||||
const issueIds = new Set<string>();
|
||||
for (const issue of mineIssues) issueIds.add(issue.id);
|
||||
for (const issue of touchedIssues) issueIds.add(issue.id);
|
||||
for (const issue of remoteIssueSearchResults) issueIds.add(issue.id);
|
||||
return [...issueIds];
|
||||
}, [mineIssues, remoteIssueSearchResults, touchedIssues]);
|
||||
const {
|
||||
summaries: externalObjectSummaryByIssueId,
|
||||
isLoading: externalObjectSummariesLoading,
|
||||
isReady: externalObjectSummariesReady,
|
||||
} = useIssueExternalObjectSummaries(
|
||||
selectedCompanyId,
|
||||
inboxIssueIdsForExternalObjectSummaries,
|
||||
);
|
||||
const issueFilterContext = useMemo(() => ({
|
||||
externalObjectSummaryByIssueId,
|
||||
externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading,
|
||||
}), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId]);
|
||||
const visibleMineIssues = useMemo(
|
||||
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds),
|
||||
[mineIssues, issueFilters, currentUserId, liveIssueIds],
|
||||
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext),
|
||||
[mineIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext],
|
||||
);
|
||||
const visibleTouchedIssues = useMemo(
|
||||
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds),
|
||||
[touchedIssues, issueFilters, currentUserId, liveIssueIds],
|
||||
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext),
|
||||
[touchedIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext],
|
||||
);
|
||||
const unreadTouchedIssues = useMemo(
|
||||
() => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||
|
|
@ -1160,23 +1198,6 @@ export function Inbox() {
|
|||
visibleTouchedIssues,
|
||||
],
|
||||
);
|
||||
const shouldUseIssueSearchSupplement =
|
||||
!!selectedCompanyId
|
||||
&& normalizedSearchQuery.length > 0;
|
||||
const { data: remoteIssueSearchResults = [] } = useQuery({
|
||||
queryKey: [
|
||||
...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25),
|
||||
"inbox-supplement",
|
||||
],
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
q: normalizedSearchQuery,
|
||||
limit: 25,
|
||||
includeRoutineExecutions: true,
|
||||
}),
|
||||
enabled: shouldUseIssueSearchSupplement,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
const issueSearchSupplementResults = useMemo(
|
||||
() =>
|
||||
getInboxSearchSupplementIssues({
|
||||
|
|
@ -1188,11 +1209,13 @@ export function Inbox() {
|
|||
currentUserId,
|
||||
enableRoutineVisibilityFilter: true,
|
||||
liveIssueIds,
|
||||
issueFilterContext,
|
||||
}),
|
||||
[
|
||||
archivedSearchIssues,
|
||||
currentUserId,
|
||||
filteredWorkItems,
|
||||
issueFilterContext,
|
||||
issueFilters,
|
||||
liveIssueIds,
|
||||
normalizedSearchQuery,
|
||||
|
|
@ -1367,6 +1390,15 @@ export function Inbox() {
|
|||
issueFilters: { ...previous.issueFilters, ...patch },
|
||||
}));
|
||||
}, [updateFilterPreferences]);
|
||||
useEffect(() => {
|
||||
if (!experimentalSettingsLoaded || externalObjectsEnabled || issueFilters.externalObjectStatuses.length === 0) return;
|
||||
updateIssueFilters({ externalObjectStatuses: [] });
|
||||
}, [
|
||||
experimentalSettingsLoaded,
|
||||
externalObjectsEnabled,
|
||||
issueFilters.externalObjectStatuses.length,
|
||||
updateIssueFilters,
|
||||
]);
|
||||
const updateAllCategoryFilter = useCallback((value: InboxCategoryFilter) => {
|
||||
updateFilterPreferences((previous) => ({ ...previous, allCategoryFilter: value }));
|
||||
}, [updateFilterPreferences]);
|
||||
|
|
@ -2027,6 +2059,7 @@ export function Inbox() {
|
|||
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
|
||||
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId={currentUserId}
|
||||
enableExternalObjectFilters={externalObjectsEnabled}
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
iconOnly
|
||||
|
|
@ -2124,6 +2157,7 @@ export function Inbox() {
|
|||
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
|
||||
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId={currentUserId}
|
||||
enableExternalObjectFilters={externalObjectsEnabled}
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
iconOnly
|
||||
|
|
@ -2334,6 +2368,7 @@ export function Inbox() {
|
|||
key={`issue:${issue.id}`}
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
externalObjectSummary={externalObjectSummaryByIssueId.get(issue.id) ?? null}
|
||||
selected={selected}
|
||||
className={
|
||||
isArchiving
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|||
enableConferenceRoomChat: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
enableTaskWatchdogs: false,
|
||||
enableCloudSync: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ export function InstanceExperimentalSettings() {
|
|||
experimentalQuery.data?.enableExperimentalFileViewer === true;
|
||||
const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true;
|
||||
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
|
||||
const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true;
|
||||
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
||||
const enableIssueGraphLivenessAutoRecovery =
|
||||
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
|
||||
|
|
@ -355,6 +356,24 @@ export function InstanceExperimentalSettings() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Enable External Objects</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Detect external URLs in issues and show resolved status for pull requests, tickets, and other referenced
|
||||
work objects.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={enableExternalObjects}
|
||||
onCheckedChange={() => toggleMutation.mutate({ enableExternalObjects: !enableExternalObjects })}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label="Toggle external objects experimental setting"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -957,6 +957,7 @@ describe("IssueDetail", () => {
|
|||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
});
|
||||
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
|
||||
conferenceRoomChatFlag.enabled = true;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import { IssueOutputSection } from "../components/issue-output/IssueOutputSectio
|
|||
import { isImageAttachment } from "../lib/issue-attachments";
|
||||
import { getPromotedOutputAttachmentIds } from "../lib/issue-output";
|
||||
import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
|
||||
import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
|
||||
|
|
@ -88,6 +89,7 @@ import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard";
|
|||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews";
|
||||
import { computePauseAffectsSummary } from "../lib/interrupt-handoff";
|
||||
import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects";
|
||||
import { IssueRunLedger } from "../components/IssueRunLedger";
|
||||
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
|
|
@ -743,6 +745,7 @@ type IssueDetailChatTabProps = {
|
|||
assigneeUserId: string | null;
|
||||
onResumeFromBacklog?: () => Promise<void> | void;
|
||||
resumeFromBacklogPending?: boolean;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
};
|
||||
|
||||
const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
|
|
@ -804,6 +807,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
assigneeUserId,
|
||||
onResumeFromBacklog,
|
||||
resumeFromBacklogPending,
|
||||
externalReferences,
|
||||
}: IssueDetailChatTabProps) {
|
||||
// Conference Room Chat experimental flag (PAP-136/PAP-139): ON renders the
|
||||
// NUX thread (bubbles, metadata rows, composer chrome); OFF renders the
|
||||
|
|
@ -1028,6 +1032,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
onResumeFromBacklog={onResumeFromBacklog}
|
||||
resumeFromBacklogPending={resumeFromBacklogPending}
|
||||
footer={footer}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1048,6 +1053,7 @@ type IssueDetailActivityTabProps = {
|
|||
onCheckMonitorNow: () => void;
|
||||
checkingMonitorNow: boolean;
|
||||
handoffFocusSignal?: number;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
};
|
||||
|
||||
function IssueDetailActivityTab({
|
||||
|
|
@ -1065,6 +1071,7 @@ function IssueDetailActivityTab({
|
|||
onCheckMonitorNow,
|
||||
checkingMonitorNow,
|
||||
handoffFocusSignal = 0,
|
||||
externalReferences,
|
||||
}: IssueDetailActivityTabProps) {
|
||||
const { data: activity, isLoading: activityLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.activity(issueId),
|
||||
|
|
@ -1266,6 +1273,11 @@ function IssueDetailActivityTab({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
<IssueContinuationHandoff
|
||||
document={continuationHandoff}
|
||||
focusSignal={handoffFocusSignal}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
{linkedApprovals && linkedApprovals.length > 0 && (
|
||||
<div className="mb-3 space-y-3">
|
||||
{linkedApprovals.map((approval) => (
|
||||
|
|
@ -1286,7 +1298,6 @@ function IssueDetailActivityTab({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
|
||||
<IssueScheduledRetryCard issueId={issue.id} scheduledRetry={issue.scheduledRetry ?? null} />
|
||||
<IssueMonitorActivityCard
|
||||
issue={issue}
|
||||
|
|
@ -1355,6 +1366,7 @@ export function IssueDetail() {
|
|||
enabled: !!issueId,
|
||||
});
|
||||
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
|
||||
const externalObjectsState = useIssueExternalObjects(issue?.id ?? null);
|
||||
const commentComposerDisabledReason = useMemo(() => {
|
||||
if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) {
|
||||
return null;
|
||||
|
|
@ -2859,6 +2871,10 @@ export function IssueDetail() {
|
|||
onAddSubIssue={openNewSubIssue}
|
||||
onUpdate={handleIssuePropertiesUpdate}
|
||||
hasActiveRun={resolvedHasActiveRun}
|
||||
externalObjects={externalObjectsState.isEnabled ? externalObjectsState.groups : undefined}
|
||||
externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined}
|
||||
externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined}
|
||||
onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined}
|
||||
/>
|
||||
);
|
||||
return () => closePanel();
|
||||
|
|
@ -2871,6 +2887,11 @@ export function IssueDetail() {
|
|||
panelChildIssues,
|
||||
panelIssue,
|
||||
resolvedHasActiveRun,
|
||||
externalObjectsState.isEnabled,
|
||||
externalObjectsState.groups,
|
||||
externalObjectsState.isLoading,
|
||||
externalObjectsState.isError,
|
||||
externalObjectsState.refetch,
|
||||
]);
|
||||
|
||||
const goToInboxShortcutArmedRef = useRef(false);
|
||||
|
|
@ -3947,6 +3968,7 @@ export function IssueDetail() {
|
|||
multiline
|
||||
foldable
|
||||
mentions={mentionOptions}
|
||||
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
|
|
@ -4049,6 +4071,7 @@ export function IssueDetail() {
|
|||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
||||
mentions={mentionOptions}
|
||||
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
|
|
@ -4240,6 +4263,7 @@ export function IssueDetail() {
|
|||
resumeFromBacklogPending={
|
||||
updateIssue.isPending && updateIssue.variables?.status === "todo"
|
||||
}
|
||||
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
|
@ -4263,12 +4287,20 @@ export function IssueDetail() {
|
|||
}}
|
||||
onCheckMonitorNow={() => checkIssueMonitorNow.mutate()}
|
||||
checkingMonitorNow={checkIssueMonitorNow.isPending}
|
||||
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="related-work">
|
||||
<IssueRelatedWorkPanel relatedWork={issue.relatedWork} />
|
||||
<IssueRelatedWorkPanel
|
||||
relatedWork={issue.relatedWork}
|
||||
externalObjectsEnabled={externalObjectsState.isEnabled}
|
||||
externalObjects={externalObjectsState.isEnabled ? externalObjectsState.groups : undefined}
|
||||
externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined}
|
||||
externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined}
|
||||
onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{activePluginTab && (
|
||||
|
|
@ -4456,6 +4488,10 @@ export function IssueDetail() {
|
|||
onUpdate={(data) => updateIssue.mutate(data)}
|
||||
inline
|
||||
hasActiveRun={resolvedHasActiveRun}
|
||||
externalObjects={externalObjectsState.isEnabled ? externalObjectsState.groups : undefined}
|
||||
externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined}
|
||||
externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined}
|
||||
onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ const config: StorybookConfig = {
|
|||
alias: {
|
||||
"@": path.resolve(storybookConfigDir, "../../src"),
|
||||
lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/Lexical.mjs"),
|
||||
// Vite's bundled `node:crypto` polyfill omits `createHash`, which
|
||||
// `@paperclipai/shared/external-objects.ts` imports server-side. Use
|
||||
// a no-op browser shim so the import resolves; the canonicalizer
|
||||
// only runs server-side.
|
||||
"node:crypto": path.resolve(storybookConfigDir, "node-crypto-browser-shim.ts"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Browser-safe shim for the slice of `node:crypto` that
|
||||
* `@paperclipai/shared/external-objects.ts` imports.
|
||||
*
|
||||
* The shared canonicalizer runs server-side and never executes in the browser,
|
||||
* but the static `import { createHash } from "node:crypto"` is still pulled
|
||||
* into Storybook's module graph. Vite's built-in `node:crypto` polyfill does
|
||||
* not export `createHash`, so the build fails before our code runs. This shim
|
||||
* provides a no-op implementation so the bundler can resolve the import; if a
|
||||
* code path ever reaches it in the browser we throw loudly so we notice.
|
||||
*/
|
||||
|
||||
class BrowserHash {
|
||||
update(_value: string): this {
|
||||
throw new Error(
|
||||
"createHash from node:crypto is not available in the browser bundle",
|
||||
);
|
||||
}
|
||||
|
||||
digest(_encoding: string): string {
|
||||
throw new Error(
|
||||
"createHash from node:crypto is not available in the browser bundle",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createHash(_algorithm: string): BrowserHash {
|
||||
return new BrowserHash();
|
||||
}
|
||||
|
||||
export default { createHash };
|
||||
|
|
@ -4,6 +4,7 @@ import type { TranscriptEntry } from "@/adapters";
|
|||
import type { LiveRunForIssue } from "@/api/heartbeats";
|
||||
import { CommentThread } from "@/components/CommentThread";
|
||||
import { IssueChatThread } from "@/components/IssueChatThread";
|
||||
import type { MarkdownExternalReferenceMap } from "@/components/MarkdownBody";
|
||||
import { RunChatSurface } from "@/components/RunChatSurface";
|
||||
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
|
||||
import type { MentionOption } from "@/components/MarkdownEditor";
|
||||
|
|
@ -376,14 +377,14 @@ const liveRunTranscript: TranscriptEntry[] = [
|
|||
const issueChatComments: IssueChatComment[] = [
|
||||
createComment({
|
||||
id: "comment-issue-board",
|
||||
body: "Please turn the comment thread into a reviewable chat surface. I need to see operator messages, agent output, system events, and live run progress together.",
|
||||
body: "Please turn the comment thread into a reviewable chat surface. I need to see operator messages, agent output, system events, and live run progress together.\n\nFollow-up tracked in https://github.com/acme/web/pull/241 (merged) and https://github.com/acme/web/pull/243 (review pending).",
|
||||
createdAt: new Date("2026-04-20T13:44:00.000Z"),
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-issue-agent",
|
||||
authorAgentId: codexAgent.id,
|
||||
authorUserId: null,
|
||||
body: "I kept the existing component contracts and added fixtures with realistic Paperclip work: checkout, comments, linked runs, and review feedback.",
|
||||
body: "I kept the existing component contracts and added fixtures with realistic Paperclip work: checkout, comments, linked runs, and review feedback.\n\nFlaky CI lives in https://github.com/acme/web/pull/242 — re-running. Plain control link: https://random.example.com/path stays undecorated.",
|
||||
createdAt: new Date("2026-04-20T13:50:00.000Z"),
|
||||
runId: "run-issue-chat-01",
|
||||
runAgentId: codexAgent.id,
|
||||
|
|
@ -592,10 +593,12 @@ function ThreadProps({
|
|||
comments,
|
||||
queuedComments = [],
|
||||
timelineEvents = [],
|
||||
externalReferences,
|
||||
}: {
|
||||
comments: StoryComment[];
|
||||
queuedComments?: StoryComment[];
|
||||
timelineEvents?: IssueTimelineEvent[];
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
return (
|
||||
<CommentThread
|
||||
|
|
@ -615,10 +618,77 @@ function ThreadProps({
|
|||
suggestedAssigneeValue={`agent:${codexAgent.id}`}
|
||||
mentions={mentionOptions}
|
||||
onInterruptQueued={async () => {}}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const externalReferenceComments: StoryComment[] = [
|
||||
createComment({
|
||||
id: "comment-external-board",
|
||||
body: [
|
||||
"Tracking work that just landed:",
|
||||
"",
|
||||
"- Merged PR: https://github.com/acme/web/pull/241",
|
||||
"- Awaiting review: https://github.com/acme/web/pull/243",
|
||||
"- Auth-blocked: https://app.hubspot.com/leads/99",
|
||||
"- Plain control link (no decoration): https://random.example.com/path",
|
||||
].join("\n"),
|
||||
createdAt: new Date("2026-04-20T14:02:00.000Z"),
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-external-agent",
|
||||
authorAgentId: codexAgent.id,
|
||||
authorUserId: null,
|
||||
body: [
|
||||
"Confirmed handoff updated.",
|
||||
"Failed CI on https://github.com/acme/web/pull/242 needs a rerun.",
|
||||
"",
|
||||
"```",
|
||||
"Code-fenced URLs stay plain: https://github.com/acme/web/pull/241",
|
||||
"```",
|
||||
].join("\n"),
|
||||
createdAt: new Date("2026-04-20T14:05:00.000Z"),
|
||||
runId: "run-external-01",
|
||||
runAgentId: codexAgent.id,
|
||||
}),
|
||||
];
|
||||
|
||||
const externalReferences: MarkdownExternalReferenceMap = {
|
||||
"https://github.com/acme/web/pull/241": {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "succeeded",
|
||||
liveness: "fresh",
|
||||
statusLabel: "Merged",
|
||||
displayTitle: "Add external refs",
|
||||
},
|
||||
"https://github.com/acme/web/pull/242": {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "failed",
|
||||
liveness: "stale",
|
||||
statusLabel: "CI failed",
|
||||
displayTitle: "Flaky tests",
|
||||
},
|
||||
"https://github.com/acme/web/pull/243": {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "waiting",
|
||||
liveness: "fresh",
|
||||
statusLabel: "Awaiting review",
|
||||
displayTitle: "Add liveness overlay",
|
||||
},
|
||||
"https://app.hubspot.com/leads/99": {
|
||||
providerKey: "hubspot",
|
||||
objectType: "lead",
|
||||
statusCategory: "auth_required",
|
||||
liveness: "auth_required",
|
||||
statusLabel: "Reconnect",
|
||||
displayTitle: "Acme deal",
|
||||
},
|
||||
};
|
||||
|
||||
function CommentThreadMatrix() {
|
||||
return (
|
||||
<Section eyebrow="CommentThread" title="Timeline comments across empty, single, long, markdown, and queued states">
|
||||
|
|
@ -635,6 +705,15 @@ function CommentThreadMatrix() {
|
|||
<ScenarioCard title="Markdown, code, mentions, and links" description="Markdown rendering with code fences, @mentions, links, and a queued reply.">
|
||||
<ThreadProps comments={markdownComments} queuedComments={[queuedComment]} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="External object decoration"
|
||||
description="Resolved URLs render with the §2 status chip; an unknown URL stays plain. Code-fenced URLs are not decorated."
|
||||
>
|
||||
<ThreadProps
|
||||
comments={externalReferenceComments}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
|
@ -709,6 +788,7 @@ function IssueChatMatrix() {
|
|||
includeSucceededRunsWithoutOutput
|
||||
onInterruptQueued={async () => {}}
|
||||
onCancelQueued={() => undefined}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,720 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
EXTERNAL_OBJECT_LIVENESS_STATES,
|
||||
EXTERNAL_OBJECT_STATUS_CATEGORIES,
|
||||
type ExternalObject,
|
||||
type ExternalObjectLivenessState,
|
||||
type ExternalObjectMention,
|
||||
type ExternalObjectStatusCategory,
|
||||
type ExternalObjectSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { ExternalObjectPill } from "@/components/ExternalObjectPill";
|
||||
import { ExternalObjectStatusIcon } from "@/components/ExternalObjectStatusIcon";
|
||||
import { ExternalObjectStatusSummary } from "@/components/ExternalObjectStatusSummary";
|
||||
import { IssueFiltersPopover } from "@/components/IssueFiltersPopover";
|
||||
import { IssueProperties } from "@/components/IssueProperties";
|
||||
import { IssueRelatedWorkPanel } from "@/components/IssueRelatedWorkPanel";
|
||||
import { IssueRow } from "@/components/IssueRow";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "@/components/MarkdownBody";
|
||||
import {
|
||||
countActiveIssueFilters,
|
||||
defaultIssueFilterState,
|
||||
type IssueFilterState,
|
||||
} from "@/lib/issue-filters";
|
||||
import {
|
||||
externalObjectCategoryLabel,
|
||||
externalObjectFallbackTone,
|
||||
externalObjectLivenessLabel,
|
||||
externalObjectProviderLabel,
|
||||
externalObjectTypeLabel,
|
||||
} from "@/lib/external-objects";
|
||||
import type { IssueExternalObjectGroup } from "@/hooks/useIssueExternalObjects";
|
||||
import {
|
||||
storybookAgents,
|
||||
storybookExecutionWorkspaces,
|
||||
storybookIssueLabels,
|
||||
storybookIssues,
|
||||
storybookProjects,
|
||||
} from "../fixtures/paperclipData";
|
||||
|
||||
function makeObject(args: {
|
||||
id: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
liveness: ExternalObjectLivenessState;
|
||||
displayTitle?: string;
|
||||
url: string;
|
||||
statusLabel?: string;
|
||||
}): ExternalObject {
|
||||
return {
|
||||
id: args.id,
|
||||
companyId: "company-1",
|
||||
providerKey: args.providerKey,
|
||||
pluginId: null,
|
||||
objectType: args.objectType,
|
||||
externalId: args.id,
|
||||
sanitizedCanonicalUrl: args.url,
|
||||
canonicalIdentityHash: args.id,
|
||||
displayTitle: args.displayTitle ?? null,
|
||||
statusKey: args.statusCategory,
|
||||
statusLabel: args.statusLabel ?? externalObjectCategoryLabel(args.statusCategory),
|
||||
statusCategory: args.statusCategory,
|
||||
statusTone: externalObjectFallbackTone(args.statusCategory),
|
||||
liveness: args.liveness,
|
||||
isTerminal: ["succeeded", "failed", "closed", "archived"].includes(args.statusCategory),
|
||||
data: {},
|
||||
remoteVersion: null,
|
||||
etag: null,
|
||||
lastResolvedAt: "2026-04-24T22:45:00.000Z",
|
||||
lastChangedAt: "2026-04-24T22:45:00.000Z",
|
||||
lastErrorAt: args.liveness === "unreachable" ? "2026-04-24T22:50:00.000Z" : null,
|
||||
nextRefreshAt: null,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
createdAt: "2026-04-24T20:00:00.000Z",
|
||||
updatedAt: "2026-04-24T22:45:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function makeMention(args: {
|
||||
id: string;
|
||||
objectId: string;
|
||||
sourceKind: ExternalObjectMention["sourceKind"];
|
||||
documentKey?: string | null;
|
||||
}): ExternalObjectMention {
|
||||
return {
|
||||
id: args.id,
|
||||
companyId: "company-1",
|
||||
sourceIssueId: "issue-1",
|
||||
sourceKind: args.sourceKind,
|
||||
sourceRecordId: null,
|
||||
documentKey: args.documentKey ?? null,
|
||||
propertyKey: null,
|
||||
matchedTextRedacted: null,
|
||||
sanitizedDisplayUrl: "https://example.com/object",
|
||||
canonicalIdentityHash: args.objectId,
|
||||
canonicalIdentity: null,
|
||||
objectId: args.objectId,
|
||||
providerKey: null,
|
||||
detectorKey: null,
|
||||
objectType: null,
|
||||
confidence: "exact",
|
||||
createdByPluginId: null,
|
||||
createdAt: "2026-04-24T20:00:00.000Z",
|
||||
updatedAt: "2026-04-24T22:45:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function makeGroup(args: {
|
||||
object: ExternalObject;
|
||||
mentionCount?: number;
|
||||
sourceLabels?: string[];
|
||||
}): IssueExternalObjectGroup {
|
||||
return {
|
||||
group: {
|
||||
object: args.object,
|
||||
mentions: [makeMention({ id: `${args.object.id}-m`, objectId: args.object.id, sourceKind: "description" })],
|
||||
mentionCount: args.mentionCount ?? 1,
|
||||
sourceLabels: args.sourceLabels ?? ["description"],
|
||||
},
|
||||
pill: {
|
||||
providerKey: args.object.providerKey,
|
||||
objectType: args.object.objectType,
|
||||
statusCategory: args.object.statusCategory,
|
||||
liveness: args.object.liveness,
|
||||
displayTitle: args.object.displayTitle,
|
||||
statusLabel: args.object.statusLabel,
|
||||
url: args.object.sanitizedCanonicalUrl,
|
||||
},
|
||||
mentionCount: args.mentionCount ?? 1,
|
||||
sourceLabels: args.sourceLabels ?? ["description"],
|
||||
};
|
||||
}
|
||||
|
||||
function StateMatrix() {
|
||||
return (
|
||||
<div className="paperclip-story space-y-8">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">External object status matrix</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Every status category × liveness combination from the UX spec §6, used as the canonical
|
||||
presentational reference for inline markdown, pills, properties, and rollups.
|
||||
</p>
|
||||
</header>
|
||||
<table className="w-full table-auto border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-3 py-2">Category</th>
|
||||
{EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => (
|
||||
<th key={liveness} className="px-3 py-2">{externalObjectLivenessLabel(liveness)}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{EXTERNAL_OBJECT_STATUS_CATEGORIES.map((category) => (
|
||||
<tr key={category} className="border-b border-border">
|
||||
<td className="px-3 py-3 font-mono text-xs text-muted-foreground">{category}</td>
|
||||
{EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => (
|
||||
<td key={liveness} className="px-3 py-3">
|
||||
<ExternalObjectStatusIcon
|
||||
category={category}
|
||||
liveness={liveness}
|
||||
sizeClassName="h-5 w-5"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-medium">Pills (host-rendered identity, no plugin React)</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "succeeded",
|
||||
liveness: "fresh",
|
||||
displayTitle: "Add external refs",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
}}
|
||||
sourceCount={4}
|
||||
sourceSummary="description, 3 comments"
|
||||
/>
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "failed",
|
||||
liveness: "stale",
|
||||
displayTitle: "Bad CI run",
|
||||
url: "https://github.com/acme/web/pull/242",
|
||||
}}
|
||||
sourceCount={2}
|
||||
/>
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "hubspot",
|
||||
objectType: "lead",
|
||||
statusCategory: "auth_required",
|
||||
liveness: "auth_required",
|
||||
displayTitle: "Acme deal",
|
||||
url: "https://app.hubspot.com/leads/99",
|
||||
}}
|
||||
/>
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "linear",
|
||||
objectType: "issue",
|
||||
statusCategory: "running",
|
||||
liveness: "fresh",
|
||||
displayTitle: "Spike: queues",
|
||||
url: "https://linear.app/acme/issue/INF-44",
|
||||
}}
|
||||
/>
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "ci",
|
||||
objectType: "deployment",
|
||||
statusCategory: "unreachable",
|
||||
liveness: "unreachable",
|
||||
displayTitle: "deploy prod-0412",
|
||||
url: "https://ci.example.com/runs/88421",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function inlineMarkdownStory() {
|
||||
const references: MarkdownExternalReferenceMap = {
|
||||
"https://github.com/acme/web/pull/241": {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "succeeded",
|
||||
liveness: "fresh",
|
||||
statusLabel: "Merged",
|
||||
displayTitle: "Add external refs",
|
||||
},
|
||||
"https://github.com/acme/web/pull/242": {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "failed",
|
||||
liveness: "stale",
|
||||
statusLabel: "CI failed",
|
||||
displayTitle: "Flaky tests",
|
||||
},
|
||||
"https://github.com/acme/web/pull/243": {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "waiting",
|
||||
liveness: "fresh",
|
||||
statusLabel: "Awaiting review",
|
||||
displayTitle: "Add liveness overlay",
|
||||
},
|
||||
"https://app.hubspot.com/leads/99": {
|
||||
providerKey: "hubspot",
|
||||
objectType: "lead",
|
||||
statusCategory: "auth_required",
|
||||
liveness: "auth_required",
|
||||
statusLabel: "Reconnect",
|
||||
displayTitle: "Acme deal",
|
||||
},
|
||||
"https://ci.example.com/runs/88421": {
|
||||
providerKey: "ci",
|
||||
objectType: "deployment",
|
||||
statusCategory: "unreachable",
|
||||
liveness: "unreachable",
|
||||
statusLabel: "Unreachable",
|
||||
displayTitle: "Prod-0412",
|
||||
},
|
||||
};
|
||||
const markdown = `Status of recent integrations:\n\n- Merged PR: https://github.com/acme/web/pull/241\n- Stale failed CI: https://github.com/acme/web/pull/242\n- Awaiting review: https://github.com/acme/web/pull/243\n- Auth required: https://app.hubspot.com/leads/99\n- Unreachable deploy: https://ci.example.com/runs/88421\n- Unrelated control link: https://random.example.com/path\n\n\`\`\`\n# Code blocks must be left alone — https://github.com/acme/web/pull/241\n\`\`\`\n\nInline code stays plain too: \`https://github.com/acme/web/pull/241\`.`;
|
||||
return (
|
||||
<div className="paperclip-story space-y-4 text-sm">
|
||||
<h2 className="text-lg font-medium">Inline markdown decoration</h2>
|
||||
<MarkdownBody externalReferences={references}>{markdown}</MarkdownBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function relatedWorkStory() {
|
||||
const externalObjects = [
|
||||
makeGroup({
|
||||
object: makeObject({
|
||||
id: "obj-1",
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "failed",
|
||||
liveness: "fresh",
|
||||
displayTitle: "Add external refs",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
}),
|
||||
mentionCount: 4,
|
||||
sourceLabels: ["description", "comments", "plan document"],
|
||||
}),
|
||||
makeGroup({
|
||||
object: makeObject({
|
||||
id: "obj-2",
|
||||
providerKey: "hubspot",
|
||||
objectType: "lead",
|
||||
statusCategory: "auth_required",
|
||||
liveness: "auth_required",
|
||||
displayTitle: "Acme deal",
|
||||
url: "https://app.hubspot.com/leads/99",
|
||||
}),
|
||||
mentionCount: 1,
|
||||
sourceLabels: ["External links property"],
|
||||
}),
|
||||
makeGroup({
|
||||
object: makeObject({
|
||||
id: "obj-3",
|
||||
providerKey: "ci",
|
||||
objectType: "deployment",
|
||||
statusCategory: "running",
|
||||
liveness: "fresh",
|
||||
displayTitle: "deploy prod-0412",
|
||||
url: "https://ci.example.com/runs/88421",
|
||||
}),
|
||||
mentionCount: 2,
|
||||
sourceLabels: ["comments"],
|
||||
}),
|
||||
makeGroup({
|
||||
object: makeObject({
|
||||
id: "obj-4",
|
||||
providerKey: "github",
|
||||
objectType: "issue",
|
||||
statusCategory: "succeeded",
|
||||
liveness: "stale",
|
||||
displayTitle: "Closed parent issue",
|
||||
url: "https://github.com/acme/web/issues/15",
|
||||
}),
|
||||
mentionCount: 1,
|
||||
sourceLabels: ["comments"],
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<IssueRelatedWorkPanel
|
||||
relatedWork={{
|
||||
outbound: [],
|
||||
inbound: [],
|
||||
}}
|
||||
externalObjects={externalObjects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function projectsRollupStory() {
|
||||
function summary(args: {
|
||||
failed?: number;
|
||||
waiting?: number;
|
||||
running?: number;
|
||||
succeeded?: number;
|
||||
auth?: number;
|
||||
stale?: number;
|
||||
}): ExternalObjectSummary {
|
||||
const objects: ExternalObjectSummary["objects"] = [];
|
||||
function pushMany(category: ExternalObjectStatusCategory, count: number) {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
objects.push({
|
||||
id: `obj-${category}-${i}`,
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
displayTitle: null,
|
||||
statusCategory: category,
|
||||
statusTone: externalObjectFallbackTone(category),
|
||||
liveness: args.stale && i % 2 === 0 ? "stale" : "fresh",
|
||||
isTerminal: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
pushMany("failed", args.failed ?? 0);
|
||||
pushMany("waiting", args.waiting ?? 0);
|
||||
pushMany("running", args.running ?? 0);
|
||||
pushMany("succeeded", args.succeeded ?? 0);
|
||||
pushMany("auth_required", args.auth ?? 0);
|
||||
const tones = objects.map((o) => o.statusTone);
|
||||
const dominant: ExternalObjectSummary["highestSeverity"] =
|
||||
tones.includes("danger") ? "danger"
|
||||
: tones.includes("warning") ? "warning"
|
||||
: tones.includes("info") ? "info"
|
||||
: tones.includes("success") ? "success"
|
||||
: "muted";
|
||||
const byStatusCategory: Record<string, number> = {};
|
||||
for (const obj of objects) {
|
||||
byStatusCategory[obj.statusCategory] = (byStatusCategory[obj.statusCategory] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
total: objects.length,
|
||||
byStatusCategory,
|
||||
byLiveness: { fresh: objects.length, stale: 0, auth_required: 0, unreachable: 0, unknown: 0 },
|
||||
highestSeverity: dominant,
|
||||
staleCount: args.stale ?? 0,
|
||||
authRequiredCount: args.auth ?? 0,
|
||||
unreachableCount: 0,
|
||||
objects,
|
||||
};
|
||||
}
|
||||
|
||||
const projects = [
|
||||
{ name: "Paperclip App", color: "#6366f1", summary: summary({ failed: 3, running: 12 }) },
|
||||
{ name: "Marketing site", color: "#22c55e", summary: summary({ waiting: 2 }) },
|
||||
{ name: "Experimental", color: "#a855f7", summary: summary({ succeeded: 6 }) },
|
||||
{ name: "Auth provider", color: "#f97316", summary: summary({ auth: 1 }) },
|
||||
];
|
||||
return (
|
||||
<div className="paperclip-story w-72 rounded border border-border bg-background p-2">
|
||||
<div className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">Projects (sidebar)</div>
|
||||
<ul className="flex flex-col">
|
||||
{projects.map((project) => (
|
||||
<li
|
||||
key={project.name}
|
||||
className="flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium hover:bg-accent/50"
|
||||
>
|
||||
<span className="h-3.5 w-3.5 shrink-0 rounded-sm" style={{ backgroundColor: project.color }} />
|
||||
<span className="flex-1 truncate">{project.name}</span>
|
||||
<ExternalObjectStatusSummary summary={project.summary} compact />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateMatrixStory() {
|
||||
return (
|
||||
<div className="paperclip-story space-y-12 p-6">
|
||||
<StateMatrix />
|
||||
{inlineMarkdownStory()}
|
||||
{relatedWorkStory()}
|
||||
{projectsRollupStory()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function makeIntegrationGroups(): IssueExternalObjectGroup[] {
|
||||
return [
|
||||
makeGroup({
|
||||
object: makeObject({
|
||||
id: "obj-int-failed",
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "failed",
|
||||
liveness: "fresh",
|
||||
displayTitle: "CI broken on main",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
statusLabel: "CI failed",
|
||||
}),
|
||||
mentionCount: 4,
|
||||
sourceLabels: ["Description", "3 comments"],
|
||||
}),
|
||||
makeGroup({
|
||||
object: makeObject({
|
||||
id: "obj-int-auth",
|
||||
providerKey: "hubspot",
|
||||
objectType: "lead",
|
||||
statusCategory: "auth_required",
|
||||
liveness: "auth_required",
|
||||
displayTitle: "Acme deal — needs reconnect",
|
||||
url: "https://app.hubspot.com/leads/99",
|
||||
statusLabel: "Reconnect",
|
||||
}),
|
||||
mentionCount: 1,
|
||||
sourceLabels: ["External links property"],
|
||||
}),
|
||||
makeGroup({
|
||||
object: makeObject({
|
||||
id: "obj-int-running",
|
||||
providerKey: "ci",
|
||||
objectType: "deployment",
|
||||
statusCategory: "running",
|
||||
liveness: "fresh",
|
||||
displayTitle: "deploy prod-0412",
|
||||
url: "https://ci.example.com/runs/88421",
|
||||
statusLabel: "Running",
|
||||
}),
|
||||
mentionCount: 2,
|
||||
sourceLabels: ["2 comments"],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function makeIntegrationSummary(): ExternalObjectSummary {
|
||||
const objects = makeIntegrationGroups()
|
||||
.map((entry) => entry.group.object)
|
||||
.filter((object): object is ExternalObject => Boolean(object))
|
||||
.map((object) => ({
|
||||
id: object.id,
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
displayTitle: object.displayTitle,
|
||||
statusCategory: object.statusCategory,
|
||||
statusTone: object.statusTone,
|
||||
liveness: object.liveness,
|
||||
isTerminal: object.isTerminal,
|
||||
}));
|
||||
const tones = objects.map((o) => o.statusTone);
|
||||
const dominant: ExternalObjectSummary["highestSeverity"] =
|
||||
tones.includes("danger") ? "danger"
|
||||
: tones.includes("warning") ? "warning"
|
||||
: tones.includes("info") ? "info"
|
||||
: tones.includes("success") ? "success"
|
||||
: "muted";
|
||||
const byStatusCategory: Record<string, number> = {};
|
||||
for (const o of objects) {
|
||||
byStatusCategory[o.statusCategory] = (byStatusCategory[o.statusCategory] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
total: objects.length,
|
||||
byStatusCategory,
|
||||
byLiveness: { fresh: objects.length - 1, stale: 0, auth_required: 1, unreachable: 0, unknown: 0 },
|
||||
highestSeverity: dominant,
|
||||
staleCount: 0,
|
||||
authRequiredCount: 1,
|
||||
unreachableCount: 0,
|
||||
objects,
|
||||
};
|
||||
}
|
||||
|
||||
function PropertiesPanelDesktop() {
|
||||
const issue = storybookIssues[0]!;
|
||||
return (
|
||||
<div className="paperclip-story w-[420px] rounded-lg border border-border bg-background/70 p-4">
|
||||
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Issue properties — desktop @ 1440×900
|
||||
</div>
|
||||
<IssueProperties
|
||||
issue={issue}
|
||||
childIssues={[]}
|
||||
externalObjects={makeIntegrationGroups()}
|
||||
onAddSubIssue={() => undefined}
|
||||
onUpdate={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertiesPanelMobile() {
|
||||
const issue = storybookIssues[0]!;
|
||||
return (
|
||||
<div className="paperclip-story mx-auto w-[358px] rounded-lg border border-border bg-background/70 p-3">
|
||||
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Issue properties — mobile sheet @ 390×844
|
||||
</div>
|
||||
<IssueProperties
|
||||
issue={issue}
|
||||
childIssues={[]}
|
||||
externalObjects={makeIntegrationGroups()}
|
||||
onAddSubIssue={() => undefined}
|
||||
onUpdate={() => undefined}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelatedWorkEmptyDesktop() {
|
||||
return (
|
||||
<div className="paperclip-story space-y-3 p-6">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Related work — empty external objects (zero refs, empty copy visible)
|
||||
</div>
|
||||
<IssueRelatedWorkPanel
|
||||
relatedWork={{ outbound: [], inbound: [] }}
|
||||
externalObjects={[]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMobileDrawer() {
|
||||
const summary = makeIntegrationSummary();
|
||||
return (
|
||||
<div className="paperclip-story mx-auto w-[320px] rounded border border-border bg-background p-2">
|
||||
<div className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">Projects (mobile drawer)</div>
|
||||
<ul className="flex flex-col">
|
||||
{[
|
||||
{ name: "Paperclip App", color: "#6366f1", summary },
|
||||
{ name: "Marketing site", color: "#22c55e", summary: { ...summary, highestSeverity: "warning", byStatusCategory: { waiting: 2 }, total: 2, objects: [] } },
|
||||
{ name: "Experimental", color: "#a855f7", summary: { ...summary, highestSeverity: "muted", byStatusCategory: {}, total: 0, objects: [] } },
|
||||
].map((project) => (
|
||||
<li
|
||||
key={project.name}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium hover:bg-accent/50"
|
||||
>
|
||||
<span className="h-3.5 w-3.5 shrink-0 rounded-sm" style={{ backgroundColor: project.color }} />
|
||||
<span className="flex-1 truncate">{project.name}</span>
|
||||
<ExternalObjectStatusSummary summary={project.summary as ExternalObjectSummary} compact />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueListWithBadge() {
|
||||
const summary = makeIntegrationSummary();
|
||||
return (
|
||||
<div className="paperclip-story p-6">
|
||||
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Issue list — desktop @ 1440×900 (badge + control row)
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-background/70">
|
||||
{storybookIssues.slice(0, 2).map((issue, index) => (
|
||||
<IssueRow
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
externalObjectSummary={index === 0 ? summary : null}
|
||||
desktopTrailing={
|
||||
<span className="text-xs text-muted-foreground">{issue.priority}</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterPopoverWithExternalChecked() {
|
||||
const [state, setState] = useState<IssueFilterState>({
|
||||
...defaultIssueFilterState,
|
||||
externalObjectStatuses: ["failed", "auth_required"],
|
||||
});
|
||||
const triggerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
triggerRef.current?.querySelector("button")?.click();
|
||||
}, 150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="paperclip-story flex min-h-[640px] items-start justify-end p-6">
|
||||
<div ref={triggerRef}>
|
||||
<IssueFiltersPopover
|
||||
state={state}
|
||||
onChange={(patch) => setState((current) => ({ ...current, ...patch }))}
|
||||
activeFilterCount={countActiveIssueFilters(state, true)}
|
||||
agents={storybookAgents.map((agent) => ({ id: agent.id, name: agent.name }))}
|
||||
projects={storybookProjects.map((project) => ({ id: project.id, name: project.name }))}
|
||||
labels={storybookIssueLabels.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId="user-board"
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
workspaces={storybookExecutionWorkspaces.map((workspace) => ({ id: workspace.id, name: workspace.name }))}
|
||||
creators={[
|
||||
{ id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationSurfacesStory() {
|
||||
return (
|
||||
<div className="paperclip-story space-y-10 p-6">
|
||||
<PropertiesPanelDesktop />
|
||||
<PropertiesPanelMobile />
|
||||
<RelatedWorkEmptyDesktop />
|
||||
<SidebarMobileDrawer />
|
||||
<IssueListWithBadge />
|
||||
<FilterPopoverWithExternalChecked />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Foundations/External Objects",
|
||||
component: StateMatrixStory,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"External-object surface gallery: the §6 state matrix, the inline markdown decoration, the related-work section, and the project-rollup sidebar marker. Mirrors the Phase 6 acceptance set from the UX spec.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof StateMatrixStory>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const FullSurface: Story = {};
|
||||
|
||||
export const PropertiesRowDesktop: StoryObj = {
|
||||
render: () => <PropertiesPanelDesktop />,
|
||||
};
|
||||
|
||||
export const PropertiesRowMobileSheet: StoryObj = {
|
||||
render: () => <PropertiesPanelMobile />,
|
||||
};
|
||||
|
||||
export const RelatedWorkEmpty: StoryObj = {
|
||||
render: () => <RelatedWorkEmptyDesktop />,
|
||||
};
|
||||
|
||||
export const SidebarMobile: StoryObj = {
|
||||
render: () => <SidebarMobileDrawer />,
|
||||
};
|
||||
|
||||
export const IssueListRow: StoryObj = {
|
||||
render: () => <IssueListWithBadge />,
|
||||
};
|
||||
|
||||
export const FilterPopoverOpen: StoryObj = {
|
||||
render: () => <FilterPopoverWithExternalChecked />,
|
||||
};
|
||||
|
||||
export const IntegrationSurfaces: StoryObj = {
|
||||
render: () => <IntegrationSurfacesStory />,
|
||||
};
|
||||
Loading…
Reference in New Issue