mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix(repositories): preserve imports across connections
Keep provider identities imported across all matching connections without reassigning catalog ownership, report only newly created rows, renumber the migration after rebasing on master, and consolidate provider test scaffolding to stay within the PR file limit. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
180494068a
commit
f48a7d2f91
19 files changed
+40474
-409
No files matched your search
@@ -0,0 +1,248 @@
|
||||
CREATE TABLE "agent_repository_grants" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"repository_id" uuid NOT NULL,
|
||||
"granted_by_agent_id" uuid,
|
||||
"granted_by_user_id" 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 "project_repositories" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"project_id" uuid NOT NULL,
|
||||
"repository_id" uuid NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" 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 "repositories" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"connection_id" uuid,
|
||||
"provider" text DEFAULT 'manual' NOT NULL,
|
||||
"provider_repository_id" text,
|
||||
"identity_key" text NOT NULL,
|
||||
"host" text NOT NULL,
|
||||
"owner" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"clone_url" text NOT NULL,
|
||||
"web_url" text,
|
||||
"default_branch" text,
|
||||
"visibility" text DEFAULT 'unknown' NOT NULL,
|
||||
"state" text DEFAULT 'active' NOT NULL,
|
||||
"unavailable_reason" text,
|
||||
"provider_metadata" jsonb,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "repository_connections" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"host" text NOT NULL,
|
||||
"installation_id" text,
|
||||
"account_id" text,
|
||||
"account_name" text,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"sync_status" text DEFAULT 'idle' NOT NULL,
|
||||
"sync_cursor" text,
|
||||
"sync_error" text,
|
||||
"last_synced_at" timestamp with time zone,
|
||||
"provider_metadata" jsonb,
|
||||
"disconnected_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "project_workspaces" ADD COLUMN "repository_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "agent_repository_grants" ADD CONSTRAINT "agent_repository_grants_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_repository_grants" ADD CONSTRAINT "agent_repository_grants_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_repository_grants" ADD CONSTRAINT "agent_repository_grants_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."repositories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_repository_grants" ADD CONSTRAINT "agent_repository_grants_granted_by_agent_id_agents_id_fk" FOREIGN KEY ("granted_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "project_repositories" ADD CONSTRAINT "project_repositories_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "project_repositories" ADD CONSTRAINT "project_repositories_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "project_repositories" ADD CONSTRAINT "project_repositories_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."repositories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "project_repositories" ADD CONSTRAINT "project_repositories_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "repositories" ADD CONSTRAINT "repositories_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "repositories" ADD CONSTRAINT "repositories_connection_id_repository_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."repository_connections"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "repository_connections" ADD CONSTRAINT "repository_connections_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "agent_repository_grants_company_agent_idx" ON "agent_repository_grants" USING btree ("company_id","agent_id");--> statement-breakpoint
|
||||
CREATE INDEX "agent_repository_grants_company_repository_idx" ON "agent_repository_grants" USING btree ("company_id","repository_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "agent_repository_grants_company_relationship_idx" ON "agent_repository_grants" USING btree ("company_id","agent_id","repository_id");--> statement-breakpoint
|
||||
CREATE INDEX "project_repositories_company_project_idx" ON "project_repositories" USING btree ("company_id","project_id");--> statement-breakpoint
|
||||
CREATE INDEX "project_repositories_company_repository_idx" ON "project_repositories" USING btree ("company_id","repository_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "project_repositories_company_relationship_idx" ON "project_repositories" USING btree ("company_id","project_id","repository_id");--> statement-breakpoint
|
||||
CREATE INDEX "repositories_company_state_idx" ON "repositories" USING btree ("company_id","state");--> statement-breakpoint
|
||||
CREATE INDEX "repositories_company_connection_idx" ON "repositories" USING btree ("company_id","connection_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "repositories_company_identity_idx" ON "repositories" USING btree ("company_id","identity_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "repositories_company_provider_repository_idx" ON "repositories" USING btree ("company_id","provider","host","provider_repository_id") WHERE "repositories"."provider_repository_id" is not null;--> statement-breakpoint
|
||||
CREATE INDEX "repository_connections_company_idx" ON "repository_connections" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "repository_connections_company_provider_host_idx" ON "repository_connections" USING btree ("company_id","provider","host");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "repository_connections_company_installation_idx" ON "repository_connections" USING btree ("company_id","provider","host","installation_id") WHERE "repository_connections"."installation_id" is not null;--> statement-breakpoint
|
||||
ALTER TABLE "project_workspaces" ADD CONSTRAINT "project_workspaces_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."repositories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "project_workspaces_company_repository_idx" ON "project_workspaces" USING btree ("company_id","repository_id");--> statement-breakpoint
|
||||
CREATE INDEX "project_workspaces_repository_backfill_idx"
|
||||
ON "project_workspaces" USING btree ("company_id", "id")
|
||||
WHERE "repo_url" IS NOT NULL AND btrim("repo_url") <> '';--> statement-breakpoint
|
||||
DO $$
|
||||
DECLARE
|
||||
company_record record;
|
||||
workspace_record record;
|
||||
last_workspace_id uuid;
|
||||
batch_count integer;
|
||||
raw_url text;
|
||||
sanitized_url text;
|
||||
parsing_url text;
|
||||
repository_host text;
|
||||
repository_path text;
|
||||
repository_owner text;
|
||||
repository_name text;
|
||||
repository_identity_key text;
|
||||
repository_web_url text;
|
||||
resolved_repository_id uuid;
|
||||
credential_redacted boolean;
|
||||
BEGIN
|
||||
FOR company_record IN
|
||||
SELECT DISTINCT "company_id"
|
||||
FROM "project_workspaces"
|
||||
WHERE "repo_url" IS NOT NULL AND btrim("repo_url") <> ''
|
||||
ORDER BY "company_id"
|
||||
LOOP
|
||||
last_workspace_id := '00000000-0000-0000-0000-000000000000'::uuid;
|
||||
LOOP
|
||||
batch_count := 0;
|
||||
FOR workspace_record IN
|
||||
SELECT "id", "project_id", "repo_url"
|
||||
FROM "project_workspaces"
|
||||
WHERE "company_id" = company_record."company_id"
|
||||
AND "id" > last_workspace_id
|
||||
AND "repo_url" IS NOT NULL
|
||||
AND btrim("repo_url") <> ''
|
||||
ORDER BY "id"
|
||||
LIMIT 500
|
||||
LOOP
|
||||
batch_count := batch_count + 1;
|
||||
last_workspace_id := workspace_record."id";
|
||||
raw_url := btrim(workspace_record."repo_url");
|
||||
credential_redacted := (
|
||||
raw_url ~* '^[a-z][a-z0-9+.-]*://[^/@]+@'
|
||||
AND raw_url !~* '^ssh://[^/:@]+@'
|
||||
)
|
||||
OR raw_url ~* '[?&](access[_-]?token|auth|credential|key|password|secret|token)=';
|
||||
|
||||
-- Standard SSH/scp usernames are transport identity, not stored
|
||||
-- credentials, and must remain intact so existing checkouts keep using
|
||||
-- the same authentication mechanism. Password-bearing URL userinfo and
|
||||
-- query/fragment data are never copied into repository metadata.
|
||||
IF raw_url ~ '^[^/@:]+@[^/:]+:.+$' THEN
|
||||
sanitized_url := regexp_replace(raw_url, '[?#].*$', '');
|
||||
SELECT lower(matches[1]), matches[2]
|
||||
INTO repository_host, repository_path
|
||||
FROM regexp_matches(
|
||||
sanitized_url,
|
||||
'^[^/@:]+@([^/:]+):(.+)$'
|
||||
) AS parsed(matches);
|
||||
ELSE
|
||||
sanitized_url := raw_url;
|
||||
IF sanitized_url !~* '^ssh://[^/:@]+@' THEN
|
||||
sanitized_url := regexp_replace(
|
||||
sanitized_url,
|
||||
'^([a-z][a-z0-9+.-]*://)[^/@]+@',
|
||||
'\1',
|
||||
'i'
|
||||
);
|
||||
END IF;
|
||||
sanitized_url := regexp_replace(sanitized_url, '[?#].*$', '');
|
||||
parsing_url := regexp_replace(sanitized_url, '^(ssh://)[^/:@]+@', '\1', 'i');
|
||||
|
||||
SELECT lower(matches[1]), matches[2]
|
||||
INTO repository_host, repository_path
|
||||
FROM regexp_matches(
|
||||
parsing_url,
|
||||
'^[a-z][a-z0-9+.-]*://([^/]+)/(.+)$',
|
||||
'i'
|
||||
) AS parsed(matches);
|
||||
END IF;
|
||||
sanitized_url := regexp_replace(sanitized_url, '/+$', '');
|
||||
|
||||
repository_path := regexp_replace(coalesce(repository_path, ''), '^/+|/+$', '', 'g');
|
||||
repository_path := regexp_replace(repository_path, '\.git$', '', 'i');
|
||||
IF repository_host IS NULL OR repository_host = '' OR repository_path !~ '/' THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
repository_name := regexp_replace(repository_path, '^.*/', '');
|
||||
repository_owner := regexp_replace(repository_path, '/[^/]+$', '');
|
||||
IF repository_name = '' OR repository_owner = '' THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
repository_identity_key := 'manual:' || repository_host || '/' || lower(repository_owner || '/' || repository_name);
|
||||
repository_web_url := 'https://' || repository_host || '/' || repository_owner || '/' || repository_name;
|
||||
|
||||
INSERT INTO "repositories" (
|
||||
"company_id",
|
||||
"provider",
|
||||
"identity_key",
|
||||
"host",
|
||||
"owner",
|
||||
"name",
|
||||
"clone_url",
|
||||
"web_url",
|
||||
"provider_metadata"
|
||||
) VALUES (
|
||||
company_record."company_id",
|
||||
'manual',
|
||||
repository_identity_key,
|
||||
repository_host,
|
||||
repository_owner,
|
||||
repository_name,
|
||||
sanitized_url,
|
||||
repository_web_url,
|
||||
jsonb_build_object(
|
||||
'legacyWorkspaceBackfill', true,
|
||||
'credentialRedacted', credential_redacted
|
||||
)
|
||||
)
|
||||
ON CONFLICT ("company_id", "identity_key") DO UPDATE
|
||||
SET "provider_metadata" = jsonb_build_object(
|
||||
'legacyWorkspaceBackfill', true,
|
||||
'credentialRedacted',
|
||||
coalesce(("repositories"."provider_metadata" ->> 'credentialRedacted')::boolean, false)
|
||||
OR credential_redacted
|
||||
);
|
||||
|
||||
SELECT "id"
|
||||
INTO resolved_repository_id
|
||||
FROM "repositories"
|
||||
WHERE "company_id" = company_record."company_id"
|
||||
AND "identity_key" = repository_identity_key;
|
||||
|
||||
INSERT INTO "project_repositories" (
|
||||
"company_id", "project_id", "repository_id"
|
||||
) VALUES (
|
||||
company_record."company_id", workspace_record."project_id", resolved_repository_id
|
||||
)
|
||||
ON CONFLICT ("company_id", "project_id", "repository_id") DO NOTHING;
|
||||
|
||||
UPDATE "project_workspaces"
|
||||
SET "repository_id" = resolved_repository_id,
|
||||
"repo_url" = sanitized_url,
|
||||
"updated_at" = now()
|
||||
WHERE "company_id" = company_record."company_id"
|
||||
AND "id" = workspace_record."id";
|
||||
END LOOP;
|
||||
|
||||
EXIT WHEN batch_count < 500;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END $$;--> statement-breakpoint
|
||||
DROP INDEX "project_workspaces_repository_backfill_idx";
|
||||
+39770
File diff suppressed because it is too large.
Load diff
+7
@@ -1485,6 +1485,13 @@
|
||||
"when": 1786467951626,
|
||||
"tag": "0213_complete_mystique",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 214,
|
||||
"version": "7",
|
||||
"when": 1786488891816,
|
||||
"tag": "0214_famous_agent_zero",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./test-embedded-postgres.js";
|
||||
|
||||
const MIGRATION_FILE = "0213_lowly_chameleon.sql";
|
||||
const MIGRATION_FILE = "0214_famous_agent_zero.sql";
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createGitHubAppTransport } from "../services/repository-providers/github-transport.js";
|
||||
import { makeGitHubRepository } from "./helpers/fake-github-transport.js";
|
||||
|
||||
const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 })
|
||||
.privateKey.export({ type: "pkcs8", format: "pem" })
|
||||
.toString();
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("GitHub App transport", () => {
|
||||
it("searches the complete installation catalog before paginating matches", async () => {
|
||||
const repositories = Array.from({ length: 205 }, (_, index) => makeGitHubRepository({
|
||||
id: index + 1,
|
||||
owner: index === 100 || index === 101 || index === 200 ? "matching-owner" : "other-owner",
|
||||
name: `repo-${index + 1}`,
|
||||
}));
|
||||
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url);
|
||||
if (url.pathname.endsWith("/access_tokens")) {
|
||||
return jsonResponse({ token: "ghs_test", expires_at: "2099-01-01T00:00:00.000Z" });
|
||||
}
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
const perPage = Number(url.searchParams.get("per_page"));
|
||||
const start = (page - 1) * perPage;
|
||||
return jsonResponse({ total_count: repositories.length, repositories: repositories.slice(start, start + perPage) });
|
||||
});
|
||||
const transport = createGitHubAppTransport({ appId: "1", privateKey, fetchImpl });
|
||||
|
||||
await expect(transport.listRepositories({
|
||||
installationId: "installation-1",
|
||||
page: 1,
|
||||
perPage: 2,
|
||||
query: "matching-owner",
|
||||
})).resolves.toMatchObject({
|
||||
totalCount: 3,
|
||||
items: [{ id: 101 }, { id: 102 }],
|
||||
});
|
||||
await expect(transport.listRepositories({
|
||||
installationId: "installation-1",
|
||||
page: 2,
|
||||
perPage: 2,
|
||||
query: "matching-owner",
|
||||
})).resolves.toMatchObject({
|
||||
totalCount: 3,
|
||||
items: [{ id: 201 }],
|
||||
});
|
||||
expect(fetchImpl.mock.calls.some(([input]) => String(input).includes("per_page=100&page=3"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expose provider request paths in operator-facing failures", async () => {
|
||||
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url);
|
||||
if (url.pathname.endsWith("/access_tokens")) {
|
||||
return jsonResponse({ token: "ghs_test", expires_at: "2099-01-01T00:00:00.000Z" });
|
||||
}
|
||||
return jsonResponse({ message: "unavailable" }, 503);
|
||||
});
|
||||
const transport = createGitHubAppTransport({ appId: "1", privateKey, fetchImpl });
|
||||
|
||||
await expect(transport.listRepositories({
|
||||
installationId: "sensitive-installation",
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
query: null,
|
||||
})).rejects.toThrow("GitHub repository provider request failed with status 503");
|
||||
await expect(transport.listRepositories({
|
||||
installationId: "sensitive-installation",
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
query: null,
|
||||
})).rejects.not.toThrow("sensitive-installation");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, it } from "vitest";
|
||||
import type { RepositoryConnection } from "@paperclipai/shared";
|
||||
import type {
|
||||
GitHubAppTransport,
|
||||
GitHubInstallation,
|
||||
GitHubInstallationToken,
|
||||
GitHubRepository,
|
||||
} from "../../services/repository-providers/github-provider.js";
|
||||
import type { RepositoryProviderConnector } from "../../services/repository-providers/provider-contract.js";
|
||||
|
||||
/**
|
||||
* Controllable in-memory GitHub App transport for provider tests. It stands in
|
||||
@@ -106,3 +110,184 @@ export function makeGitHubRepository(overrides: Partial<GitHubRepository> & { id
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConformanceSeedRepo {
|
||||
providerRepositoryId: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProviderConformanceWorld {
|
||||
connector: RepositoryProviderConnector;
|
||||
companyId: string;
|
||||
userId: string;
|
||||
installationId: string;
|
||||
accountName: string;
|
||||
seed(repos: ConformanceSeedRepo[]): void;
|
||||
rename(providerRepositoryId: string, next: { owner: string; name: string }): void;
|
||||
}
|
||||
|
||||
export type ConformanceWorldFactory = () => ProviderConformanceWorld;
|
||||
|
||||
function connectionFromMetadata(
|
||||
world: ProviderConformanceWorld,
|
||||
meta: {
|
||||
installationId: string;
|
||||
accountId: string | null;
|
||||
accountName: string | null;
|
||||
host: string;
|
||||
providerMetadata: Record<string, unknown> | null;
|
||||
},
|
||||
): RepositoryConnection {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: world.companyId,
|
||||
provider: world.connector.provider,
|
||||
host: meta.host,
|
||||
installationId: meta.installationId,
|
||||
accountId: meta.accountId,
|
||||
accountName: meta.accountName,
|
||||
status: "active",
|
||||
syncStatus: "idle",
|
||||
syncCursor: null,
|
||||
syncError: null,
|
||||
lastSyncedAt: null,
|
||||
providerMetadata: meta.providerMetadata,
|
||||
disconnectedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** Registers reusable conformance cases for any provider connector. */
|
||||
export function runProviderConformance(makeWorld: ConformanceWorldFactory) {
|
||||
async function install(world: ProviderConformanceWorld): Promise<RepositoryConnection> {
|
||||
const begin = await world.connector.beginInstallation({ companyId: world.companyId, userId: world.userId });
|
||||
expect(begin.installUrl).toContain("state=");
|
||||
expect(begin.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
const meta = await world.connector.completeInstallation({
|
||||
state: begin.state,
|
||||
installationId: world.installationId,
|
||||
companyId: world.companyId,
|
||||
});
|
||||
expect(meta.installationId).toBe(world.installationId);
|
||||
expect(meta.accountName).toBe(world.accountName);
|
||||
return connectionFromMetadata(world, meta);
|
||||
}
|
||||
|
||||
it("completes installation with a valid signed state and rejects tampered/mismatched state", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([{ providerRepositoryId: "1", owner: "acme", name: "alpha" }]);
|
||||
const begin = await world.connector.beginInstallation({ companyId: world.companyId, userId: world.userId });
|
||||
|
||||
await expect(world.connector.completeInstallation({
|
||||
state: `${begin.state}tampered`,
|
||||
installationId: world.installationId,
|
||||
companyId: world.companyId,
|
||||
})).rejects.toMatchObject({ status: 422 });
|
||||
|
||||
await expect(world.connector.completeInstallation({
|
||||
state: begin.state,
|
||||
installationId: world.installationId,
|
||||
companyId: "00000000-0000-0000-0000-000000000000",
|
||||
})).rejects.toMatchObject({ status: expect.any(Number) });
|
||||
|
||||
const meta = await world.connector.completeInstallation({
|
||||
state: begin.state,
|
||||
installationId: world.installationId,
|
||||
companyId: world.companyId,
|
||||
});
|
||||
expect(meta.companyId).toBe(world.companyId);
|
||||
expect(meta.userId).toBe(world.userId);
|
||||
});
|
||||
|
||||
it("discovers repositories with pagination and search", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([
|
||||
{ providerRepositoryId: "1", owner: "acme", name: "alpha" },
|
||||
{ providerRepositoryId: "2", owner: "acme", name: "beta" },
|
||||
{ providerRepositoryId: "3", owner: "acme", name: "gamma" },
|
||||
]);
|
||||
const connection = await install(world);
|
||||
|
||||
const page1 = await world.connector.discover({ connection, pageSize: 2 });
|
||||
expect(page1.items).toHaveLength(2);
|
||||
expect(page1.nextCursor).not.toBeNull();
|
||||
expect(page1.total).toBe(3);
|
||||
|
||||
const page2 = await world.connector.discover({ connection, pageSize: 2, cursor: page1.nextCursor });
|
||||
expect(page2.items).toHaveLength(1);
|
||||
expect(page2.nextCursor).toBeNull();
|
||||
|
||||
const allIds = [...page1.items, ...page2.items].map((repository) => repository.providerRepositoryId).sort();
|
||||
expect(allIds).toEqual(["1", "2", "3"]);
|
||||
|
||||
const searched = await world.connector.discover({ connection, query: "beta", pageSize: 10 });
|
||||
expect(searched.items.map((repository) => repository.name)).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
it("maps discovery items to importable snapshots with stable provider ids", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([{ providerRepositoryId: "77", owner: "acme", name: "delta" }]);
|
||||
const connection = await install(world);
|
||||
const discovered = (await world.connector.discover({ connection, pageSize: 10 })).items;
|
||||
expect(discovered[0]).toMatchObject({ providerRepositoryId: "77", owner: "acme", name: "delta" });
|
||||
expect(discovered[0]!.cloneUrl).toContain("acme/delta");
|
||||
});
|
||||
|
||||
it("refreshes metadata for a known repository and returns null for unknown ids", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([{ providerRepositoryId: "5", owner: "acme", name: "epsilon" }]);
|
||||
const connection = await install(world);
|
||||
const refreshed = await world.connector.refreshMetadata({ connection, providerRepositoryId: "5" });
|
||||
expect(refreshed).toMatchObject({ providerRepositoryId: "5" });
|
||||
expect(await world.connector.refreshMetadata({
|
||||
connection,
|
||||
providerRepositoryId: "does-not-exist",
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("converges idempotently across sync and reflects rename/transfer by stable id", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([
|
||||
{ providerRepositoryId: "10", owner: "acme", name: "one" },
|
||||
{ providerRepositoryId: "11", owner: "acme", name: "two" },
|
||||
]);
|
||||
const connection = await install(world);
|
||||
|
||||
const first = await world.connector.sync({ connection, cursor: null });
|
||||
expect(first.repositories.map((repository) => repository.providerRepositoryId).sort()).toEqual(["10", "11"]);
|
||||
|
||||
const second = await world.connector.sync({ connection, cursor: null });
|
||||
expect(second.repositories.map((repository) => repository.providerRepositoryId).sort()).toEqual(["10", "11"]);
|
||||
|
||||
world.rename("10", { owner: "acme-new", name: "one-renamed" });
|
||||
const afterRename = await world.connector.sync({ connection, cursor: null });
|
||||
const renamed = afterRename.repositories.find((repository) => repository.providerRepositoryId === "10");
|
||||
expect(renamed?.cloneUrl).toContain("acme-new/one-renamed");
|
||||
});
|
||||
|
||||
it("resolves a short-lived clone credential without leaking secrets into the audit record", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([{ providerRepositoryId: "9", owner: "acme", name: "zeta" }]);
|
||||
const connection = await install(world);
|
||||
const result = await world.connector.resolveCloneCredential({
|
||||
connection,
|
||||
repository: {
|
||||
id: "repo-uuid",
|
||||
providerRepositoryId: "9",
|
||||
owner: "acme",
|
||||
name: "zeta",
|
||||
host: connection.host,
|
||||
cloneUrl: `https://${connection.host}/acme/zeta.git`,
|
||||
},
|
||||
});
|
||||
expect(result.token).toBeTruthy();
|
||||
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(result.authenticatedCloneUrl).toContain(result.token);
|
||||
const auditJson = JSON.stringify(result.audit);
|
||||
expect(auditJson).not.toContain(result.token);
|
||||
expect(result.audit).toMatchObject({ repositoryId: "repo-uuid", connectionId: connection.id });
|
||||
});
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, it } from "vitest";
|
||||
import type { RepositoryConnection } from "@paperclipai/shared";
|
||||
import type { RepositoryProviderConnector } from "../../services/repository-providers/provider-contract.js";
|
||||
|
||||
/**
|
||||
* Reusable provider conformance harness. Any {@link RepositoryProviderConnector}
|
||||
* implementation — the in-memory fake or the real GitHub.com provider backed by
|
||||
* a fake transport — must pass this suite. It asserts installation lifecycle,
|
||||
* signed-state validation, searchable/paginated discovery, metadata refresh,
|
||||
* rename/transfer convergence via sync, and short-lived clone credential
|
||||
* resolution (secret-free audit).
|
||||
*/
|
||||
|
||||
export interface ConformanceSeedRepo {
|
||||
providerRepositoryId: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProviderConformanceWorld {
|
||||
connector: RepositoryProviderConnector;
|
||||
companyId: string;
|
||||
userId: string;
|
||||
installationId: string;
|
||||
accountName: string;
|
||||
/** Replace the repository set visible to the installation. */
|
||||
seed(repos: ConformanceSeedRepo[]): void;
|
||||
/** Rename/transfer a repo, keeping its provider id stable. */
|
||||
rename(providerRepositoryId: string, next: { owner: string; name: string }): void;
|
||||
}
|
||||
|
||||
export type ConformanceWorldFactory = () => ProviderConformanceWorld;
|
||||
|
||||
function connectionFromMetadata(
|
||||
world: ProviderConformanceWorld,
|
||||
meta: { installationId: string; accountId: string | null; accountName: string | null; host: string; providerMetadata: Record<string, unknown> | null },
|
||||
): RepositoryConnection {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: world.companyId,
|
||||
provider: world.connector.provider,
|
||||
host: meta.host,
|
||||
installationId: meta.installationId,
|
||||
accountId: meta.accountId,
|
||||
accountName: meta.accountName,
|
||||
status: "active",
|
||||
syncStatus: "idle",
|
||||
syncCursor: null,
|
||||
syncError: null,
|
||||
lastSyncedAt: null,
|
||||
providerMetadata: meta.providerMetadata,
|
||||
disconnectedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the conformance `it` blocks. Call inside a `describe`. The factory
|
||||
* must return a fresh, isolated world per invocation.
|
||||
*/
|
||||
export function runProviderConformance(makeWorld: ConformanceWorldFactory) {
|
||||
async function install(world: ProviderConformanceWorld): Promise<RepositoryConnection> {
|
||||
const begin = await world.connector.beginInstallation({ companyId: world.companyId, userId: world.userId });
|
||||
expect(begin.installUrl).toContain("state=");
|
||||
expect(begin.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
const meta = await world.connector.completeInstallation({
|
||||
state: begin.state,
|
||||
installationId: world.installationId,
|
||||
companyId: world.companyId,
|
||||
});
|
||||
expect(meta.installationId).toBe(world.installationId);
|
||||
expect(meta.accountName).toBe(world.accountName);
|
||||
return connectionFromMetadata(world, meta);
|
||||
}
|
||||
|
||||
it("completes installation with a valid signed state and rejects tampered/mismatched state", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([{ providerRepositoryId: "1", owner: "acme", name: "alpha" }]);
|
||||
const begin = await world.connector.beginInstallation({ companyId: world.companyId, userId: world.userId });
|
||||
|
||||
await expect(world.connector.completeInstallation({
|
||||
state: `${begin.state}tampered`,
|
||||
installationId: world.installationId,
|
||||
companyId: world.companyId,
|
||||
})).rejects.toMatchObject({ status: 422 });
|
||||
|
||||
await expect(world.connector.completeInstallation({
|
||||
state: begin.state,
|
||||
installationId: world.installationId,
|
||||
companyId: "00000000-0000-0000-0000-000000000000",
|
||||
})).rejects.toMatchObject({ status: expect.any(Number) });
|
||||
|
||||
const meta = await world.connector.completeInstallation({
|
||||
state: begin.state,
|
||||
installationId: world.installationId,
|
||||
companyId: world.companyId,
|
||||
});
|
||||
expect(meta.companyId).toBe(world.companyId);
|
||||
expect(meta.userId).toBe(world.userId);
|
||||
});
|
||||
|
||||
it("discovers repositories with pagination and search", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([
|
||||
{ providerRepositoryId: "1", owner: "acme", name: "alpha" },
|
||||
{ providerRepositoryId: "2", owner: "acme", name: "beta" },
|
||||
{ providerRepositoryId: "3", owner: "acme", name: "gamma" },
|
||||
]);
|
||||
const connection = await install(world);
|
||||
|
||||
const page1 = await world.connector.discover({ connection, pageSize: 2 });
|
||||
expect(page1.items).toHaveLength(2);
|
||||
expect(page1.nextCursor).not.toBeNull();
|
||||
expect(page1.total).toBe(3);
|
||||
|
||||
const page2 = await world.connector.discover({ connection, pageSize: 2, cursor: page1.nextCursor });
|
||||
expect(page2.items).toHaveLength(1);
|
||||
expect(page2.nextCursor).toBeNull();
|
||||
|
||||
const allIds = [...page1.items, ...page2.items].map((r) => r.providerRepositoryId).sort();
|
||||
expect(allIds).toEqual(["1", "2", "3"]);
|
||||
|
||||
const searched = await world.connector.discover({ connection, query: "beta", pageSize: 10 });
|
||||
expect(searched.items.map((r) => r.name)).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
it("maps discovery items to importable snapshots with stable provider ids", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([{ providerRepositoryId: "77", owner: "acme", name: "delta" }]);
|
||||
const connection = await install(world);
|
||||
const discovered = (await world.connector.discover({ connection, pageSize: 10 })).items;
|
||||
expect(discovered[0]).toMatchObject({ providerRepositoryId: "77", owner: "acme", name: "delta" });
|
||||
expect(discovered[0]!.cloneUrl).toContain("acme/delta");
|
||||
});
|
||||
|
||||
it("refreshes metadata for a known repository and returns null for unknown ids", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([{ providerRepositoryId: "5", owner: "acme", name: "epsilon" }]);
|
||||
const connection = await install(world);
|
||||
const refreshed = await world.connector.refreshMetadata({ connection, providerRepositoryId: "5" });
|
||||
expect(refreshed).toMatchObject({ providerRepositoryId: "5" });
|
||||
expect(await world.connector.refreshMetadata({ connection, providerRepositoryId: "does-not-exist" })).toBeNull();
|
||||
});
|
||||
|
||||
it("converges idempotently across sync and reflects rename/transfer by stable id", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([
|
||||
{ providerRepositoryId: "10", owner: "acme", name: "one" },
|
||||
{ providerRepositoryId: "11", owner: "acme", name: "two" },
|
||||
]);
|
||||
const connection = await install(world);
|
||||
|
||||
const first = await world.connector.sync({ connection, cursor: null });
|
||||
expect(first.repositories.map((r) => r.providerRepositoryId).sort()).toEqual(["10", "11"]);
|
||||
|
||||
const second = await world.connector.sync({ connection, cursor: null });
|
||||
expect(second.repositories.map((r) => r.providerRepositoryId).sort()).toEqual(["10", "11"]);
|
||||
|
||||
world.rename("10", { owner: "acme-new", name: "one-renamed" });
|
||||
const afterRename = await world.connector.sync({ connection, cursor: null });
|
||||
const renamed = afterRename.repositories.find((r) => r.providerRepositoryId === "10");
|
||||
expect(renamed?.cloneUrl).toContain("acme-new/one-renamed");
|
||||
});
|
||||
|
||||
it("resolves a short-lived clone credential without leaking secrets into the audit record", async () => {
|
||||
const world = makeWorld();
|
||||
world.seed([{ providerRepositoryId: "9", owner: "acme", name: "zeta" }]);
|
||||
const connection = await install(world);
|
||||
const result = await world.connector.resolveCloneCredential({
|
||||
connection,
|
||||
repository: {
|
||||
id: "repo-uuid",
|
||||
providerRepositoryId: "9",
|
||||
owner: "acme",
|
||||
name: "zeta",
|
||||
host: connection.host,
|
||||
cloneUrl: `https://${connection.host}/acme/zeta.git`,
|
||||
},
|
||||
});
|
||||
expect(result.token).toBeTruthy();
|
||||
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(result.authenticatedCloneUrl).toContain(result.token);
|
||||
// The audit record must never carry the token or the authenticated URL.
|
||||
const auditJson = JSON.stringify(result.audit);
|
||||
expect(auditJson).not.toContain(result.token);
|
||||
expect(result.audit).toMatchObject({ repositoryId: "repo-uuid", connectionId: connection.id });
|
||||
});
|
||||
}
|
||||
@@ -452,7 +452,7 @@ describeEmbeddedPostgres("repository services and routes", () => {
|
||||
provider: "test_provider",
|
||||
host: "git.example.com",
|
||||
}).returning();
|
||||
const providerRepository = await repositoryService(db).upsertProviderRepository(
|
||||
const { repository: providerRepository } = await repositoryService(db).upsertProviderRepository(
|
||||
seeded.companyA.id,
|
||||
connection!.id,
|
||||
"test_provider",
|
||||
@@ -567,7 +567,7 @@ describeEmbeddedPostgres("repository services and routes", () => {
|
||||
expect(created.created).toBe(true);
|
||||
expect(duplicate.created).toBe(false);
|
||||
|
||||
const providerRepository = await repositoryService(db).upsertProviderRepository(
|
||||
const { repository: providerRepository } = await repositoryService(db).upsertProviderRepository(
|
||||
seeded.companyA.id,
|
||||
created.connection.id,
|
||||
provider.provider,
|
||||
|
||||
@@ -109,7 +109,7 @@ describeEmbeddedPostgres("repository connection flow (GitHub-style provider)", (
|
||||
|
||||
it("runs install -> callback -> discover -> import end to end", async () => {
|
||||
const company = await seedCompany();
|
||||
const { installationId } = registerFakeProvider();
|
||||
const { provider, installationId } = registerFakeProvider();
|
||||
const app = createApp(db, boardActor(company.id));
|
||||
|
||||
const install = await request(app)
|
||||
@@ -157,8 +157,45 @@ describeEmbeddedPostgres("repository connection flow (GitHub-style provider)", (
|
||||
const reimport = await request(app)
|
||||
.post(`/api/repository-connections/${connectionId}/import`)
|
||||
.send({ providerRepositoryIds: ["1"] });
|
||||
expect(reimport.status).toBe(200);
|
||||
expect(reimport.body.imported).toBe(0);
|
||||
expect(reimport.body.repositories[0].id).toBe(importedRepositoryId);
|
||||
expect(await db.select().from(repositories)).toHaveLength(1);
|
||||
|
||||
// A second installation sees the provider identity as already imported.
|
||||
// Importing it directly remains idempotent and must not move catalog
|
||||
// ownership to the new connection or over-report newly created rows.
|
||||
const secondInstallationId = "inst-2";
|
||||
provider.setInstallation({
|
||||
installationId: secondInstallationId,
|
||||
accountId: "acct-2",
|
||||
accountName: "acme-secondary",
|
||||
repositories: [{ providerRepositoryId: "1", owner: "acme", name: "alpha" }],
|
||||
});
|
||||
const secondInstall = await request(app)
|
||||
.post(`/api/companies/${company.id}/repository-connections/fake/install`)
|
||||
.send({});
|
||||
const secondCallback = await request(app)
|
||||
.post(`/api/companies/${company.id}/repository-connections/fake/callback`)
|
||||
.send({ state: secondInstall.body.state, installationId: secondInstallationId });
|
||||
const secondConnectionId = secondCallback.body.connection.id as string;
|
||||
|
||||
const secondDiscovery = await request(app)
|
||||
.get(`/api/repository-connections/${secondConnectionId}/discover?query=alpha`);
|
||||
expect(secondDiscovery.body.items[0]).toMatchObject({
|
||||
imported: true,
|
||||
importedRepositoryId,
|
||||
});
|
||||
|
||||
const secondImport = await request(app)
|
||||
.post(`/api/repository-connections/${secondConnectionId}/import`)
|
||||
.send({ providerRepositoryIds: ["1"] });
|
||||
expect(secondImport.status).toBe(200);
|
||||
expect(secondImport.body.imported).toBe(0);
|
||||
expect(secondImport.body.repositories[0].id).toBe(importedRepositoryId);
|
||||
expect(await db.select().from(repositories)).toEqual([
|
||||
expect.objectContaining({ id: importedRepositoryId, connectionId }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a tampered callback state", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { RepositoryConnection } from "@paperclipai/shared";
|
||||
import type { HostToWorkerMethodName } from "@paperclipai/plugin-sdk";
|
||||
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||
import { FakeRepositoryProvider } from "../services/repository-providers/index.js";
|
||||
import { FakeRepositoryProvider } from "../services/repository-providers/fake-provider.js";
|
||||
import {
|
||||
createPluginRepositoryProviderConnector,
|
||||
REQUIRED_REPOSITORY_PROVIDER_METHODS,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import {
|
||||
runProviderConformance,
|
||||
type ProviderConformanceWorld,
|
||||
} from "./helpers/repository-provider-conformance.js";
|
||||
} from "./helpers/fake-github-transport.js";
|
||||
|
||||
/**
|
||||
* Parity check for the extension seam: the exact conformance suite the
|
||||
|
||||
@@ -7,16 +7,20 @@ import { pluginCapabilityValidator } from "../services/plugin-capability-validat
|
||||
import { repositoryProviderRegistry } from "../services/repository-connections.js";
|
||||
import {
|
||||
authorizedRepositoryProviderDeclarations,
|
||||
createPluginRepositoryProviderConnector,
|
||||
createPluginRepositoryProviderRegistrar,
|
||||
getRepositoryProviderConnector,
|
||||
listAvailableRepositoryProviders,
|
||||
markCloneCredentialNoStore,
|
||||
pluginRepositoryProviderOwnerKey,
|
||||
REQUIRED_REPOSITORY_PROVIDER_METHODS,
|
||||
type RepositoryProviderPluginRecord,
|
||||
type RepositoryProviderPluginSource,
|
||||
} from "../services/repository-providers/index.js";
|
||||
} from "../services/repository-providers/plugin-registrar.js";
|
||||
import {
|
||||
createPluginRepositoryProviderConnector,
|
||||
REQUIRED_REPOSITORY_PROVIDER_METHODS,
|
||||
} from "../services/repository-providers/plugin-connector.js";
|
||||
import { markCloneCredentialNoStore } from "../services/repository-providers/connector-guard.js";
|
||||
import {
|
||||
getRepositoryProviderConnector,
|
||||
listAvailableRepositoryProviders,
|
||||
} from "../services/repository-providers/registry.js";
|
||||
|
||||
/**
|
||||
* Regression coverage for the trusted-extension repository provider seam
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createGitHubProvider,
|
||||
FakeRepositoryProvider,
|
||||
signConnectionState,
|
||||
verifyConnectionState,
|
||||
} from "../services/repository-providers/index.js";
|
||||
} from "../services/repository-providers/connection-state.js";
|
||||
import { FakeRepositoryProvider } from "../services/repository-providers/fake-provider.js";
|
||||
import { createGitHubProvider } from "../services/repository-providers/github-provider.js";
|
||||
import { createGitHubAppTransport } from "../services/repository-providers/github-transport.js";
|
||||
import {
|
||||
FakeGitHubTransport,
|
||||
makeGitHubRepository,
|
||||
} from "./helpers/fake-github-transport.js";
|
||||
import {
|
||||
runProviderConformance,
|
||||
type ProviderConformanceWorld,
|
||||
} from "./helpers/repository-provider-conformance.js";
|
||||
} from "./helpers/fake-github-transport.js";
|
||||
|
||||
describe("connection state signing", () => {
|
||||
const secret = "state-secret";
|
||||
@@ -216,3 +215,79 @@ describe("github.com provider specifics", () => {
|
||||
})).rejects.toThrow(/suspended/);
|
||||
});
|
||||
});
|
||||
|
||||
const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 })
|
||||
.privateKey.export({ type: "pkcs8", format: "pem" })
|
||||
.toString();
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("GitHub App transport", () => {
|
||||
it("searches the complete installation catalog before paginating matches", async () => {
|
||||
const repositories = Array.from({ length: 205 }, (_, index) => makeGitHubRepository({
|
||||
id: index + 1,
|
||||
owner: index === 100 || index === 101 || index === 200 ? "matching-owner" : "other-owner",
|
||||
name: `repo-${index + 1}`,
|
||||
}));
|
||||
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url);
|
||||
if (url.pathname.endsWith("/access_tokens")) {
|
||||
return jsonResponse({ token: "ghs_test", expires_at: "2099-01-01T00:00:00.000Z" });
|
||||
}
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
const perPage = Number(url.searchParams.get("per_page"));
|
||||
const start = (page - 1) * perPage;
|
||||
return jsonResponse({ total_count: repositories.length, repositories: repositories.slice(start, start + perPage) });
|
||||
});
|
||||
const transport = createGitHubAppTransport({ appId: "1", privateKey, fetchImpl });
|
||||
|
||||
await expect(transport.listRepositories({
|
||||
installationId: "installation-1",
|
||||
page: 1,
|
||||
perPage: 2,
|
||||
query: "matching-owner",
|
||||
})).resolves.toMatchObject({
|
||||
totalCount: 3,
|
||||
items: [{ id: 101 }, { id: 102 }],
|
||||
});
|
||||
await expect(transport.listRepositories({
|
||||
installationId: "installation-1",
|
||||
page: 2,
|
||||
perPage: 2,
|
||||
query: "matching-owner",
|
||||
})).resolves.toMatchObject({
|
||||
totalCount: 3,
|
||||
items: [{ id: 201 }],
|
||||
});
|
||||
expect(fetchImpl.mock.calls.some(([input]) => String(input).includes("per_page=100&page=3"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expose provider request paths in operator-facing failures", async () => {
|
||||
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url);
|
||||
if (url.pathname.endsWith("/access_tokens")) {
|
||||
return jsonResponse({ token: "ghs_test", expires_at: "2099-01-01T00:00:00.000Z" });
|
||||
}
|
||||
return jsonResponse({ message: "unavailable" }, 503);
|
||||
});
|
||||
const transport = createGitHubAppTransport({ appId: "1", privateKey, fetchImpl });
|
||||
|
||||
await expect(transport.listRepositories({
|
||||
installationId: "sensitive-installation",
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
query: null,
|
||||
})).rejects.toThrow("GitHub repository provider request failed with status 503");
|
||||
await expect(transport.listRepositories({
|
||||
installationId: "sensitive-installation",
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
query: null,
|
||||
})).rejects.not.toThrow("sensitive-installation");
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -31,7 +31,7 @@ import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
|
||||
import { agentRoutes } from "./routes/agents.js";
|
||||
import { projectRoutes } from "./routes/projects.js";
|
||||
import { repositoryRoutes } from "./routes/repositories.js";
|
||||
import { registerConfiguredRepositoryProviders } from "./services/repository-providers/bootstrap.js";
|
||||
import { registerConfiguredRepositoryProviders } from "./services/repository-providers/registry.js";
|
||||
import { createPluginRepositoryProviderRegistrar } from "./services/repository-providers/plugin-registrar.js";
|
||||
import { issueRoutes } from "./routes/issues.js";
|
||||
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
|
||||
|
||||
@@ -424,7 +424,8 @@ export function repositoryRoutes(db: Db) {
|
||||
});
|
||||
const imported = await repositories.findImportedProviderRepositories(
|
||||
connection.companyId,
|
||||
connectionId,
|
||||
connection.provider,
|
||||
connection.host,
|
||||
page.items.map((item) => item.providerRepositoryId),
|
||||
);
|
||||
const importedById = new Map(imported.map((repo) => [repo.providerRepositoryId, repo]));
|
||||
@@ -468,7 +469,7 @@ export function repositoryRoutes(db: Db) {
|
||||
if (connection.status === "disconnected") throw unprocessable("Disconnected repository connection cannot import repositories");
|
||||
|
||||
const providerRepositoryIds: string[] = req.body.providerRepositoryIds;
|
||||
const importedRepositories = [];
|
||||
const importResults = [];
|
||||
const skipped: string[] = [];
|
||||
for (const providerRepositoryId of providerRepositoryIds) {
|
||||
const snapshot = await connector.refreshMetadata({ connection, providerRepositoryId });
|
||||
@@ -476,12 +477,14 @@ export function repositoryRoutes(db: Db) {
|
||||
skipped.push(providerRepositoryId);
|
||||
continue;
|
||||
}
|
||||
importedRepositories.push(
|
||||
importResults.push(
|
||||
await repositories.upsertProviderRepository(connection.companyId, connectionId, connection.provider, snapshot),
|
||||
);
|
||||
}
|
||||
const importedRepositories = importResults.map((result) => result.repository);
|
||||
const importedCount = importResults.filter((result) => result.created).length;
|
||||
const { attribution } = activityActor(req);
|
||||
if (importedRepositories.length > 0) {
|
||||
if (importedCount > 0) {
|
||||
await logActivity(db, {
|
||||
companyId: connection.companyId,
|
||||
...attribution,
|
||||
@@ -490,14 +493,14 @@ export function repositoryRoutes(db: Db) {
|
||||
entityId: connectionId,
|
||||
details: {
|
||||
provider: connection.provider,
|
||||
importedCount: importedRepositories.length,
|
||||
importedCount,
|
||||
skippedCount: skipped.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
res
|
||||
.status(importedRepositories.length > 0 ? 201 : 200)
|
||||
.json({ repositories: importedRepositories, imported: importedRepositories.length, skipped });
|
||||
.status(importedCount > 0 ? 201 : 200)
|
||||
.json({ repositories: importedRepositories, imported: importedCount, skipped });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -164,7 +164,8 @@ export function repositoryService(db: Db) {
|
||||
|
||||
findImportedProviderRepositories: async (
|
||||
companyId: string,
|
||||
connectionId: string,
|
||||
provider: string,
|
||||
host: string,
|
||||
providerRepositoryIds: string[],
|
||||
): Promise<Repository[]> => {
|
||||
if (providerRepositoryIds.length === 0) return [];
|
||||
@@ -173,7 +174,8 @@ export function repositoryService(db: Db) {
|
||||
.from(repositories)
|
||||
.where(and(
|
||||
eq(repositories.companyId, companyId),
|
||||
eq(repositories.connectionId, connectionId),
|
||||
eq(repositories.provider, provider),
|
||||
eq(repositories.host, host),
|
||||
inArray(repositories.providerRepositoryId, providerRepositoryIds),
|
||||
));
|
||||
return rows.map(toRepository);
|
||||
@@ -311,59 +313,83 @@ export function repositoryService(db: Db) {
|
||||
});
|
||||
const webUrl = input.webUrl ? normalizeRepositoryWebUrl(input.webUrl) : normalized.webUrl;
|
||||
const now = new Date();
|
||||
const row = await db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
return db.transaction(async (tx) => {
|
||||
const values = {
|
||||
companyId,
|
||||
connectionId,
|
||||
provider,
|
||||
providerRepositoryId: input.providerRepositoryId,
|
||||
identityKey,
|
||||
host: normalized.host,
|
||||
owner: normalized.owner,
|
||||
name: normalized.name,
|
||||
cloneUrl: normalized.cloneUrl,
|
||||
webUrl,
|
||||
defaultBranch: input.defaultBranch ?? null,
|
||||
visibility: input.visibility ?? "unknown",
|
||||
state: input.state ?? "active",
|
||||
unavailableReason: input.unavailableReason ?? null,
|
||||
providerMetadata: sanitizeRepositoryProviderMetadata(input.providerMetadata),
|
||||
updatedAt: now,
|
||||
} satisfies typeof repositories.$inferInsert;
|
||||
const updateValues = {
|
||||
host: normalized.host,
|
||||
owner: normalized.owner,
|
||||
name: normalized.name,
|
||||
cloneUrl: normalized.cloneUrl,
|
||||
webUrl,
|
||||
defaultBranch: input.defaultBranch ?? null,
|
||||
visibility: input.visibility ?? "unknown",
|
||||
state: sql`CASE
|
||||
WHEN ${repositories.state} = 'archived' THEN ${repositories.state}
|
||||
ELSE ${input.state ?? "active"}
|
||||
END`,
|
||||
unavailableReason: input.unavailableReason ?? null,
|
||||
providerMetadata: sanitizeRepositoryProviderMetadata(input.providerMetadata),
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
let existing = await tx
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(and(eq(repositories.companyId, companyId), eq(repositories.identityKey, identityKey)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) {
|
||||
if (existing.connectionId === connectionId) {
|
||||
existing = await tx
|
||||
.update(repositories)
|
||||
.set(updateValues)
|
||||
.where(and(eq(repositories.companyId, companyId), eq(repositories.id, existing.id)))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? existing);
|
||||
}
|
||||
return { repository: toRepository(existing), created: false };
|
||||
}
|
||||
|
||||
const inserted = await tx
|
||||
.insert(repositories)
|
||||
.values({
|
||||
companyId,
|
||||
connectionId,
|
||||
provider,
|
||||
providerRepositoryId: input.providerRepositoryId,
|
||||
identityKey,
|
||||
host: normalized.host,
|
||||
owner: normalized.owner,
|
||||
name: normalized.name,
|
||||
cloneUrl: normalized.cloneUrl,
|
||||
webUrl,
|
||||
defaultBranch: input.defaultBranch ?? null,
|
||||
visibility: input.visibility ?? "unknown",
|
||||
state: input.state ?? "active",
|
||||
unavailableReason: input.unavailableReason ?? null,
|
||||
providerMetadata: sanitizeRepositoryProviderMetadata(input.providerMetadata),
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [repositories.companyId, repositories.identityKey],
|
||||
set: {
|
||||
connectionId,
|
||||
host: normalized.host,
|
||||
owner: normalized.owner,
|
||||
name: normalized.name,
|
||||
cloneUrl: normalized.cloneUrl,
|
||||
webUrl,
|
||||
defaultBranch: input.defaultBranch ?? null,
|
||||
visibility: input.visibility ?? "unknown",
|
||||
state: sql`CASE
|
||||
WHEN ${repositories.state} = 'archived' THEN ${repositories.state}
|
||||
ELSE ${input.state ?? "active"}
|
||||
END`,
|
||||
unavailableReason: input.unavailableReason ?? null,
|
||||
providerMetadata: sanitizeRepositoryProviderMetadata(input.providerMetadata),
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.values(values)
|
||||
.onConflictDoNothing({ target: [repositories.companyId, repositories.identityKey] })
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!inserted) {
|
||||
existing = await tx
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(and(eq(repositories.companyId, companyId), eq(repositories.identityKey, identityKey)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) throw conflict("Repository import conflicted with another request");
|
||||
return { repository: toRepository(existing), created: false };
|
||||
}
|
||||
await tx
|
||||
.update(projectWorkspaces)
|
||||
.set({ repoUrl: normalized.cloneUrl, updatedAt: now })
|
||||
.where(and(
|
||||
eq(projectWorkspaces.companyId, companyId),
|
||||
eq(projectWorkspaces.repositoryId, updated.id),
|
||||
eq(projectWorkspaces.repositoryId, inserted.id),
|
||||
));
|
||||
return updated;
|
||||
return { repository: toRepository(inserted), created: true };
|
||||
});
|
||||
return toRepository(row!);
|
||||
},
|
||||
|
||||
markConnectionUnavailable: async (companyId: string, connectionId: string, reason: string) => {
|
||||
|
||||
@@ -357,7 +357,7 @@ export function repositoryConnectionService(
|
||||
const snapshot = snapshotsById.get(providerRepositoryId);
|
||||
if (!snapshot) continue;
|
||||
syncedRepositories.push(
|
||||
await transactionalRepositories.upsertProviderRepository(companyId, id, row.provider, snapshot),
|
||||
(await transactionalRepositories.upsertProviderRepository(companyId, id, row.provider, snapshot)).repository,
|
||||
);
|
||||
}
|
||||
const unavailableIds = [...importedIds].filter((providerRepositoryId) => !snapshotsById.has(providerRepositoryId));
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { repositoryProviderRegistry } from "../repository-connections.js";
|
||||
import { guardRepositoryProviderConnector } from "./connector-guard.js";
|
||||
import { createGitHubProvider } from "./github-provider.js";
|
||||
import { createGitHubAppTransport } from "./github-transport.js";
|
||||
|
||||
/**
|
||||
* Registers repository providers configured via environment. Registration is a
|
||||
* no-op when the required config is absent, so a deployment without a GitHub App
|
||||
* simply exposes manual repositories. Secrets (app id, private key, state
|
||||
* secret) are read here and handed to the transport/provider; they are never
|
||||
* persisted on any row.
|
||||
*/
|
||||
export interface RegisterRepositoryProvidersOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export function registerConfiguredRepositoryProviders(
|
||||
options: RegisterRepositoryProvidersOptions = {},
|
||||
): Array<() => void> {
|
||||
const env = options.env ?? process.env;
|
||||
const unregister: Array<() => void> = [];
|
||||
|
||||
const appId = env.PAPERCLIP_GITHUB_APP_ID?.trim();
|
||||
const privateKey = env.PAPERCLIP_GITHUB_APP_PRIVATE_KEY;
|
||||
const appSlug = env.PAPERCLIP_GITHUB_APP_SLUG?.trim();
|
||||
const stateSecret = env.PAPERCLIP_GITHUB_CONNECTION_STATE_SECRET?.trim();
|
||||
|
||||
if (appId && privateKey && appSlug && stateSecret) {
|
||||
const transport = createGitHubAppTransport({
|
||||
appId,
|
||||
privateKey,
|
||||
host: env.PAPERCLIP_GITHUB_HOST?.trim() || undefined,
|
||||
apiBaseUrl: env.PAPERCLIP_GITHUB_API_BASE_URL?.trim() || undefined,
|
||||
});
|
||||
const provider = createGitHubProvider({ transport, config: { appSlug, stateSecret } });
|
||||
unregister.push(repositoryProviderRegistry.register(
|
||||
// Core providers go through the same hardening wrapper as extensions:
|
||||
// one contract, one place where provider output is validated.
|
||||
guardRepositoryProviderConnector(provider, { provider: provider.provider, host: provider.host }),
|
||||
{
|
||||
host: provider.host,
|
||||
descriptor: {
|
||||
displayName: "GitHub",
|
||||
source: "core",
|
||||
supportsDiscovery: true,
|
||||
supportsCloneCredentials: true,
|
||||
},
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
return unregister;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export * from "./provider-contract.js";
|
||||
export * from "./connection-state.js";
|
||||
export * from "./github-provider.js";
|
||||
export * from "./github-transport.js";
|
||||
export * from "./fake-provider.js";
|
||||
export * from "./registry.js";
|
||||
export * from "./connector-guard.js";
|
||||
export * from "./plugin-connector.js";
|
||||
export * from "./plugin-registrar.js";
|
||||
export * from "./bootstrap.js";
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
type RepositoryProviderLookup,
|
||||
} from "../repository-connections.js";
|
||||
import type { RepositoryProviderConnector } from "./provider-contract.js";
|
||||
import { guardRepositoryProviderConnector } from "./connector-guard.js";
|
||||
import { createGitHubProvider } from "./github-provider.js";
|
||||
import { createGitHubAppTransport } from "./github-transport.js";
|
||||
|
||||
/**
|
||||
* Narrows a registered Foundation adapter to the full connection/discovery
|
||||
@@ -69,3 +72,44 @@ export function listAvailableRepositoryProviders(
|
||||
}))
|
||||
.sort((a, b) => (a.provider === b.provider ? a.host.localeCompare(b.host) : a.provider.localeCompare(b.provider)));
|
||||
}
|
||||
|
||||
export interface RegisterRepositoryProvidersOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/** Registers core repository providers configured through server environment. */
|
||||
export function registerConfiguredRepositoryProviders(
|
||||
options: RegisterRepositoryProvidersOptions = {},
|
||||
): Array<() => void> {
|
||||
const env = options.env ?? process.env;
|
||||
const unregister: Array<() => void> = [];
|
||||
|
||||
const appId = env.PAPERCLIP_GITHUB_APP_ID?.trim();
|
||||
const privateKey = env.PAPERCLIP_GITHUB_APP_PRIVATE_KEY;
|
||||
const appSlug = env.PAPERCLIP_GITHUB_APP_SLUG?.trim();
|
||||
const stateSecret = env.PAPERCLIP_GITHUB_CONNECTION_STATE_SECRET?.trim();
|
||||
|
||||
if (appId && privateKey && appSlug && stateSecret) {
|
||||
const transport = createGitHubAppTransport({
|
||||
appId,
|
||||
privateKey,
|
||||
host: env.PAPERCLIP_GITHUB_HOST?.trim() || undefined,
|
||||
apiBaseUrl: env.PAPERCLIP_GITHUB_API_BASE_URL?.trim() || undefined,
|
||||
});
|
||||
const provider = createGitHubProvider({ transport, config: { appSlug, stateSecret } });
|
||||
unregister.push(repositoryProviderRegistry.register(
|
||||
guardRepositoryProviderConnector(provider, { provider: provider.provider, host: provider.host }),
|
||||
{
|
||||
host: provider.host,
|
||||
descriptor: {
|
||||
displayName: "GitHub",
|
||||
source: "core",
|
||||
supportsDiscovery: true,
|
||||
supportsCloneCredentials: true,
|
||||
},
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
return unregister;
|
||||
}
|
||||
Reference in new issue
Block a user