mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
Allow external objects to define presentation metadata
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
85351a2c9a
commit
95acb60120
19 files changed
+228
-30
No files matched your search
@@ -182,12 +182,16 @@ describe("GitHub external object provider", () => {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
externalId: "acme/app#pull/42",
|
||||
displayKey: "GitHub PR",
|
||||
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",
|
||||
}),
|
||||
]);
|
||||
@@ -198,22 +202,22 @@ describe("GitHub external object provider", () => {
|
||||
[
|
||||
"open",
|
||||
{ state: "open", draft: false, merged: false, title: "Ship it", updated_at: "2026-04-24T01:02:03Z" },
|
||||
{ statusKey: "open", statusLabel: "Open", statusCategory: "open", statusTone: "info", isTerminal: false },
|
||||
{ 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", statusCategory: "waiting", statusTone: "warning", isTerminal: false },
|
||||
{ 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", statusCategory: "closed", statusTone: "muted", isTerminal: true },
|
||||
{ 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", statusCategory: "succeeded", statusTone: "success", isTerminal: true },
|
||||
{ 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));
|
||||
@@ -235,6 +239,8 @@ describe("GitHub external object provider", () => {
|
||||
ok: true,
|
||||
snapshot: expect.objectContaining({
|
||||
...expected,
|
||||
displayKey: "GitHub PR",
|
||||
iconKey: "github",
|
||||
displayTitle: expect.stringContaining(String(body.title)),
|
||||
remoteVersion: "2026-04-24T01:02:03Z",
|
||||
etag: '"etag-1"',
|
||||
@@ -253,12 +259,12 @@ describe("GitHub external object provider", () => {
|
||||
[
|
||||
"open",
|
||||
{ state: "open", title: "Issue", state_reason: null, updated_at: "2026-04-24T01:02:03Z" },
|
||||
{ statusKey: "open", statusLabel: "Open", statusCategory: "open", statusTone: "info", isTerminal: false },
|
||||
{ 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", statusCategory: "closed", statusTone: "muted", isTerminal: true },
|
||||
{ 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));
|
||||
@@ -275,6 +281,8 @@ describe("GitHub external object provider", () => {
|
||||
ok: true,
|
||||
snapshot: expect.objectContaining({
|
||||
...expected,
|
||||
displayKey: "GitHub Issue",
|
||||
iconKey: "github",
|
||||
data: expect.objectContaining({
|
||||
provider: "github",
|
||||
owner: "acme",
|
||||
@@ -322,7 +330,7 @@ describe("GitHub external object provider", () => {
|
||||
[
|
||||
"not-found",
|
||||
new Response("", { status: 404, headers: { etag: '"missing"' } }),
|
||||
{ ok: true, snapshot: expect.objectContaining({ statusKey: "not_found", statusCategory: "archived", statusTone: "muted" }) },
|
||||
{ ok: true, snapshot: expect.objectContaining({ displayKey: "GitHub PR", 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, {
|
||||
@@ -548,6 +556,8 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
displayKey: "Mock Ticket",
|
||||
iconKey: "circle-dot",
|
||||
displayTitle: "Mock ticket 123",
|
||||
confidence: "exact",
|
||||
})),
|
||||
@@ -557,9 +567,12 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
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,
|
||||
@@ -579,6 +592,8 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
displayKey: "Mock Ticket",
|
||||
iconKey: "circle-dot",
|
||||
pluginId: plugin!.id,
|
||||
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
|
||||
});
|
||||
@@ -586,6 +601,10 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
|
||||
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",
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface ExternalObjectDetection {
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
externalId: string;
|
||||
displayKey?: string | null;
|
||||
iconKey?: string | null;
|
||||
displayTitle?: string | null;
|
||||
confidence?: ExternalObjectMentionConfidence;
|
||||
pluginId?: string | null;
|
||||
@@ -50,9 +52,12 @@ export interface ExternalObjectDetector {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -126,6 +131,7 @@ 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
|
||||
@@ -217,9 +223,12 @@ function objectSnapshot(object: ExternalObjectRecord): PluginExternalObjectRecor
|
||||
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,
|
||||
@@ -285,6 +294,8 @@ function createPluginProviderDetector(
|
||||
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,
|
||||
@@ -399,6 +410,8 @@ export function externalObjectService(
|
||||
externalId: detection.externalId,
|
||||
sanitizedCanonicalUrl: canonical.sanitizedCanonicalUrl,
|
||||
canonicalIdentityHash: canonical.canonicalIdentityHash,
|
||||
displayKey: detection.displayKey ?? null,
|
||||
iconKey: detection.iconKey ?? null,
|
||||
displayTitle: detection.displayTitle ?? canonical.sanitizedDisplayUrl,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -415,6 +428,8 @@ export function externalObjectService(
|
||||
set: {
|
||||
sanitizedCanonicalUrl: values.sanitizedCanonicalUrl,
|
||||
canonicalIdentityHash: values.canonicalIdentityHash,
|
||||
displayKey: values.displayKey,
|
||||
iconKey: values.iconKey,
|
||||
displayTitle: values.displayTitle,
|
||||
updatedAt: now,
|
||||
},
|
||||
@@ -663,7 +678,10 @@ export function externalObjectService(
|
||||
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,
|
||||
@@ -796,9 +814,12 @@ export function externalObjectService(
|
||||
|
||||
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,
|
||||
|
||||
@@ -105,6 +105,10 @@ function displayTitleFor(identity: GitHubObjectIdentity) {
|
||||
return `${identity.owner}/${identity.repo}#${identity.number}`;
|
||||
}
|
||||
|
||||
function displayKeyFor(identity: Pick<GitHubObjectIdentity, "objectType">) {
|
||||
return identity.objectType === "pull_request" ? "GitHub PR" : "GitHub Issue";
|
||||
}
|
||||
|
||||
function retryAfterSeconds(response: Response) {
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
if (retryAfter && /^[0-9]+$/.test(retryAfter)) return Number(retryAfter);
|
||||
@@ -163,9 +167,12 @@ function failureFromGitHubResponse(response: Response): ExternalObjectResolveRes
|
||||
|
||||
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,
|
||||
@@ -217,9 +224,18 @@ function pullRequestSnapshot(identity: GitHubObjectIdentity, body: Record<string
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -257,9 +273,12 @@ function issueSnapshot(identity: GitHubObjectIdentity, body: Record<string, unkn
|
||||
: "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",
|
||||
@@ -320,6 +339,8 @@ export function createGitHubExternalObjectProvider(
|
||||
providerKey: "github",
|
||||
objectType: identity.objectType,
|
||||
externalId: externalIdFor(identity),
|
||||
displayKey: displayKeyFor(identity),
|
||||
iconKey: "github",
|
||||
displayTitle: displayTitleFor(identity),
|
||||
confidence: "exact",
|
||||
}];
|
||||
|
||||
Reference in new issue
Block a user