import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; export const PI_CODE_DIFF_SETTINGS_VERSION = 1; export interface ProviderUrlPattern { host: string; path: string; } export interface ProviderUrlSettings { patterns: ProviderUrlPattern[]; canonical: string; clone?: string; } export interface ProviderOperationSettings { args: string[]; method?: string; } export interface ProviderSettings { id: string; label: string; executable: string; urls: ProviderUrlSettings; operations: Record; refs: Record; fields: Record; capabilities: Record; } export interface RepositoryProfileSettings { cwd: string; subdir?: string; pathspecs?: string[]; importAliases?: Record; } export interface CodeCommandSettings { command: string[]; targetArgs?: string[]; } export interface PiCodeDiffSettings { version: typeof PI_CODE_DIFF_SETTINGS_VERSION; code?: CodeCommandSettings; providers: Record; repositories: Record; } const BUILT_IN_GITHUB_PROVIDER: ProviderSettings = { id: "github", label: "GitHub", executable: "gh", urls: { patterns: [{ host: "github.com", path: "/{repo}/pull/{number}" }], canonical: "https://github.com/{repo}/pull/{number}", clone: "https://github.com/{repo}.git", }, operations: { pullRequest: { args: ["pr", "view", "{number}", "--repo", "{repo}", "--json", "number,title,body,additions,deletions,changedFiles,author,state,headRefName,headRefOid,baseRefName"], }, pullRequestDetails: { args: ["pr", "view", "{number}", "--repo", "{repo}", "--json", "url,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup,comments,reviews,createdAt,updatedAt"], }, reviews: { args: ["api", "repos/{repo}/pulls/{number}/reviews?per_page=100"] }, review: { args: ["api", "repos/{repo}/pulls/{number}/reviews/{reviewId}", "--include"] }, reviewCommentsForReview: { args: ["api", "repos/{repo}/pulls/{number}/reviews/{reviewId}/comments?per_page=100&page={page}", "--include"] }, branchLookup: { args: ["pr", "list", "--repo", "{repo}", "--state", "all", "--head", "{branch}", "--json", "number,title,headRefName,state,url", "--limit", "1"] }, identity: { args: ["api", "user"] }, submitReview: { args: ["api", "repos/{repo}/pulls/{number}/reviews", "--include", "--method", "POST", "--input", "{payloadPath}"] }, reviewThreads: { args: ["api", "graphql", "-f", "query={query}", "-F", "owner={owner}", "-F", "name={name}", "-F", "number={number}"] }, reviewComments: { args: ["api", "repos/{repo}/pulls/{number}/comments?per_page=100"] }, reviewCommentsPage: { args: ["api", "--include", "repos/{repo}/pulls/{number}/comments?per_page=100&page={page}"] }, pullRequestCommentsPage: { args: ["api", "--include", "repos/{repo}/issues/{number}/comments?per_page=100&page={page}"] }, pullRequestReviewsPage: { args: ["api", "--include", "repos/{repo}/pulls/{number}/reviews?per_page=100&page={page}"] }, }, refs: { head: "refs/pull/{number}/head" }, fields: { number: ["number"], title: ["title"], body: ["body"], additions: ["additions"], deletions: ["deletions"], changedFiles: ["changedFiles"], author: ["author.login", "user.login"], state: ["state"], reviewState: ["state"], headRefName: ["headRefName"], headRefOid: ["headRefOid"], baseRefName: ["baseRefName"], baseRefOid: ["baseRefOid"], identityLogin: ["login"], identityId: ["id"], submissionId: ["id"], submissionState: ["state"], submissionCommitId: ["commit_id"], submissionUrl: ["html_url"], submissionAuthor: ["user.login"], submissionAuthorId: ["user.id"], submissionBody: ["body"], commentId: ["id"], commentReviewId: ["pull_request_review_id"], commentAuthorId: ["user.id"], commentReplyToId: ["in_reply_to_id"], pullRequestUrl: ["url"], pullRequestDraft: ["isDraft"], pullRequestMergeState: ["mergeStateStatus"], pullRequestReviewDecision: ["reviewDecision"], pullRequestChecks: ["statusCheckRollup"], pullRequestComments: ["comments"], pullRequestReviews: ["reviews"], pullRequestCreatedAt: ["createdAt"], pullRequestUpdatedAt: ["updatedAt"], commentAuthor: ["author.login", "user.login"], commentBody: ["body"], commentCreatedAt: ["createdAt", "created_at"], commentSubmittedAt: ["submittedAt", "submitted_at"], commentState: ["state"], commentUrl: ["html_url", "url"], commentPath: ["path"], commentLine: ["line"], commentSubjectType: ["subject_type"], commentSide: ["side"], commentStartLine: ["start_line"], commentStartSide: ["start_side"], commentCommitId: ["commit_id"], commentOriginalCommitId: ["original_commit_id"], commentOriginalLine: ["original_line"], commentOriginalStartLine: ["original_start_line"], checkName: ["name"], checkWorkflowName: ["workflowName"], checkStatus: ["status"], checkConclusion: ["conclusion"], }, capabilities: { atomicReview: true, commitIdRequired: true, baseRevisionRequired: false, fileComments: false, graphqlReviewThreads: true, requestChangesBodyRequired: true, pullRequestChecks: true, threadedReplies: true, validateSubmitResponse: true, validateTargetBeforeSubmit: true, }, }; function withBuiltInProviders(settings: PiCodeDiffSettings): PiCodeDiffSettings { return { ...settings, providers: { github: BUILT_IN_GITHUB_PROVIDER, ...settings.providers }, }; } function defaultSettings(): PiCodeDiffSettings { return withBuiltInProviders({ version: PI_CODE_DIFF_SETTINGS_VERSION, providers: {}, repositories: {}, }); } function isRecord(value: unknown): value is Record { return value != null && typeof value === "object" && !Array.isArray(value); } function rejectUnknownKeys(value: Record, allowed: string[], context: string): void { const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); if (unknown.length > 0) throw new Error(`${context} has unsupported fields: ${unknown.join(", ")}.`); } function readNonEmptyString(value: unknown, context: string): string { if (typeof value !== "string" || value.trim().length === 0 || /[\0\r\n]/.test(value)) { throw new Error(`${context} must be a non-empty single-line string.`); } return value.trim(); } function readStringArray(value: unknown, context: string): string[] { if (!Array.isArray(value) || value.length === 0) throw new Error(`${context} must be a non-empty string array.`); return value.map((entry, index) => readNonEmptyString(entry, `${context}[${index}]`)); } function readUrlPattern(value: unknown, context: string): ProviderUrlPattern { if (!isRecord(value)) throw new Error(`${context} must be an object.`); rejectUnknownKeys(value, ["host", "path"], context); const host = readNonEmptyString(value.host, `${context}.host`).toLowerCase(); if (!/^[a-z0-9.-]+$/.test(host) || host.startsWith(".") || host.endsWith(".")) { throw new Error(`${context}.host is invalid.`); } const path = readNonEmptyString(value.path, `${context}.path`); if (!path.startsWith("/") || path.includes("?") || path.includes("#")) throw new Error(`${context}.path is invalid.`); return { host, path }; } function readUrlSettings(value: unknown, context: string): ProviderUrlSettings { if (!isRecord(value)) throw new Error(`${context} must be an object.`); rejectUnknownKeys(value, ["patterns", "canonical", "clone"], context); if (!Array.isArray(value.patterns) || value.patterns.length === 0) throw new Error(`${context}.patterns must be a non-empty array.`); const patterns = value.patterns.map((entry, index) => readUrlPattern(entry, `${context}.patterns[${index}]`)); const canonical = readNonEmptyString(value.canonical, `${context}.canonical`); if (!canonical.startsWith("https://")) throw new Error(`${context}.canonical must use https.`); const clone = value.clone == null ? undefined : readNonEmptyString(value.clone, `${context}.clone`); if (clone != null && !clone.startsWith("https://")) throw new Error(`${context}.clone must use https.`); return { patterns, canonical, ...(clone == null ? {} : { clone }) }; } function readOperation(value: unknown, context: string): ProviderOperationSettings { if (!isRecord(value)) throw new Error(`${context} must be an object.`); rejectUnknownKeys(value, ["args", "method"], context); const args = readStringArray(value.args, `${context}.args`); const method = value.method == null ? undefined : readNonEmptyString(value.method, `${context}.method`).toUpperCase(); if (method != null && !/^[A-Z]+$/.test(method)) throw new Error(`${context}.method is invalid.`); return { args, ...(method == null ? {} : { method }) }; } function readStringMap(value: unknown, context: string): Record { if (value == null) return {}; if (!isRecord(value)) throw new Error(`${context} must be an object.`); return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, readNonEmptyString(entry, `${context}.${key}`)])); } function readFieldMap(value: unknown, context: string): Record { if (value == null) return {}; if (!isRecord(value)) throw new Error(`${context} must be an object.`); return Object.fromEntries(Object.entries(value).map(([key, entry]) => { const paths = typeof entry === "string" ? [readNonEmptyString(entry, `${context}.${key}`)] : readStringArray(entry, `${context}.${key}`); if (paths.some((path) => !/^[A-Za-z0-9_.-]+$/.test(path))) throw new Error(`${context}.${key} contains an invalid field path.`); return [key, paths]; })); } function readCapabilities(value: unknown, context: string): Record { if (value == null) return {}; if (!isRecord(value)) throw new Error(`${context} must be an object.`); return Object.fromEntries(Object.entries(value).map(([key, entry]) => { if (typeof entry !== "boolean") throw new Error(`${context}.${key} must be boolean.`); return [key, entry]; })); } function readProvider(id: string, value: unknown): ProviderSettings { if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id)) throw new Error(`Provider id ${id} is invalid.`); const context = `providers.${id}`; if (!isRecord(value)) throw new Error(`${context} must be an object.`); rejectUnknownKeys(value, ["label", "executable", "urls", "operations", "refs", "fields", "capabilities"], context); if (!isRecord(value.operations)) throw new Error(`${context}.operations must be an object.`); const operations = Object.fromEntries(Object.entries(value.operations).map(([key, entry]) => [key, readOperation(entry, `${context}.operations.${key}`)])); return { id, label: readNonEmptyString(value.label, `${context}.label`), executable: readNonEmptyString(value.executable, `${context}.executable`), urls: readUrlSettings(value.urls, `${context}.urls`), operations, refs: readStringMap(value.refs, `${context}.refs`), fields: readFieldMap(value.fields, `${context}.fields`), capabilities: readCapabilities(value.capabilities, `${context}.capabilities`), }; } function readRepository(value: unknown, context: string): RepositoryProfileSettings { if (typeof value === "string") return { cwd: readNonEmptyString(value, context) }; if (!isRecord(value)) throw new Error(`${context} must be a path or object.`); rejectUnknownKeys(value, ["cwd", "path", "subdir", "pathspecs", "importAliases"], context); const cwd = readNonEmptyString(value.cwd ?? value.path, `${context}.cwd`); const subdir = value.subdir == null ? undefined : readNonEmptyString(value.subdir, `${context}.subdir`); const pathspecs = value.pathspecs == null ? undefined : readStringArray(value.pathspecs, `${context}.pathspecs`); const importAliases = value.importAliases == null ? undefined : readStringMap(value.importAliases, `${context}.importAliases`); return { cwd, ...(subdir == null ? {} : { subdir }), ...(pathspecs == null ? {} : { pathspecs }), ...(importAliases == null ? {} : { importAliases }), }; } function readCodeArgs(value: unknown, context: string, allowTarget: boolean): string[] { if (value == null) return []; if (!Array.isArray(value)) throw new Error(`${context} must be an array.`); return value.map((entry, index) => { const template = readNonEmptyString(entry, `${context}[${index}]`); const placeholders = [...template.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]!); if (placeholders.some((name) => !["cwd", "file", "line"].includes(name))) throw new Error(`${context} contains an unsupported placeholder.`); if (!allowTarget && placeholders.some((name) => name !== "cwd")) throw new Error(`${context} cannot use file or line placeholders.`); if (template.replace(/\{(?:cwd|file|line)\}/g, "").match(/[{}]/)) throw new Error(`${context} contains a malformed placeholder.`); return template; }); } function readCodeSettings(value: unknown): CodeCommandSettings | undefined { if (value == null) return undefined; if (!isRecord(value)) throw new Error("settings.code must be an object."); rejectUnknownKeys(value, ["command", "targetArgs"], "settings.code"); const command = readCodeArgs(value.command, "settings.code.command", false); if (command.length === 0) throw new Error("settings.code.command must not be empty."); return { command, targetArgs: readCodeArgs(value.targetArgs, "settings.code.targetArgs", true) }; } export function parsePiCodeDiffSettings(value: unknown): PiCodeDiffSettings { if (!isRecord(value)) throw new Error("Settings must be an object."); rejectUnknownKeys(value, ["version", "code", "providers", "repositories"], "settings"); if (value.version !== undefined && value.version !== PI_CODE_DIFF_SETTINGS_VERSION) throw new Error(`Settings version must be ${PI_CODE_DIFF_SETTINGS_VERSION}.`); const code = readCodeSettings(value.code); if (!isRecord(value.providers)) throw new Error("settings.providers must be an object."); const providers = Object.fromEntries(Object.entries(value.providers).map(([id, entry]) => [id, readProvider(id, entry)])); const repositoriesValue = value.repositories ?? {}; if (!isRecord(repositoriesValue)) throw new Error("settings.repositories must be an object."); const repositories = Object.fromEntries(Object.entries(repositoriesValue).map(([repo, entry]) => [repo.toLowerCase(), readRepository(entry, `repositories.${repo}`)])); return withBuiltInProviders({ version: PI_CODE_DIFF_SETTINGS_VERSION, ...(code == null ? {} : { code }), providers, repositories }); } export function getPiCodeDiffSettingsPath(): string { return process.env.PI_CODE_DIFF_SETTINGS_PATH ?? join(getAgentDir(), "pi-code-diff-settings.json"); } export function loadPiCodeDiffSettings(): PiCodeDiffSettings { const path = getPiCodeDiffSettingsPath(); if (!existsSync(path)) return defaultSettings(); return parsePiCodeDiffSettings(JSON.parse(readFileSync(path, "utf8")) as unknown); } export function getProviderSettings(id: string, settings = loadPiCodeDiffSettings()): ProviderSettings | undefined { return settings.providers[id]; } export function requireProviderSettings(id: string, settings = loadPiCodeDiffSettings()): ProviderSettings { const provider = getProviderSettings(id, settings); if (provider == null) throw new Error(`Provider ${id} is not configured in ${getPiCodeDiffSettingsPath()}.`); return provider; } export function renderProviderTemplate(template: string, values: Record): string { const rendered = template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (_match, key: string) => { const value = values[key]; if (value == null) throw new Error(`Missing provider template value: ${key}.`); const text = String(value); if (/[\0\r\n]/.test(text)) throw new Error(`Invalid provider template value: ${key}.`); return text; }); if (/\{[^{}]+\}/.test(rendered)) throw new Error(`Unresolved provider template: ${rendered}.`); return rendered; } export function renderProviderOperation( provider: ProviderSettings, operation: string, values: Record, ): ProviderOperationSettings { const configured = provider.operations[operation]; if (configured == null) throw new Error(`Provider ${provider.id} does not configure operation ${operation}.`); return { args: configured.args.map((argument) => renderProviderTemplate(argument, values)), ...(configured.method == null ? {} : { method: configured.method }), }; } export function getProviderCapability(provider: ProviderSettings, capability: string): boolean { return provider.capabilities[capability] === true; } export function readConfiguredField(provider: ProviderSettings, field: string, value: unknown): unknown { const paths = provider.fields[field] ?? []; for (const path of paths) { let current = value; for (const segment of path.split(".")) { if (!isRecord(current) && !Array.isArray(current)) { current = undefined; break; } current = (current as Record)[segment]; } if (current !== undefined && current !== null) return current; } return undefined; }