mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
102 lines
5.8 KiB
TypeScript
102 lines
5.8 KiB
TypeScript
import { connectionGrants, toolConnections } from "@paperclipai/db";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { ghFetch } from "./github-fetch.js";
|
|
import type { ProjectRepository, ProjectWorkspace } from "@paperclipai/shared";
|
|
import { unprocessable } from "../errors.js";
|
|
import { isConnectionGrantAudienceAllowed } from "./tool-gateway.js";
|
|
|
|
export function canBrowseProjectRepositoryGrant(input: {
|
|
grant: { status: string; kind: string; subjectUserId: string | null };
|
|
userId: string | null;
|
|
activeMember: boolean;
|
|
audience: string[];
|
|
}) {
|
|
const { grant, userId, activeMember, audience } = input;
|
|
if (grant.status !== "active") return false;
|
|
if (grant.kind === "user") return Boolean(userId && activeMember && grant.subjectUserId === userId);
|
|
return grant.kind === "organization" && isConnectionGrantAudienceAllowed(audience, userId, activeMember);
|
|
}
|
|
|
|
export function mergeProjectRepository(
|
|
repositories: Map<string, ProjectRepository>,
|
|
repo: { id: string; fullName: string; private?: boolean; ownerType?: ProjectRepository["ownerType"] },
|
|
connectionName: string,
|
|
connectionId?: string,
|
|
) {
|
|
const previous = repositories.get(repo.id);
|
|
repositories.set(repo.id, {
|
|
...repo, url: `https://github.com/${repo.fullName}`,
|
|
connections: [...new Set([...(previous?.connections ?? []), connectionName])],
|
|
...(connectionId ? { connectionIds: [...new Set([...(previous?.connectionIds ?? []), connectionId])] } : {}),
|
|
});
|
|
}
|
|
|
|
/** Prefer current provider metadata; unavailable existing selections can remain. */
|
|
export function resolveProjectRepositorySelection(
|
|
ids: string[],
|
|
available: ProjectRepository[],
|
|
existing: Pick<ProjectWorkspace, "name" | "repoUrl" | "metadata">[] = [],
|
|
): ProjectRepository[] {
|
|
return [...new Set(ids)].map((id) => {
|
|
const current = available.find((repo) => repo.id === id);
|
|
if (current) return current;
|
|
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === id && workspace.repoUrl);
|
|
if (retained) return { id, fullName: retained.name, url: retained.repoUrl!, connections: [],
|
|
...(typeof retained.metadata?.githubRepositoryPrivate === "boolean" ? { private: retained.metadata.githubRepositoryPrivate } : {}) };
|
|
throw unprocessable("A selected GitHub repository is no longer available. Refresh repositories and try again.");
|
|
});
|
|
}
|
|
|
|
/** Register an existing GitHub URL without assuming it is in the connection catalog.
|
|
* No fetch or credential sharing: execution uses the normal repository access policy.
|
|
*/
|
|
export function normalizeProjectRepositoryUrl(value: string): { fullName: string; url: string } {
|
|
let parsed: URL;
|
|
try { parsed = new URL(value); } catch { throw unprocessable("Repository URL must be an HTTPS GitHub repository URL"); }
|
|
if (parsed.protocol !== "https:" || parsed.hostname !== "github.com" || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
throw unprocessable("Repository URL must be an HTTPS GitHub repository URL without credentials, query, or fragment");
|
|
}
|
|
const path = parsed.pathname.replace(/\/$/, "").replace(/\.git$/, "");
|
|
if (!/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(path) || path.split("/").some(part => part === "." || part === "..")) {
|
|
throw unprocessable("Repository URL must identify a GitHub owner and repository");
|
|
}
|
|
return { fullName: path.slice(1), url: `https://github.com${path}` };
|
|
}
|
|
|
|
/** Use the freshest active catalog visibility (the UI lock), then retained selections. Legacy URL-only
|
|
* sources need anonymous discovery; unknown visibility must never start a private clone. */
|
|
export async function projectRepositoryIsPrivate(
|
|
db: import("@paperclipai/db").Db,
|
|
companyId: string,
|
|
workspace: Pick<ProjectWorkspace, "repoUrl" | "metadata">,
|
|
): Promise<boolean> {
|
|
const repo = normalizeProjectRepositoryUrl(workspace.repoUrl!);
|
|
const visibility = await db.select({
|
|
refreshedAt: sql<string | null>`${connectionGrants.providerTenant}->'github'->>'lastAccessRefreshAt'`,
|
|
private: sql<boolean | null>`(
|
|
select bool_or((repository->>'private')::boolean)
|
|
from jsonb_array_elements(coalesce(${connectionGrants.providerTenant}->'github'->'repositories', '[]'::jsonb)) repository
|
|
where lower(repository->>'fullName') = ${repo.fullName.toLowerCase()}
|
|
)` }).from(connectionGrants).innerJoin(toolConnections, and(
|
|
eq(toolConnections.id, connectionGrants.connectionId), eq(toolConnections.companyId, companyId),
|
|
)).where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.status, "active"),
|
|
eq(toolConnections.status, "active"), eq(toolConnections.enabled, true)));
|
|
// OAuth refreshes change updatedAt without observing repository visibility.
|
|
const current = visibility.filter(row => typeof row.private === "boolean" && Number.isFinite(Date.parse(row.refreshedAt ?? "")))
|
|
.sort((a, b) => Date.parse(b.refreshedAt!) - Date.parse(a.refreshedAt!))[0];
|
|
if (current) return current.private!;
|
|
if (workspace.metadata?.githubRepositoryUrl === repo.url && typeof workspace.metadata.githubRepositoryPrivate === "boolean") {
|
|
return workspace.metadata.githubRepositoryPrivate;
|
|
}
|
|
const legacy = visibility.filter(row => typeof row.private === "boolean");
|
|
if (legacy.length && legacy.every(row => row.private === legacy[0]!.private)) return legacy[0]!.private!;
|
|
const response = await ghFetch(`https://api.github.com/repos/${repo.fullName}`, {
|
|
headers: { Accept: "application/vnd.github+json" }, redirect: "error", signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (response.ok) {
|
|
const result = await response.json() as { full_name?: string; private?: boolean };
|
|
if (result.full_name?.toLowerCase() === repo.fullName.toLowerCase() && result.private === false) return false;
|
|
}
|
|
throw unprocessable("Repository visibility is unavailable; retry discovery or refresh the GitHub repository selection before provisioning.");
|
|
}
|