From 4e922d7c895288c06c4d0c2b0415080964897db3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 26 Jul 2026 16:41:57 -0700 Subject: [PATCH] enforce code file length --- AGENTS.md | 24 +- package.json | 8 +- .../skills/attach-github-assets/SOURCES.md | 3 +- .../junior-github/src/credential-support.ts | 893 ++++++++ packages/junior-github/src/git-config.ts | 264 +++ packages/junior-github/src/index.ts | 1901 +---------------- packages/junior-github/src/plugin.ts | 813 +++++++ policies/code-comments.md | 3 + policies/interface-design.md | 6 +- scripts/check-file-length.mjs | 100 + scripts/check-file-length.test.mjs | 45 + scripts/file-length-exceptions.mjs | 87 + 12 files changed, 2232 insertions(+), 1915 deletions(-) create mode 100644 packages/junior-github/src/credential-support.ts create mode 100644 packages/junior-github/src/git-config.ts create mode 100644 packages/junior-github/src/plugin.ts create mode 100644 scripts/check-file-length.mjs create mode 100644 scripts/check-file-length.test.mjs create mode 100644 scripts/file-length-exceptions.mjs diff --git a/AGENTS.md b/AGENTS.md index 7759ca497..dc80c864b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ Use **pnpm**: `pnpm install`, `pnpm dev`, `pnpm test`, `pnpm typecheck`, `pnpm s - Use `/commit` for commits, `/pr-writer` for pull requests, and `/skill-writer` for skill changes. - For non-trivial changes: discover, implement the smallest vertical slice, verify, and summarize. - Search every consumer before changing a shared signature, error contract, or name; use a hard cutover unless compatibility is explicitly required. +- When adding or renaming a shared export, search its exact name and the canonical domain terms a maintainer would use. The owner and consumers should be easy to distinguish without knowing the file path first. - Let unexpected failures reach the owning boundary; retry only expected transient failures. Follow `policies/error-handling.md`. - Exported functions need brief intent-focused JSDoc; follow `policies/code-comments.md`. - Run applicable checks, move durable explanations beside the owning code, and delete completed plans. @@ -42,20 +43,21 @@ Use **pnpm**: `pnpm install`, `pnpm dev`, `pnpm test`, `pnpm typecheck`, `pnpm s - Read `packages/junior/src/chat/README.md` before changing shared chat runtime behavior; it owns flow, module boundaries, vocabulary, and invariants. - Follow `policies/provider-boundaries.md`; provider modules do not import runtime orchestration, and shared modules do not expose provider SDK types. - Group files by feature and import feature files directly; do not add feature-directory barrels. +- Code files may not exceed 1,000 lines unless `scripts/file-length-exceptions.mjs` names the file and explains why it should stay large. - Do not add mutable runtime globals or test-only singleton mutation APIs. ## Where Rules Live -| Need | Source | -| ----------------------- | ------------------------------------------------------------------------------------------------------- | -| Repo-wide policy index | `policies/README.md` | -| Runtime vocabulary | `TERMINOLOGY.md` | -| Design and failures | `policies/interface-design.md`, `policies/correctness-complexity.md`, `policies/error-handling.md` | -| Provider boundaries | `policies/provider-boundaries.md` | -| Comments and telemetry | `policies/code-comments.md`, `policies/observability.md`, `TELEMETRY.md` | -| Chat architecture | `packages/junior/src/chat/README.md` | -| Testing and evals | `policies/testing.md`, `policies/evals.md`, `packages/junior/tests/README.md`, `packages/junior-evals/README.md` | -| Local agent validation | `packages/docs/src/content/docs/contribute/local-agent-validation.md` | -| Temporary plans | `openspec/changes//` | +| Need | Source | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Repo-wide policy index | `policies/README.md` | +| Runtime vocabulary | `TERMINOLOGY.md` | +| Design and failures | `policies/interface-design.md`, `policies/correctness-complexity.md`, `policies/error-handling.md` | +| Provider boundaries | `policies/provider-boundaries.md` | +| Comments and telemetry | `policies/code-comments.md`, `policies/observability.md`, `TELEMETRY.md` | +| Chat architecture | `packages/junior/src/chat/README.md` | +| Testing and evals | `policies/testing.md`, `policies/evals.md`, `packages/junior/tests/README.md`, `packages/junior-evals/README.md` | +| Local agent validation | `packages/docs/src/content/docs/contribute/local-agent-validation.md` | +| Temporary plans | `openspec/changes//` | Feature architecture and non-obvious invariants belong in the owning package or module `README.md`. Code, schemas, exported types, and tests are authoritative. Plans cannot override policy; update the policy for an exception. diff --git a/package.json b/package.json index 506a1fa1b..599efa39c 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "worktree:setup": "node scripts/worktree.mjs setup", "cloudflare:token": "node scripts/refresh-cloudflare-tunnel-token.mjs", "prepare": "simple-git-hooks", - "lint": "pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-vercel lint && pnpm ast-grep:lint && pnpm package:lint", + "lint": "pnpm file-length:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-vercel lint && pnpm ast-grep:lint && pnpm package:lint", "lint:fix": "pnpm --filter @sentry/junior lint:fix", + "file-length:check": "node --test scripts/check-file-length.test.mjs && node scripts/check-file-length.mjs", "ast-grep:lint": "ast-grep scan", "lint-staged": "lint-staged", "build": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-vercel build && pnpm --filter @sentry/junior-dashboard build", @@ -38,7 +39,10 @@ }, "lint-staged": { "packages/junior/**/*.{js,jsx,ts,tsx,mjs,cjs}": "pnpm --filter @sentry/junior exec oxlint --config .oxlintrc.json --deny-warnings --fix", - "*.{js,jsx,ts,tsx,mjs,cjs,json,md,mdx,yml,yaml}": "pnpm exec prettier --write --ignore-unknown" + "*.{js,jsx,ts,tsx,mjs,cjs,json,md,mdx,yml,yaml}": [ + "pnpm file-length:check", + "pnpm exec prettier --write --ignore-unknown" + ] }, "devDependencies": { "@ast-grep/cli": "^0.43.0", diff --git a/packages/junior-github/skills/attach-github-assets/SOURCES.md b/packages/junior-github/skills/attach-github-assets/SOURCES.md index fbb049ebe..2ce867aee 100644 --- a/packages/junior-github/skills/attach-github-assets/SOURCES.md +++ b/packages/junior-github/skills/attach-github-assets/SOURCES.md @@ -5,7 +5,8 @@ - `intercom/2x-skills@59213af`, `plugins/pr-tools/skills/attach-github-assets/SKILL.md` — upstream runtime intent and supported use cases; primary source; high confidence; MIT. - `intercom/2x-skills@59213af`, `plugins/pr-tools/skills/attach-github-assets/scripts/upload.sh` — upstream endpoint, MIME mapping, and response contract; primary implementation source; high confidence; MIT. - `intercom/2x-skills@59213af`, `plugins/pr-tools/LICENSE` — upstream copyright and license notice; authoritative legal source; high confidence. -- `packages/junior-github/src/index.ts` — local GitHub credential and egress boundary; authoritative local source; high confidence. +- `packages/junior-github/src/credential-support.ts` — local GitHub credential boundary; authoritative local source; high confidence. +- `packages/junior-github/src/plugin.ts` — local GitHub egress boundary; authoritative local source; high confidence. - `packages/junior-github/skills/github-code/SKILL.md` — local repository targeting and credential guidance; authoritative local convention; high confidence. - GitHub REST API documentation — no documented user-attachment upload operation found; official source; medium confidence for absence. diff --git a/packages/junior-github/src/credential-support.ts b/packages/junior-github/src/credential-support.ts new file mode 100644 index 000000000..993978252 --- /dev/null +++ b/packages/junior-github/src/credential-support.ts @@ -0,0 +1,893 @@ +/** + * GitHub credential issuance and provider request support. + * + * This module owns OAuth refresh, installation tokens, credential leases, and + * repository-scoped credential parsing. + */ +import { createPrivateKey, createSign } from "node:crypto"; +import type { + PluginCredentialResult, + PluginGrant, + PluginProviderAccount, + PluginStoredTokens, + PluginUserTokenSlot, + IssueCredentialHookContext, +} from "@sentry/junior-plugin-api"; +import { + type GitHubAppPermissions, + readGrantPermissions, +} from "./permissions.js"; + +export type JsonRecord = Record; +export type GitHubGrantName = + | "installation-read" + | "installation-write" + | "user-read" + | "user-write"; +export type GitHubGrantReason = + | "github.api-read" + | "github.asset-upload" + | "github.git-read" + | "github.graphql-read" + | "github.installation-write" + | "github.user-read" + | "github.user-write"; +export type GitHubGrant = PluginGrant & { + name: GitHubGrantName; + reason: GitHubGrantReason; +}; + +interface GitHubRequestParams { + body?: unknown; + method?: string; + token: string; +} + +interface OAuthTokenRequestInput { + clientId: string; + clientSecret: string; + payload: Record; +} + +interface RefreshUserAccessTokenInput { + clientIdEnv: string; + clientSecretEnv: string; + refreshToken: string; + requestedScope?: string; +} + +interface CredentialLeaseInput { + account?: PluginProviderAccount; + authorization?: { + provider: "github"; + scope?: string; + type: "oauth"; + }; + domains?: string[]; + expiresAtMs: number; + token: string; +} + +type TokenResolution = + | { ok: true; tokens: PluginStoredTokens } + | { ok: false; result: PluginCredentialResult }; + +interface UserCredentialOptions { + clientIdEnv: string; + clientSecretEnv: string; + userScope?: string; +} + +interface InstallationCredentialBaseOptions { + appIdEnv: string; + installationIdEnv: string; + privateKeyEnv: string; +} + +type InstallationCredentialOptions = InstallationCredentialBaseOptions & + ( + | { + loadPermissions?: never; + permissions?: GitHubAppPermissions; + repositories: string[]; + } + | { + loadPermissions?: never; + permissions: GitHubAppPermissions; + repositories?: never; + } + | { + loadPermissions: LoadInstallationReadPermissions; + permissions?: never; + repositories?: never; + } + ); + +type LoadInstallationReadPermissions = (input: { + appJwt: string; + installationId: number; +}) => Promise>; + +interface GitHubRepository { + name: string; + owner: string; +} + +export const GITHUB_APP_ID_ENV = "GITHUB_APP_ID"; +export const GITHUB_APP_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY"; +export const GITHUB_INSTALLATION_ID_ENV = "GITHUB_INSTALLATION_ID"; +export const GITHUB_AUTH_TOKEN_ENV = "GITHUB_TOKEN"; +export const GITHUB_AUTH_TOKEN_PLACEHOLDER = "ghp_host_managed_credential"; +const MAX_LEASE_MS = 60 * 60 * 1000; +const REFRESH_BUFFER_MS = 5 * 60 * 1000; +const USER_REFRESH_TIMEOUT_MS = 20_000; +export const GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES = 64 * 1024; +export const HTTP_READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); +export const USER_TOKEN_GRANTS = new Set(["user-read", "user-write"]); +export const CREATE_TOOL_ROUTING_GUIDANCE = + "This is a Junior tool-routing denial, not a GitHub permission failure. Do not ask the user for GitHub permissions; retry with the required Junior tool."; +export const USER_WRITE_REQUIREMENTS = [ + "requesting GitHub user permission to perform this operation", +]; +const GITHUB_CREDENTIAL_DOMAINS = ["api.github.com", "github.com"]; +const GITHUB_ASSET_UPLOAD_CREDENTIAL_DOMAINS = [ + ...GITHUB_CREDENTIAL_DOMAINS, + "uploads.github.com", +]; + +class GitHubUserRefreshRejectedError extends Error { + constructor(message: string) { + super(message); + this.name = "GitHubUserRefreshRejectedError"; + } +} + +class GitHubRequestError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "GitHubRequestError"; + this.status = status; + } +} + +export class GitHubPluginSetupError extends Error { + constructor(message: string) { + super(message); + this.name = "GitHubPluginSetupError"; + } +} + +/** Return whether a provider value is a JSON object. */ +export function isRecord(value: unknown): value is JsonRecord { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +/** Read a non-empty GitHub plugin environment value. */ +export function readEnv(name: string): string | undefined { + const value = process.env[name]; + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +/** Read a required GitHub plugin environment value. */ +export function requireEnv(name: string): string { + const value = readEnv(name); + if (!value) { + throw new GitHubPluginSetupError(`Missing ${name}`); + } + return value; +} + +/** Normalize configured GitHub OAuth scopes. */ +export function normalizeScopeList(scopes?: string[]): string[] { + return [ + ...new Set( + (scopes ?? []) + .flatMap((scope) => String(scope).split(/\s+/)) + .map((scope) => scope.trim()) + .filter(Boolean), + ), + ].sort(); +} + +function normalizeOAuthScope(scope?: string): string | undefined { + const normalized = normalizeScopeList(scope ? [scope] : []); + return normalized.length ? normalized.join(" ") : undefined; +} + +function hasRequiredOAuthScope( + storedScope?: string, + requiredScope?: string, +): boolean { + const required = normalizeScopeList(requiredScope ? [requiredScope] : []); + if (required.length === 0) { + return true; + } + const stored = new Set(normalizeScopeList(storedScope ? [storedScope] : [])); + if (stored.size === 0) { + return false; + } + return required.every((scope) => stored.has(scope)); +} + +function isGitHubApiUrl(upstreamUrl: URL): boolean { + return upstreamUrl.hostname.toLowerCase() === "api.github.com"; +} + +function base64Url(input: string): string { + return Buffer.from(input) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function getPrivateKey(envName: string) { + const raw = requireEnv(envName); + let key; + try { + key = createPrivateKey({ key: raw, format: "pem" }); + } catch { + throw new GitHubPluginSetupError( + `Invalid ${envName}: expected a PEM-encoded RSA private key`, + ); + } + + if (key.asymmetricKeyType !== "rsa") { + throw new GitHubPluginSetupError( + `Invalid ${envName}: GitHub App signing requires an RSA private key`, + ); + } + return key; +} + +function createAppJwt(appId: string, privateKeyEnv: string): string { + const now = Math.floor(Date.now() / 1000); + const header = { alg: "RS256", typ: "JWT" }; + const payload = { iat: now - 60, exp: now + 9 * 60, iss: appId }; + const encodedHeader = base64Url(JSON.stringify(header)); + const encodedPayload = base64Url(JSON.stringify(payload)); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signer = createSign("RSA-SHA256"); + signer.update(signingInput); + signer.end(); + const signature = signer + .sign(getPrivateKey(privateKeyEnv)) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + return `${signingInput}.${signature}`; +} + +/** Send an authenticated request to the GitHub API. */ +export async function githubRequest( + apiBase: string, + path: string, + params: GitHubRequestParams, +): Promise { + const response = await fetch(`${apiBase}${path}`, { + method: params.method ?? "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${params.token}`, + "X-GitHub-Api-Version": "2022-11-28", + ...(params.body ? { "Content-Type": "application/json" } : {}), + }, + ...(params.body ? { body: JSON.stringify(params.body) } : {}), + }); + + const text = await response.text(); + let parsed: unknown; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + } + + if (!response.ok) { + const message = + isRecord(parsed) && typeof parsed.message === "string" + ? parsed.message + : `GitHub API error ${response.status}`; + throw new GitHubRequestError(message, response.status); + } + return parsed; +} + +function buildOAuthTokenRequest(input: OAuthTokenRequestInput): { + body: URLSearchParams; + headers: Record; +} { + const payload = { + ...input.payload, + client_id: input.clientId, + client_secret: input.clientSecret, + }; + return { + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(payload), + }; +} + +function parseOAuthResponseJson(responseText: string): unknown { + if (!responseText.trim()) { + return undefined; + } + try { + return JSON.parse(responseText); + } catch { + return undefined; + } +} + +function oauthErrorCode(data: unknown): string | undefined { + return isRecord(data) && typeof data.error === "string" + ? data.error + : undefined; +} + +function isRejectedRefreshError(errorCode: string | undefined): boolean { + return errorCode === "bad_refresh_token" || errorCode === "invalid_grant"; +} + +function parseOAuthTokenResponse( + data: unknown, + requestedScope?: string, +): PluginStoredTokens { + if (!isRecord(data)) { + throw new Error("OAuth token response is invalid"); + } + if (typeof data.access_token !== "string" || !data.access_token.trim()) { + throw new Error("OAuth token response missing access_token"); + } + if (typeof data.refresh_token !== "string" || !data.refresh_token.trim()) { + throw new Error("OAuth token response missing refresh_token"); + } + let scope = normalizeOAuthScope(requestedScope); + if (data.scope !== undefined) { + if (typeof data.scope !== "string") { + throw new Error("OAuth token response returned invalid scope"); + } + scope = normalizeOAuthScope(data.scope) ?? scope; + } + const result: PluginStoredTokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + ...(scope ? { scope } : {}), + }; + if (data.expires_in !== undefined) { + if ( + typeof data.expires_in !== "number" || + !Number.isFinite(data.expires_in) || + data.expires_in <= 0 + ) { + throw new Error("OAuth token response returned invalid expires_in"); + } + result.expiresAt = Date.now() + data.expires_in * 1000; + } + if (data.refresh_token_expires_in !== undefined) { + if ( + typeof data.refresh_token_expires_in !== "number" || + !Number.isFinite(data.refresh_token_expires_in) || + data.refresh_token_expires_in <= 0 + ) { + throw new Error( + "OAuth token response returned invalid refresh_token_expires_in", + ); + } + result.refreshTokenExpiresAt = + Date.now() + data.refresh_token_expires_in * 1000; + } + return result; +} + +async function refreshUserAccessToken( + input: RefreshUserAccessTokenInput, +): Promise { + const clientId = requireEnv(input.clientIdEnv); + const clientSecret = requireEnv(input.clientSecretEnv); + const request = buildOAuthTokenRequest({ + clientId, + clientSecret, + payload: { + grant_type: "refresh_token", + refresh_token: input.refreshToken, + }, + }); + const response = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: request.headers, + body: request.body, + signal: AbortSignal.timeout(USER_REFRESH_TIMEOUT_MS), + }); + const responseText = await response.text(); + const responseData = parseOAuthResponseJson(responseText); + const errorCode = oauthErrorCode(responseData); + if (isRejectedRefreshError(errorCode)) { + throw new GitHubUserRefreshRejectedError( + `GitHub user token refresh rejected: ${errorCode}`, + ); + } + if (!response.ok || errorCode) { + throw new Error( + `GitHub user token refresh failed: ${response.status}${errorCode ? ` ${errorCode}` : ""}`, + ); + } + try { + return parseOAuthTokenResponse(responseData, input.requestedScope); + } catch (error) { + if ( + error instanceof Error && + error.message === "OAuth token response missing access_token" + ) { + throw new GitHubUserRefreshRejectedError(error.message); + } + throw error; + } +} + +function leaseExpiry(expiresAt?: number): number { + return expiresAt + ? Math.min(expiresAt, Date.now() + MAX_LEASE_MS) + : Date.now() + MAX_LEASE_MS; +} + +function isGitSmartHttpDomain(domain: string): boolean { + return domain.toLowerCase() === "github.com"; +} + +function authorizationFor(domain: string, token: string): string { + if (isGitSmartHttpDomain(domain)) { + return `Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; + } + return `Bearer ${token}`; +} + +function createCredentialLease( + input: CredentialLeaseInput, +): PluginCredentialResult { + return { + type: "lease", + lease: { + ...(input.account ? { account: input.account } : {}), + ...(input.authorization ? { authorization: input.authorization } : {}), + expiresAt: new Date(input.expiresAtMs).toISOString(), + headerTransforms: ( + input.domains ?? + (input.authorization + ? GITHUB_ASSET_UPLOAD_CREDENTIAL_DOMAINS + : GITHUB_CREDENTIAL_DOMAINS) + ).map((domain) => ({ + domain, + headers: { + Authorization: authorizationFor(domain, input.token), + }, + })), + }, + }; +} + +function githubUserAuthorization( + scope?: string, +): CredentialLeaseInput["authorization"] { + return { + type: "oauth", + provider: "github", + ...(scope ? { scope } : {}), + }; +} + +function credentialNeeded( + message: string, + scope?: string, + allowAuthorization = true, +): PluginCredentialResult { + return { + type: "needed", + message, + ...(allowAuthorization + ? { authorization: githubUserAuthorization(scope) } + : {}), + }; +} + +/** Return a credential result for invalid GitHub App configuration. */ +export function credentialUnavailable(message: string): PluginCredentialResult { + return { + type: "unavailable", + message, + }; +} + +function parseInstallationTokenResponse(data: unknown): { + expiresAtMs: number; + token: string; +} { + if (!isRecord(data)) { + throw new Error("GitHub installation token response is invalid"); + } + const token = data.token; + if (typeof token !== "string" || !token.trim()) { + throw new Error("GitHub installation token response missing token"); + } + const expiresAt = data.expires_at; + const expiresAtMs = + typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN; + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) { + throw new Error( + "GitHub installation token response returned invalid expires_at", + ); + } + return { token, expiresAtMs }; +} + +function readInstallationPermissions( + installation: unknown, +): Record { + if (!isRecord(installation) || !isRecord(installation.permissions)) { + throw new Error("GitHub installation response missing permissions"); + } + return readGrantPermissions(installation.permissions); +} + +function decodeGitHubPathSegment(value: string): string | undefined { + try { + const decoded = decodeURIComponent(value).trim(); + return decoded && !decoded.includes("/") ? decoded : undefined; + } catch { + return undefined; + } +} + +/** Parse a GitHub repository from an API or Git URL. */ +export function githubRepositoryFromUrl( + upstreamUrl: URL, +): GitHubRepository | undefined { + const segments = upstreamUrl.pathname.split("/").filter(Boolean); + if (isGitHubApiUrl(upstreamUrl) && segments[0]?.toLowerCase() === "repos") { + const owner = segments[1] + ? decodeGitHubPathSegment(segments[1]) + : undefined; + const name = segments[2] ? decodeGitHubPathSegment(segments[2]) : undefined; + return owner && name ? { owner, name } : undefined; + } + if (upstreamUrl.hostname.toLowerCase() !== "github.com") { + return undefined; + } + const owner = segments[0] ? decodeGitHubPathSegment(segments[0]) : undefined; + const rawName = segments[1]?.replace(/\.git$/i, ""); + const name = rawName ? decodeGitHubPathSegment(rawName) : undefined; + return owner && name ? { owner, name } : undefined; +} + +/** Build the stable lease scope for a GitHub repository. */ +export function githubRepositoryLeaseScope( + repository: GitHubRepository, +): string { + return `repository:${repository.owner.toLowerCase()}/${repository.name.toLowerCase()}`; +} + +/** Parse the repository bound to an installation-write lease. */ +export function githubRepositoryFromLeaseScope( + leaseScope: string | undefined, +): GitHubRepository { + const match = /^repository:([^/]+)\/([^/]+)$/.exec(leaseScope ?? ""); + if (!match?.[1] || !match[2]) { + throw new GitHubPluginSetupError( + "GitHub installation write grant is missing a repository lease scope.", + ); + } + return { owner: match[1], name: match[2] }; +} + +/** Resolve the GitHub account associated with stored user tokens. */ +export async function resolveUserAccount( + tokens: PluginStoredTokens, +): Promise { + const account = await githubRequest("https://api.github.com", "/user", { + token: tokens.accessToken, + }); + if (!isRecord(account)) { + throw new Error("GitHub user response is invalid"); + } + const id = account.id; + const login = account.login; + if ( + (typeof id !== "number" && typeof id !== "string") || + typeof login !== "string" || + !login.trim() + ) { + throw new Error("GitHub user response missing id or login"); + } + const url = + typeof account.html_url === "string" ? account.html_url : undefined; + return { + id: String(id), + label: login.trim(), + ...(url ? { url } : {}), + }; +} + +async function tokensWithAccount( + tokenSlot: PluginUserTokenSlot, + stored: PluginStoredTokens, + scope?: string, +): Promise { + if (stored.account) { + return { ok: true, tokens: stored }; + } + let account; + try { + account = await resolveUserAccount(stored); + } catch (error) { + if ( + error instanceof GitHubRequestError && + (error.status === 401 || error.status === 403) + ) { + return { + ok: false, + result: credentialNeeded( + "Your GitHub authorization needs to be refreshed.", + scope, + ), + }; + } + throw error; + } + const updated = { ...stored, account }; + await tokenSlot.set(updated); + return { ok: true, tokens: updated }; +} + +function shouldRefreshUserToken( + stored: PluginStoredTokens, + now = Date.now(), +): boolean { + return ( + stored.expiresAt !== undefined && stored.expiresAt - now < REFRESH_BUFFER_MS + ); +} + +function canUseStoredUserToken(stored: PluginStoredTokens): boolean { + return ( + stored.expiresAt === undefined || + (stored.expiresAt > Date.now() && !shouldRefreshUserToken(stored)) + ); +} + +/** Re-read under the token-slot refresh gate so concurrent callers reuse the winner's rotated tokens. */ +async function refreshUserTokensWithLock( + tokenSlot: PluginUserTokenSlot, + scope: string | undefined, + options: UserCredentialOptions, +): Promise { + return await tokenSlot.withRefresh(async () => { + const latest = await tokenSlot.get(); + if (!latest) { + return { + ok: false, + result: credentialNeeded("Connect your GitHub account.", scope), + }; + } + if (!hasRequiredOAuthScope(latest.scope, scope)) { + return { + ok: false, + result: credentialNeeded( + "Your GitHub authorization needs to be refreshed.", + scope, + ), + }; + } + if (canUseStoredUserToken(latest)) { + return { ok: true, tokens: latest }; + } + + let refreshed; + try { + refreshed = await refreshUserAccessToken({ + clientIdEnv: options.clientIdEnv, + clientSecretEnv: options.clientSecretEnv, + refreshToken: latest.refreshToken, + requestedScope: latest.scope ?? scope, + }); + } catch (error) { + if (!(error instanceof GitHubUserRefreshRejectedError)) { + throw error; + } + return { + ok: false, + result: credentialNeeded( + "Your GitHub authorization has expired.", + scope, + ), + }; + } + if (!hasRequiredOAuthScope(refreshed.scope, scope)) { + return { + ok: false, + result: credentialNeeded( + "Your GitHub authorization needs to be refreshed.", + scope, + ), + }; + } + const refreshedTokens = { + ...(latest.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: latest.refreshTokenExpiresAt } + : {}), + ...refreshed, + ...(latest.account ? { account: latest.account } : {}), + }; + await tokenSlot.set(refreshedTokens); + return { ok: true, tokens: refreshedTokens }; + }); +} + +/** Issue a bounded GitHub user credential for an approved grant. */ +export async function issueUserCredential( + ctx: IssueCredentialHookContext, + options: UserCredentialOptions, +): Promise { + const scope = options.userScope; + const tokenSlot = ctx.tokens.currentUser ?? ctx.tokens.credentialSubject; + if (!tokenSlot) { + return credentialNeeded( + "GitHub write access requires a current user or delegated user credential subject.", + scope, + false, + ); + } + + const stored = await tokenSlot.get(); + if (!stored) { + return credentialNeeded( + "GitHub write access requires user authorization.", + scope, + ); + } + if (!hasRequiredOAuthScope(stored.scope, scope)) { + return credentialNeeded( + "Your GitHub authorization needs to be refreshed.", + scope, + ); + } + + const now = Date.now(); + if ( + stored.expiresAt !== undefined && + stored.expiresAt - now < REFRESH_BUFFER_MS + ) { + const refreshResult = await refreshUserTokensWithLock( + tokenSlot, + scope, + options, + ); + if (!refreshResult.ok) { + return refreshResult.result; + } + const withAccount = await tokensWithAccount( + tokenSlot, + refreshResult.tokens, + scope, + ); + if (!withAccount.ok) { + return withAccount.result; + } + return createCredentialLease({ + account: withAccount.tokens.account, + token: withAccount.tokens.accessToken, + expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt), + authorization: githubUserAuthorization(scope), + }); + } + + if (stored.expiresAt === undefined || stored.expiresAt > Date.now()) { + const withAccount = await tokensWithAccount(tokenSlot, stored, scope); + if (!withAccount.ok) { + return withAccount.result; + } + return createCredentialLease({ + account: withAccount.tokens.account, + token: withAccount.tokens.accessToken, + expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt), + authorization: githubUserAuthorization(scope), + }); + } + + return credentialNeeded("Your GitHub authorization has expired.", scope); +} + +/** Issue a bounded raw token for plugin-owned GitHub API calls. */ +export async function issueInstallationToken( + options: InstallationCredentialOptions, +): Promise<{ expiresAtMs: number; token: string }> { + const appId = requireEnv(options.appIdEnv); + const installationIdRaw = requireEnv(options.installationIdEnv); + const installationId = Number(installationIdRaw); + if (!Number.isSafeInteger(installationId) || installationId <= 0) { + throw new GitHubPluginSetupError(`Invalid ${options.installationIdEnv}`); + } + + const appJwt = createAppJwt(appId, options.privateKeyEnv); + const permissions = + "permissions" in options + ? options.permissions + : typeof options.loadPermissions === "function" + ? await options.loadPermissions({ appJwt, installationId }) + : undefined; + const body = { + ...(permissions ? { permissions } : {}), + ...("repositories" in options + ? { repositories: options.repositories } + : {}), + }; + const accessTokenResponse = await githubRequest( + "https://api.github.com", + `/app/installations/${installationId}/access_tokens`, + { + method: "POST", + token: appJwt, + body, + }, + ); + const parsedToken = parseInstallationTokenResponse(accessTokenResponse); + return { + expiresAtMs: Math.min(parsedToken.expiresAtMs, Date.now() + MAX_LEASE_MS), + token: parsedToken.token, + }; +} + +/** Issue a bounded GitHub App installation credential. */ +export async function issueInstallationCredential( + options: InstallationCredentialOptions, +): Promise { + const token = await issueInstallationToken(options); + return createCredentialLease({ + token: token.token, + expiresAtMs: token.expiresAtMs, + }); +} + +/** Cache the installation's read permissions for one lease period. */ +export function createPermissionCache(): LoadInstallationReadPermissions { + let cached: + | { + expiresAtMs: number; + permissions: Record; + } + | undefined; + let pending: Promise> | undefined; + return async ({ appJwt, installationId }) => { + if (cached && cached.expiresAtMs > Date.now()) { + return cached.permissions; + } + pending ??= githubRequest( + "https://api.github.com", + `/app/installations/${installationId}`, + { token: appJwt }, + ) + .then((installation) => { + const permissions = readInstallationPermissions(installation); + cached = { + expiresAtMs: Date.now() + MAX_LEASE_MS, + permissions, + }; + return permissions; + }) + .finally(() => { + pending = undefined; + }); + return await pending; + }; +} diff --git a/packages/junior-github/src/git-config.ts b/packages/junior-github/src/git-config.ts new file mode 100644 index 000000000..4acc1c5ed --- /dev/null +++ b/packages/junior-github/src/git-config.ts @@ -0,0 +1,264 @@ +/** + * Git identity and commit-hook setup for GitHub sandboxes. + */ +import type { + Actor, + SandboxPrepareHookContext, +} from "@sentry/junior-plugin-api"; + +function cleanIdentityPart(value: unknown): string { + return String(value ?? "") + .replaceAll("\n", " ") + .replaceAll("\r", " ") + .replace(/[<>]/g, "") + .trim(); +} + +function isSlackUserId(value: string): boolean { + return /^[UW][A-Z0-9]{5,}$/.test(value); +} + +function isUserActor( + actor: Actor | undefined, +): actor is Extract { + return Boolean(actor && "userId" in actor); +} + +function actorDisplayName(value: unknown, actor?: Actor): string | undefined { + const name = cleanIdentityPart(value); + if ( + !name || + name.toLowerCase() === "unknown" || + name === cleanIdentityPart(isUserActor(actor) ? actor.userId : undefined) + ) { + return undefined; + } + return isSlackUserId(name) ? undefined : name; +} + +function actorName(actor?: Actor): string | undefined { + if (!isUserActor(actor)) { + return undefined; + } + return ( + actorDisplayName(actor?.fullName, actor) || + actorDisplayName(actor?.userName, actor) || + undefined + ); +} + +function actorEmail(actor?: Actor): string | undefined { + if (!isUserActor(actor)) { + return undefined; + } + const email = cleanIdentityPart(actor?.email); + return /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(email) ? email : undefined; +} + +/** + * Stable identity key for an actor, matching the distinctness rule + * `instructionActors` uses to build `run.actors` (identity ids only, never + * display fields) so the run actor is recognized here even under a different + * display profile. + */ +function actorIdentityKey(actor: Actor): string { + if (actor.platform === "system") { + return `system ${actor.name}`; + } + return actor.platform === "slack" + ? `slack ${actor.teamId} ${actor.userId}` + : `${actor.platform} ${actor.userId}`; +} + +/** + * Build `Co-Authored-By` trailers crediting human run actors. + * + * `run.actors` is attribution only (see `multi-actor-runs.md`): a steerer + * without a resolvable name and email is silently omitted rather than + * denying the commit. Dedupes by identity and resolved email so the same human + * under two display profiles, or an actor matching the bot identity, only ever + * produces one line. + */ +export function additionalActorCoauthorTrailers(args: { + actors?: Actor[]; + botEmail: string; +}): string[] { + if (!args.actors || args.actors.length === 0) { + return []; + } + const seenEmails = new Set([args.botEmail.toLowerCase()]); + const seenActors = new Set(); + const trailers: string[] = []; + for (const candidate of args.actors) { + const actorKey = actorIdentityKey(candidate); + if (seenActors.has(actorKey)) { + continue; + } + const name = actorName(candidate); + const email = actorEmail(candidate); + if (!name || !email) { + continue; + } + const emailKey = email.toLowerCase(); + if (seenEmails.has(emailKey)) { + continue; + } + seenActors.add(actorKey); + seenEmails.add(emailKey); + trailers.push(`Co-Authored-By: ${name} <${email}>`); + } + return trailers; +} + +/** Build the hook that replaces model-supplied commit attribution. */ +export function prepareCommitMsgHook(): string { + return `#!/usr/bin/env bash +set -eu + +message_file="\${1:-}" +if [ -z "$message_file" ]; then + exit 1 +fi + +if [ -z "\${JUNIOR_GIT_AUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_AUTHOR_EMAIL:-}" ]; then + echo "Junior GitHub plugin internal error: Junior author identity was not injected by the host runtime. Do not set Git author env vars manually; report this configuration error." >&2 + exit 1 +fi + +if [ "\${GIT_AUTHOR_NAME:-}" != "$JUNIOR_GIT_AUTHOR_NAME" ] || [ "\${GIT_AUTHOR_EMAIL:-}" != "$JUNIOR_GIT_AUTHOR_EMAIL" ]; then + echo "Junior GitHub plugin internal error: Git author was not set to the Junior identity. Do not override Git author manually; report this configuration error." >&2 + exit 1 +fi + +# Git and GitHub only interpret the final contiguous paragraph as the trailer +# block, so all missing trailers are collected and appended as one block: +# human actor trailers in run order. +desired_trailers="" +add_trailer() { + desired_trailers="$desired_trailers$1"$'\\n' +} + +if [ -n "\${JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS:-}" ]; then + while IFS= read -r actor_trailer; do + if [ -n "$actor_trailer" ]; then + add_trailer "$actor_trailer" + fi + done <<< "$JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS" +fi + +# The hook owns co-author attribution. Remove every model-supplied co-author +# from the final Git trailer block before appending the host-derived actors. +tmp_file=$(mktemp) +trap 'rm -f "$tmp_file"' EXIT +awk ' + { lines[NR] = $0 } + END { + line = NR + while (line > 0 && lines[line] == "") { + line-- + } + start = line + while (start > 0 && lines[start] != "") { + start-- + } + valid = line > 0 + for (i = start + 1; valid && i <= line; i++) { + if (lines[i] !~ /^[[:alnum:]-]+: .+/) { + valid = 0 + } + } + if (!valid) { + for (i = 1; i <= NR; i++) { + print lines[i] + } + exit 0 + } + kept = 0 + for (i = start + 1; i <= line; i++) { + if (tolower(lines[i]) !~ /^co-authored-by: /) { + kept++ + } + } + before = kept > 0 ? start : start - 1 + for (i = 1; i <= before; i++) { + print lines[i] + } + for (i = start + 1; i <= line; i++) { + if (tolower(lines[i]) !~ /^co-authored-by: /) { + print lines[i] + } + } + } +' "$message_file" > "$tmp_file" +cat "$tmp_file" > "$message_file" + +final_trailer_block=$(awk ' + { lines[NR] = $0 } + END { + line = NR + while (line > 0 && lines[line] == "") { + line-- + } + if (line == 0) { + exit 0 + } + start = line + while (start > 0 && lines[start] != "") { + start-- + } + for (i = start + 1; i <= line; i++) { + if (lines[i] !~ /^[[:alnum:]-]+: .+/) { + exit 0 + } + } + for (i = start + 1; i <= line; i++) { + print lines[i] + } + } +' "$message_file") + +missing_trailers="" +collect_missing_trailer() { + if ! printf '%s\\n' "$final_trailer_block" | grep -Fqx -- "$1"; then + missing_trailers="\${missing_trailers}\${1}"$'\\n' + fi +} + +while IFS= read -r desired_trailer; do + if [ -n "$desired_trailer" ]; then + collect_missing_trailer "$desired_trailer" + fi +done <<< "$desired_trailers" + +if [ -z "$missing_trailers" ]; then + exit 0 +fi + +if [ -n "$(tail -c 1 "$message_file")" ]; then + printf '\\n' >> "$message_file" +fi + +if [ -n "$final_trailer_block" ]; then + printf '%s' "$missing_trailers" >> "$message_file" +else + printf '\\n%s' "$missing_trailers" >> "$message_file" +fi +`; +} + +/** Set one global Git option inside the prepared sandbox. */ +export async function configureGit( + ctx: SandboxPrepareHookContext, + key: string, + value: string, +): Promise { + const result = await ctx.sandbox.run({ + cmd: "git", + args: ["config", "--global", key, value], + }); + if (result.exitCode !== 0) { + throw new Error( + `Failed to configure git ${key}: ${result.stderr || result.stdout}`, + ); + } +} diff --git a/packages/junior-github/src/index.ts b/packages/junior-github/src/index.ts index 1395498e2..6c9cb37d1 100644 --- a/packages/junior-github/src/index.ts +++ b/packages/junior-github/src/index.ts @@ -1,1901 +1,2 @@ -/** - * GitHub plugin runtime boundary. - * - * This package owns GitHub App credentials, egress grant selection, sandbox git - * preparation, and runtime tools. Host egress injects provider credentials; the - * plugin builds provider requests and enforces GitHub-specific command policy. - */ -import { createPrivateKey, createSign } from "node:crypto"; -import { - defineJuniorPlugin, - EgressPolicyDenied, - type EgressHookContext, - type EgressResponseHookContext, - type IssueCredentialHookContext, - type PluginCredentialResult, - type PluginGrant, - type PluginGrantAccess, - type PluginProviderAccount, - type PluginRegistration, - type PluginStoredTokens, - type PluginUserTokenSlot, - type Actor, - type SandboxPrepareHookContext, -} from "@sentry/junior-plugin-api"; -import { - type GitHubAppPermissions, - normalizePermissions, - permissionCapabilities, - readGrantPermissions, -} from "./permissions.js"; -import { createGitHubTools } from "./tools.js"; -import { createGitHubWebhookRoute } from "./webhooks/handler.js"; -import type { GitHubDb } from "./db/database.js"; -import { buildGitHubOutcomeReport } from "./outcomes/report.js"; -import { classifyGitHubPullRequestCommitComposition } from "./pull-request-outcomes/commit-composition.js"; - +export { githubPlugin, type GitHubPluginOptions } from "./plugin.js"; export type { GitHubAppPermissionLevel } from "./permissions.js"; - -/** Configure the built-in GitHub plugin manifest and hooks. */ -export interface GitHubPluginOptions { - /** - * Extra OAuth `scope` values to request during GitHub App user authorization. - * - * GitHub App user tokens report empty scopes, so Junior treats this as a - * local reauthorization contract only. Effective access still comes from the - * app permissions, installation repositories, and requesting user's access. - */ - additionalUserScopes?: string[]; - - /** - * GitHub App permissions Junior should expose as capabilities and downscope - * to read for installation-read tokens. - * - * Keys may use GitHub permission names with underscores or hyphens. Junior - * records these as plugin capabilities. Installation-write tokens inherit - * the App installation's complete permission envelope. - * GitHub remains the source of truth for whether a permission exists. - */ - appPermissions?: GitHubAppPermissions; - - /** Environment variable containing the GitHub App id. */ - appIdEnv?: string; - - /** Environment variable containing Junior's Git committer email. */ - botEmailEnv?: string; - - /** Environment variable containing Junior's Git committer name. */ - botNameEnv?: string; - - /** Environment variable containing the GitHub App OAuth client id. */ - clientIdEnv?: string; - - /** Environment variable containing the GitHub App OAuth client secret. */ - clientSecretEnv?: string; - - /** Environment variable containing the GitHub App installation id. */ - installationIdEnv?: string; - - /** Environment variable containing the GitHub App private key. */ - privateKeyEnv?: string; -} - -type JsonRecord = Record; -type GitHubGrantName = - | "installation-read" - | "installation-write" - | "user-read" - | "user-write"; -type GitHubGrantReason = - | "github.api-read" - | "github.asset-upload" - | "github.git-read" - | "github.graphql-read" - | "github.installation-write" - | "github.user-read" - | "github.user-write"; -type GitHubGrant = PluginGrant & { - name: GitHubGrantName; - reason: GitHubGrantReason; -}; - -interface GitHubRequestParams { - body?: unknown; - method?: string; - token: string; -} - -interface OAuthTokenRequestInput { - clientId: string; - clientSecret: string; - payload: Record; -} - -interface RefreshUserAccessTokenInput { - clientIdEnv: string; - clientSecretEnv: string; - refreshToken: string; - requestedScope?: string; -} - -interface CredentialLeaseInput { - account?: PluginProviderAccount; - authorization?: { - provider: "github"; - scope?: string; - type: "oauth"; - }; - domains?: string[]; - expiresAtMs: number; - token: string; -} - -type TokenResolution = - | { ok: true; tokens: PluginStoredTokens } - | { ok: false; result: PluginCredentialResult }; - -interface UserCredentialOptions { - clientIdEnv: string; - clientSecretEnv: string; - userScope?: string; -} - -interface InstallationCredentialBaseOptions { - appIdEnv: string; - installationIdEnv: string; - privateKeyEnv: string; -} - -type InstallationCredentialOptions = InstallationCredentialBaseOptions & - ( - | { - loadPermissions?: never; - permissions?: GitHubAppPermissions; - repositories: string[]; - } - | { - loadPermissions?: never; - permissions: GitHubAppPermissions; - repositories?: never; - } - | { - loadPermissions: LoadInstallationReadPermissions; - permissions?: never; - repositories?: never; - } - ); - -type LoadInstallationReadPermissions = (input: { - appJwt: string; - installationId: number; -}) => Promise>; - -interface GitHubRepository { - name: string; - owner: string; -} - -const GITHUB_APP_ID_ENV = "GITHUB_APP_ID"; -const GITHUB_APP_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY"; -const GITHUB_INSTALLATION_ID_ENV = "GITHUB_INSTALLATION_ID"; -const GITHUB_AUTH_TOKEN_ENV = "GITHUB_TOKEN"; -const GITHUB_AUTH_TOKEN_PLACEHOLDER = "ghp_host_managed_credential"; -const MAX_LEASE_MS = 60 * 60 * 1000; -const REFRESH_BUFFER_MS = 5 * 60 * 1000; -const USER_REFRESH_TIMEOUT_MS = 20_000; -const GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES = 64 * 1024; -const HTTP_READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); -const USER_TOKEN_GRANTS = new Set(["user-read", "user-write"]); -const CREATE_TOOL_ROUTING_GUIDANCE = - "This is a Junior tool-routing denial, not a GitHub permission failure. Do not ask the user for GitHub permissions; retry with the required Junior tool."; -const USER_WRITE_REQUIREMENTS = [ - "requesting GitHub user permission to perform this operation", -]; -const GITHUB_CREDENTIAL_DOMAINS = ["api.github.com", "github.com"]; -const GITHUB_ASSET_UPLOAD_CREDENTIAL_DOMAINS = [ - ...GITHUB_CREDENTIAL_DOMAINS, - "uploads.github.com", -]; - -class GitHubUserRefreshRejectedError extends Error { - constructor(message: string) { - super(message); - this.name = "GitHubUserRefreshRejectedError"; - } -} - -class GitHubRequestError extends Error { - status: number; - - constructor(message: string, status: number) { - super(message); - this.name = "GitHubRequestError"; - this.status = status; - } -} - -class GitHubPluginSetupError extends Error { - constructor(message: string) { - super(message); - this.name = "GitHubPluginSetupError"; - } -} - -function isRecord(value: unknown): value is JsonRecord { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function readEnv(name: string): string | undefined { - const value = process.env[name]; - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - -function requireEnv(name: string): string { - const value = readEnv(name); - if (!value) { - throw new GitHubPluginSetupError(`Missing ${name}`); - } - return value; -} - -function normalizeScopeList(scopes?: string[]): string[] { - return [ - ...new Set( - (scopes ?? []) - .flatMap((scope) => String(scope).split(/\s+/)) - .map((scope) => scope.trim()) - .filter(Boolean), - ), - ].sort(); -} - -function normalizeOAuthScope(scope?: string): string | undefined { - const normalized = normalizeScopeList(scope ? [scope] : []); - return normalized.length ? normalized.join(" ") : undefined; -} - -function hasRequiredOAuthScope( - storedScope?: string, - requiredScope?: string, -): boolean { - const required = normalizeScopeList(requiredScope ? [requiredScope] : []); - if (required.length === 0) { - return true; - } - const stored = new Set(normalizeScopeList(storedScope ? [storedScope] : [])); - if (stored.size === 0) { - return false; - } - return required.every((scope) => stored.has(scope)); -} - -function cleanIdentityPart(value: unknown): string { - return String(value ?? "") - .replaceAll("\n", " ") - .replaceAll("\r", " ") - .replace(/[<>]/g, "") - .trim(); -} - -function isSlackUserId(value: string): boolean { - return /^[UW][A-Z0-9]{5,}$/.test(value); -} - -function isUserActor( - actor: Actor | undefined, -): actor is Extract { - return Boolean(actor && "userId" in actor); -} - -function actorDisplayName(value: unknown, actor?: Actor): string | undefined { - const name = cleanIdentityPart(value); - if ( - !name || - name.toLowerCase() === "unknown" || - name === cleanIdentityPart(isUserActor(actor) ? actor.userId : undefined) - ) { - return undefined; - } - return isSlackUserId(name) ? undefined : name; -} - -function actorName(actor?: Actor): string | undefined { - if (!isUserActor(actor)) { - return undefined; - } - return ( - actorDisplayName(actor?.fullName, actor) || - actorDisplayName(actor?.userName, actor) || - undefined - ); -} - -function actorEmail(actor?: Actor): string | undefined { - if (!isUserActor(actor)) { - return undefined; - } - const email = cleanIdentityPart(actor?.email); - return /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(email) ? email : undefined; -} - -/** - * Stable identity key for an actor, matching the distinctness rule - * `instructionActors` uses to build `run.actors` (identity ids only, never - * display fields) so the run actor is recognized here even under a different - * display profile. - */ -function actorIdentityKey(actor: Actor): string { - if (actor.platform === "system") { - return `system ${actor.name}`; - } - return actor.platform === "slack" - ? `slack ${actor.teamId} ${actor.userId}` - : `${actor.platform} ${actor.userId}`; -} - -/** - * Build `Co-Authored-By` trailers crediting human run actors. - * - * `run.actors` is attribution only (see `multi-actor-runs.md`): a steerer - * without a resolvable name and email is silently omitted rather than - * denying the commit. Dedupes by identity and resolved email so the same human - * under two display profiles, or an actor matching the bot identity, only ever - * produces one line. - */ -function additionalActorCoauthorTrailers(args: { - actors?: Actor[]; - botEmail: string; -}): string[] { - if (!args.actors || args.actors.length === 0) { - return []; - } - const seenEmails = new Set([args.botEmail.toLowerCase()]); - const seenActors = new Set(); - const trailers: string[] = []; - for (const candidate of args.actors) { - const actorKey = actorIdentityKey(candidate); - if (seenActors.has(actorKey)) { - continue; - } - const name = actorName(candidate); - const email = actorEmail(candidate); - if (!name || !email) { - continue; - } - const emailKey = email.toLowerCase(); - if (seenEmails.has(emailKey)) { - continue; - } - seenActors.add(actorKey); - seenEmails.add(emailKey); - trailers.push(`Co-Authored-By: ${name} <${email}>`); - } - return trailers; -} - -function prepareCommitMsgHook(): string { - return `#!/usr/bin/env bash -set -eu - -message_file="\${1:-}" -if [ -z "$message_file" ]; then - exit 1 -fi - -if [ -z "\${JUNIOR_GIT_AUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_AUTHOR_EMAIL:-}" ]; then - echo "Junior GitHub plugin internal error: Junior author identity was not injected by the host runtime. Do not set Git author env vars manually; report this configuration error." >&2 - exit 1 -fi - -if [ "\${GIT_AUTHOR_NAME:-}" != "$JUNIOR_GIT_AUTHOR_NAME" ] || [ "\${GIT_AUTHOR_EMAIL:-}" != "$JUNIOR_GIT_AUTHOR_EMAIL" ]; then - echo "Junior GitHub plugin internal error: Git author was not set to the Junior identity. Do not override Git author manually; report this configuration error." >&2 - exit 1 -fi - -# Git and GitHub only interpret the final contiguous paragraph as the trailer -# block, so all missing trailers are collected and appended as one block: -# human actor trailers in run order. -desired_trailers="" -add_trailer() { - desired_trailers="$desired_trailers$1"$'\\n' -} - -if [ -n "\${JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS:-}" ]; then - while IFS= read -r actor_trailer; do - if [ -n "$actor_trailer" ]; then - add_trailer "$actor_trailer" - fi - done <<< "$JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS" -fi - -# The hook owns co-author attribution. Remove every model-supplied co-author -# from the final Git trailer block before appending the host-derived actors. -tmp_file=$(mktemp) -trap 'rm -f "$tmp_file"' EXIT -awk ' - { lines[NR] = $0 } - END { - line = NR - while (line > 0 && lines[line] == "") { - line-- - } - start = line - while (start > 0 && lines[start] != "") { - start-- - } - valid = line > 0 - for (i = start + 1; valid && i <= line; i++) { - if (lines[i] !~ /^[[:alnum:]-]+: .+/) { - valid = 0 - } - } - if (!valid) { - for (i = 1; i <= NR; i++) { - print lines[i] - } - exit 0 - } - kept = 0 - for (i = start + 1; i <= line; i++) { - if (tolower(lines[i]) !~ /^co-authored-by: /) { - kept++ - } - } - before = kept > 0 ? start : start - 1 - for (i = 1; i <= before; i++) { - print lines[i] - } - for (i = start + 1; i <= line; i++) { - if (tolower(lines[i]) !~ /^co-authored-by: /) { - print lines[i] - } - } - } -' "$message_file" > "$tmp_file" -cat "$tmp_file" > "$message_file" - -final_trailer_block=$(awk ' - { lines[NR] = $0 } - END { - line = NR - while (line > 0 && lines[line] == "") { - line-- - } - if (line == 0) { - exit 0 - } - start = line - while (start > 0 && lines[start] != "") { - start-- - } - for (i = start + 1; i <= line; i++) { - if (lines[i] !~ /^[[:alnum:]-]+: .+/) { - exit 0 - } - } - for (i = start + 1; i <= line; i++) { - print lines[i] - } - } -' "$message_file") - -missing_trailers="" -collect_missing_trailer() { - if ! printf '%s\\n' "$final_trailer_block" | grep -Fqx -- "$1"; then - missing_trailers="\${missing_trailers}\${1}"$'\\n' - fi -} - -while IFS= read -r desired_trailer; do - if [ -n "$desired_trailer" ]; then - collect_missing_trailer "$desired_trailer" - fi -done <<< "$desired_trailers" - -if [ -z "$missing_trailers" ]; then - exit 0 -fi - -if [ -n "$(tail -c 1 "$message_file")" ]; then - printf '\\n' >> "$message_file" -fi - -if [ -n "$final_trailer_block" ]; then - printf '%s' "$missing_trailers" >> "$message_file" -else - printf '\\n%s' "$missing_trailers" >> "$message_file" -fi -`; -} - -async function configureGit( - ctx: SandboxPrepareHookContext, - key: string, - value: string, -): Promise { - const result = await ctx.sandbox.run({ - cmd: "git", - args: ["config", "--global", key, value], - }); - if (result.exitCode !== 0) { - throw new Error( - `Failed to configure git ${key}: ${result.stderr || result.stdout}`, - ); - } -} - -function base64Url(input: string): string { - return Buffer.from(input) - .toString("base64") - .replace(/=/g, "") - .replace(/\+/g, "-") - .replace(/\//g, "_"); -} - -function getPrivateKey(envName: string) { - const raw = requireEnv(envName); - let key; - try { - key = createPrivateKey({ key: raw, format: "pem" }); - } catch { - throw new GitHubPluginSetupError( - `Invalid ${envName}: expected a PEM-encoded RSA private key`, - ); - } - - if (key.asymmetricKeyType !== "rsa") { - throw new GitHubPluginSetupError( - `Invalid ${envName}: GitHub App signing requires an RSA private key`, - ); - } - return key; -} - -function createAppJwt(appId: string, privateKeyEnv: string): string { - const now = Math.floor(Date.now() / 1000); - const header = { alg: "RS256", typ: "JWT" }; - const payload = { iat: now - 60, exp: now + 9 * 60, iss: appId }; - const encodedHeader = base64Url(JSON.stringify(header)); - const encodedPayload = base64Url(JSON.stringify(payload)); - const signingInput = `${encodedHeader}.${encodedPayload}`; - const signer = createSign("RSA-SHA256"); - signer.update(signingInput); - signer.end(); - const signature = signer - .sign(getPrivateKey(privateKeyEnv)) - .toString("base64") - .replace(/=/g, "") - .replace(/\+/g, "-") - .replace(/\//g, "_"); - return `${signingInput}.${signature}`; -} - -async function githubRequest( - apiBase: string, - path: string, - params: GitHubRequestParams, -): Promise { - const response = await fetch(`${apiBase}${path}`, { - method: params.method ?? "GET", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${params.token}`, - "X-GitHub-Api-Version": "2022-11-28", - ...(params.body ? { "Content-Type": "application/json" } : {}), - }, - ...(params.body ? { body: JSON.stringify(params.body) } : {}), - }); - - const text = await response.text(); - let parsed: unknown; - if (text) { - try { - parsed = JSON.parse(text); - } catch { - parsed = undefined; - } - } - - if (!response.ok) { - const message = - isRecord(parsed) && typeof parsed.message === "string" - ? parsed.message - : `GitHub API error ${response.status}`; - throw new GitHubRequestError(message, response.status); - } - return parsed; -} - -function buildOAuthTokenRequest(input: OAuthTokenRequestInput): { - body: URLSearchParams; - headers: Record; -} { - const payload = { - ...input.payload, - client_id: input.clientId, - client_secret: input.clientSecret, - }; - return { - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams(payload), - }; -} - -function parseOAuthResponseJson(responseText: string): unknown { - if (!responseText.trim()) { - return undefined; - } - try { - return JSON.parse(responseText); - } catch { - return undefined; - } -} - -function oauthErrorCode(data: unknown): string | undefined { - return isRecord(data) && typeof data.error === "string" - ? data.error - : undefined; -} - -function isRejectedRefreshError(errorCode: string | undefined): boolean { - return errorCode === "bad_refresh_token" || errorCode === "invalid_grant"; -} - -function parseOAuthTokenResponse( - data: unknown, - requestedScope?: string, -): PluginStoredTokens { - if (!isRecord(data)) { - throw new Error("OAuth token response is invalid"); - } - if (typeof data.access_token !== "string" || !data.access_token.trim()) { - throw new Error("OAuth token response missing access_token"); - } - if (typeof data.refresh_token !== "string" || !data.refresh_token.trim()) { - throw new Error("OAuth token response missing refresh_token"); - } - let scope = normalizeOAuthScope(requestedScope); - if (data.scope !== undefined) { - if (typeof data.scope !== "string") { - throw new Error("OAuth token response returned invalid scope"); - } - scope = normalizeOAuthScope(data.scope) ?? scope; - } - const result: PluginStoredTokens = { - accessToken: data.access_token, - refreshToken: data.refresh_token, - ...(scope ? { scope } : {}), - }; - if (data.expires_in !== undefined) { - if ( - typeof data.expires_in !== "number" || - !Number.isFinite(data.expires_in) || - data.expires_in <= 0 - ) { - throw new Error("OAuth token response returned invalid expires_in"); - } - result.expiresAt = Date.now() + data.expires_in * 1000; - } - if (data.refresh_token_expires_in !== undefined) { - if ( - typeof data.refresh_token_expires_in !== "number" || - !Number.isFinite(data.refresh_token_expires_in) || - data.refresh_token_expires_in <= 0 - ) { - throw new Error( - "OAuth token response returned invalid refresh_token_expires_in", - ); - } - result.refreshTokenExpiresAt = - Date.now() + data.refresh_token_expires_in * 1000; - } - return result; -} - -async function refreshUserAccessToken( - input: RefreshUserAccessTokenInput, -): Promise { - const clientId = requireEnv(input.clientIdEnv); - const clientSecret = requireEnv(input.clientSecretEnv); - const request = buildOAuthTokenRequest({ - clientId, - clientSecret, - payload: { - grant_type: "refresh_token", - refresh_token: input.refreshToken, - }, - }); - const response = await fetch("https://github.com/login/oauth/access_token", { - method: "POST", - headers: request.headers, - body: request.body, - signal: AbortSignal.timeout(USER_REFRESH_TIMEOUT_MS), - }); - const responseText = await response.text(); - const responseData = parseOAuthResponseJson(responseText); - const errorCode = oauthErrorCode(responseData); - if (isRejectedRefreshError(errorCode)) { - throw new GitHubUserRefreshRejectedError( - `GitHub user token refresh rejected: ${errorCode}`, - ); - } - if (!response.ok || errorCode) { - throw new Error( - `GitHub user token refresh failed: ${response.status}${errorCode ? ` ${errorCode}` : ""}`, - ); - } - try { - return parseOAuthTokenResponse(responseData, input.requestedScope); - } catch (error) { - if ( - error instanceof Error && - error.message === "OAuth token response missing access_token" - ) { - throw new GitHubUserRefreshRejectedError(error.message); - } - throw error; - } -} - -function leaseExpiry(expiresAt?: number): number { - return expiresAt - ? Math.min(expiresAt, Date.now() + MAX_LEASE_MS) - : Date.now() + MAX_LEASE_MS; -} - -function isGitSmartHttpDomain(domain: string): boolean { - return domain.toLowerCase() === "github.com"; -} - -function authorizationFor(domain: string, token: string): string { - if (isGitSmartHttpDomain(domain)) { - return `Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; - } - return `Bearer ${token}`; -} - -function createCredentialLease( - input: CredentialLeaseInput, -): PluginCredentialResult { - return { - type: "lease", - lease: { - ...(input.account ? { account: input.account } : {}), - ...(input.authorization ? { authorization: input.authorization } : {}), - expiresAt: new Date(input.expiresAtMs).toISOString(), - headerTransforms: ( - input.domains ?? - (input.authorization - ? GITHUB_ASSET_UPLOAD_CREDENTIAL_DOMAINS - : GITHUB_CREDENTIAL_DOMAINS) - ).map((domain) => ({ - domain, - headers: { - Authorization: authorizationFor(domain, input.token), - }, - })), - }, - }; -} - -function githubUserAuthorization( - scope?: string, -): CredentialLeaseInput["authorization"] { - return { - type: "oauth", - provider: "github", - ...(scope ? { scope } : {}), - }; -} - -function credentialNeeded( - message: string, - scope?: string, - allowAuthorization = true, -): PluginCredentialResult { - return { - type: "needed", - message, - ...(allowAuthorization - ? { authorization: githubUserAuthorization(scope) } - : {}), - }; -} - -function credentialUnavailable(message: string): PluginCredentialResult { - return { - type: "unavailable", - message, - }; -} - -function parseInstallationTokenResponse(data: unknown): { - expiresAtMs: number; - token: string; -} { - if (!isRecord(data)) { - throw new Error("GitHub installation token response is invalid"); - } - const token = data.token; - if (typeof token !== "string" || !token.trim()) { - throw new Error("GitHub installation token response missing token"); - } - const expiresAt = data.expires_at; - const expiresAtMs = - typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN; - if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) { - throw new Error( - "GitHub installation token response returned invalid expires_at", - ); - } - return { token, expiresAtMs }; -} - -function readInstallationPermissions( - installation: unknown, -): Record { - if (!isRecord(installation) || !isRecord(installation.permissions)) { - throw new Error("GitHub installation response missing permissions"); - } - return readGrantPermissions(installation.permissions); -} - -function decodeGitHubPathSegment(value: string): string | undefined { - try { - const decoded = decodeURIComponent(value).trim(); - return decoded && !decoded.includes("/") ? decoded : undefined; - } catch { - return undefined; - } -} - -function githubRepositoryFromUrl( - upstreamUrl: URL, -): GitHubRepository | undefined { - const segments = upstreamUrl.pathname.split("/").filter(Boolean); - if (isGitHubApiUrl(upstreamUrl) && segments[0]?.toLowerCase() === "repos") { - const owner = segments[1] - ? decodeGitHubPathSegment(segments[1]) - : undefined; - const name = segments[2] ? decodeGitHubPathSegment(segments[2]) : undefined; - return owner && name ? { owner, name } : undefined; - } - if (upstreamUrl.hostname.toLowerCase() !== "github.com") { - return undefined; - } - const owner = segments[0] ? decodeGitHubPathSegment(segments[0]) : undefined; - const rawName = segments[1]?.replace(/\.git$/i, ""); - const name = rawName ? decodeGitHubPathSegment(rawName) : undefined; - return owner && name ? { owner, name } : undefined; -} - -function githubRepositoryLeaseScope(repository: GitHubRepository): string { - return `repository:${repository.owner.toLowerCase()}/${repository.name.toLowerCase()}`; -} - -function githubRepositoryFromLeaseScope( - leaseScope: string | undefined, -): GitHubRepository { - const match = /^repository:([^/]+)\/([^/]+)$/.exec(leaseScope ?? ""); - if (!match?.[1] || !match[2]) { - throw new GitHubPluginSetupError( - "GitHub installation write grant is missing a repository lease scope.", - ); - } - return { owner: match[1], name: match[2] }; -} - -async function resolveUserAccount( - tokens: PluginStoredTokens, -): Promise { - const account = await githubRequest("https://api.github.com", "/user", { - token: tokens.accessToken, - }); - if (!isRecord(account)) { - throw new Error("GitHub user response is invalid"); - } - const id = account.id; - const login = account.login; - if ( - (typeof id !== "number" && typeof id !== "string") || - typeof login !== "string" || - !login.trim() - ) { - throw new Error("GitHub user response missing id or login"); - } - const url = - typeof account.html_url === "string" ? account.html_url : undefined; - return { - id: String(id), - label: login.trim(), - ...(url ? { url } : {}), - }; -} - -async function tokensWithAccount( - tokenSlot: PluginUserTokenSlot, - stored: PluginStoredTokens, - scope?: string, -): Promise { - if (stored.account) { - return { ok: true, tokens: stored }; - } - let account; - try { - account = await resolveUserAccount(stored); - } catch (error) { - if ( - error instanceof GitHubRequestError && - (error.status === 401 || error.status === 403) - ) { - return { - ok: false, - result: credentialNeeded( - "Your GitHub authorization needs to be refreshed.", - scope, - ), - }; - } - throw error; - } - const updated = { ...stored, account }; - await tokenSlot.set(updated); - return { ok: true, tokens: updated }; -} - -function shouldRefreshUserToken( - stored: PluginStoredTokens, - now = Date.now(), -): boolean { - return ( - stored.expiresAt !== undefined && stored.expiresAt - now < REFRESH_BUFFER_MS - ); -} - -function canUseStoredUserToken(stored: PluginStoredTokens): boolean { - return ( - stored.expiresAt === undefined || - (stored.expiresAt > Date.now() && !shouldRefreshUserToken(stored)) - ); -} - -/** Re-read under the token-slot refresh gate so concurrent callers reuse the winner's rotated tokens. */ -async function refreshUserTokensWithLock( - tokenSlot: PluginUserTokenSlot, - scope: string | undefined, - options: UserCredentialOptions, -): Promise { - return await tokenSlot.withRefresh(async () => { - const latest = await tokenSlot.get(); - if (!latest) { - return { - ok: false, - result: credentialNeeded("Connect your GitHub account.", scope), - }; - } - if (!hasRequiredOAuthScope(latest.scope, scope)) { - return { - ok: false, - result: credentialNeeded( - "Your GitHub authorization needs to be refreshed.", - scope, - ), - }; - } - if (canUseStoredUserToken(latest)) { - return { ok: true, tokens: latest }; - } - - let refreshed; - try { - refreshed = await refreshUserAccessToken({ - clientIdEnv: options.clientIdEnv, - clientSecretEnv: options.clientSecretEnv, - refreshToken: latest.refreshToken, - requestedScope: latest.scope ?? scope, - }); - } catch (error) { - if (!(error instanceof GitHubUserRefreshRejectedError)) { - throw error; - } - return { - ok: false, - result: credentialNeeded( - "Your GitHub authorization has expired.", - scope, - ), - }; - } - if (!hasRequiredOAuthScope(refreshed.scope, scope)) { - return { - ok: false, - result: credentialNeeded( - "Your GitHub authorization needs to be refreshed.", - scope, - ), - }; - } - const refreshedTokens = { - ...(latest.refreshTokenExpiresAt - ? { refreshTokenExpiresAt: latest.refreshTokenExpiresAt } - : {}), - ...refreshed, - ...(latest.account ? { account: latest.account } : {}), - }; - await tokenSlot.set(refreshedTokens); - return { ok: true, tokens: refreshedTokens }; - }); -} - -async function issueUserCredential( - ctx: IssueCredentialHookContext, - options: UserCredentialOptions, -): Promise { - const scope = options.userScope; - const tokenSlot = ctx.tokens.currentUser ?? ctx.tokens.credentialSubject; - if (!tokenSlot) { - return credentialNeeded( - "GitHub write access requires a current user or delegated user credential subject.", - scope, - false, - ); - } - - const stored = await tokenSlot.get(); - if (!stored) { - return credentialNeeded( - "GitHub write access requires user authorization.", - scope, - ); - } - if (!hasRequiredOAuthScope(stored.scope, scope)) { - return credentialNeeded( - "Your GitHub authorization needs to be refreshed.", - scope, - ); - } - - const now = Date.now(); - if ( - stored.expiresAt !== undefined && - stored.expiresAt - now < REFRESH_BUFFER_MS - ) { - const refreshResult = await refreshUserTokensWithLock( - tokenSlot, - scope, - options, - ); - if (!refreshResult.ok) { - return refreshResult.result; - } - const withAccount = await tokensWithAccount( - tokenSlot, - refreshResult.tokens, - scope, - ); - if (!withAccount.ok) { - return withAccount.result; - } - return createCredentialLease({ - account: withAccount.tokens.account, - token: withAccount.tokens.accessToken, - expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt), - authorization: githubUserAuthorization(scope), - }); - } - - if (stored.expiresAt === undefined || stored.expiresAt > Date.now()) { - const withAccount = await tokensWithAccount(tokenSlot, stored, scope); - if (!withAccount.ok) { - return withAccount.result; - } - return createCredentialLease({ - account: withAccount.tokens.account, - token: withAccount.tokens.accessToken, - expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt), - authorization: githubUserAuthorization(scope), - }); - } - - return credentialNeeded("Your GitHub authorization has expired.", scope); -} - -/** Issue a bounded raw token for plugin-owned GitHub API calls. */ -async function issueInstallationToken( - options: InstallationCredentialOptions, -): Promise<{ expiresAtMs: number; token: string }> { - const appId = requireEnv(options.appIdEnv); - const installationIdRaw = requireEnv(options.installationIdEnv); - const installationId = Number(installationIdRaw); - if (!Number.isSafeInteger(installationId) || installationId <= 0) { - throw new GitHubPluginSetupError(`Invalid ${options.installationIdEnv}`); - } - - const appJwt = createAppJwt(appId, options.privateKeyEnv); - const permissions = - "permissions" in options - ? options.permissions - : typeof options.loadPermissions === "function" - ? await options.loadPermissions({ appJwt, installationId }) - : undefined; - const body = { - ...(permissions ? { permissions } : {}), - ...("repositories" in options - ? { repositories: options.repositories } - : {}), - }; - const accessTokenResponse = await githubRequest( - "https://api.github.com", - `/app/installations/${installationId}/access_tokens`, - { - method: "POST", - token: appJwt, - body, - }, - ); - const parsedToken = parseInstallationTokenResponse(accessTokenResponse); - return { - expiresAtMs: Math.min(parsedToken.expiresAtMs, Date.now() + MAX_LEASE_MS), - token: parsedToken.token, - }; -} - -async function issueInstallationCredential( - options: InstallationCredentialOptions, -): Promise { - const token = await issueInstallationToken(options); - return createCredentialLease({ - token: token.token, - expiresAtMs: token.expiresAtMs, - }); -} - -function createPermissionCache(): LoadInstallationReadPermissions { - let cached: - | { - expiresAtMs: number; - permissions: Record; - } - | undefined; - let pending: Promise> | undefined; - return async ({ appJwt, installationId }) => { - if (cached && cached.expiresAtMs > Date.now()) { - return cached.permissions; - } - pending ??= githubRequest( - "https://api.github.com", - `/app/installations/${installationId}`, - { token: appJwt }, - ) - .then((installation) => { - const permissions = readInstallationPermissions(installation); - cached = { - expiresAtMs: Date.now() + MAX_LEASE_MS, - permissions, - }; - return permissions; - }) - .finally(() => { - pending = undefined; - }); - return await pending; - }; -} - -function githubSmartHttpAccess( - upstreamUrl: URL, -): PluginGrantAccess | undefined { - const pathname = upstreamUrl.pathname.toLowerCase(); - const service = upstreamUrl.searchParams.get("service")?.toLowerCase(); - const isSmartHttpPath = - pathname.endsWith("/info/refs") || - pathname.endsWith("/git-receive-pack") || - pathname.endsWith("/git-upload-pack"); - if (!isSmartHttpPath) { - return undefined; - } - if ( - pathname.endsWith("/git-receive-pack") || - service === "git-receive-pack" - ) { - return "write"; - } - if (pathname.endsWith("/git-upload-pack") || service === "git-upload-pack") { - return "read"; - } - return undefined; -} - -function isGitHubGraphqlUrl(upstreamUrl: URL): boolean { - return ( - upstreamUrl.hostname.toLowerCase() === "api.github.com" && - upstreamUrl.pathname.toLowerCase().endsWith("/graphql") - ); -} - -function isGitHubApiUrl(upstreamUrl: URL): boolean { - return upstreamUrl.hostname.toLowerCase() === "api.github.com"; -} - -function isGitHubAssetUploadRequest(method: string, upstreamUrl: URL): boolean { - return ( - method === "POST" && - upstreamUrl.hostname.toLowerCase() === "uploads.github.com" && - upstreamUrl.pathname === "/user-attachments/assets" - ); -} - -function githubUserReadReason( - method: string, - upstreamUrl: URL, -): GitHubGrantReason | undefined { - if (method !== "GET" || !isGitHubApiUrl(upstreamUrl)) { - return undefined; - } - return upstreamUrl.pathname.toLowerCase() === "/user" - ? "github.user-read" - : undefined; -} - -function parseGitHubGraphqlOperation( - bodyText: string | undefined, -): PluginGrantAccess | undefined { - const parsed = parseGitHubGraphqlRequest(bodyText); - if (!parsed) { - return undefined; - } - const { normalized, operationName } = parsed; - if (operationName) { - const namedOperation = normalized.match( - new RegExp( - `\\b(query|mutation|subscription)\\s+${escapeRegExp(operationName)}\\b`, - ), - )?.[1]; - return namedOperation ? graphqlOperationAccess(namedOperation) : undefined; - } - const operation = normalized.match(/\b(query|mutation|subscription)\b/)?.[1]; - const operationAccess = graphqlOperationAccess(operation); - if (operationAccess) { - return operationAccess; - } - if (normalized.startsWith("{")) { - return "read"; - } - return undefined; -} - -function parseGitHubGraphqlRequest( - bodyText: string | undefined, -): { normalized: string; operationName?: string } | undefined { - if (typeof bodyText !== "string" || bodyText.trim().length === 0) { - return undefined; - } - let parsed; - try { - parsed = JSON.parse(bodyText); - } catch { - return undefined; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return undefined; - } - const query = parsed.query; - if (typeof query !== "string") { - return undefined; - } - const operationName = - typeof parsed.operationName === "string" - ? parsed.operationName.trim() - : undefined; - const normalized = maskGraphqlStringLiterals( - query.replace(/^\s*#[^\n\r]*(?:\r?\n|$)/gm, ""), - ).trim(); - return { - normalized, - ...(operationName ? { operationName } : {}), - }; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function graphqlOperationAccess( - operation: string | undefined, -): PluginGrantAccess | undefined { - if (operation === "mutation" || operation === "subscription") { - return "write"; - } - if (operation === "query") { - return "read"; - } - return undefined; -} - -function maskGraphqlStringLiterals(query: string): string { - return query.replace(/"""[\s\S]*?"""|"(?:\\.|[^"\\])*"/g, (match) => - " ".repeat(match.length), - ); -} - -function githubGraphqlAccess( - method: string, - upstreamUrl: URL, - bodyText: string | undefined, -): PluginGrantAccess | undefined { - if (!isGitHubGraphqlUrl(upstreamUrl)) { - return undefined; - } - if (HTTP_READ_METHODS.has(method)) { - return "read"; - } - const operation = parseGitHubGraphqlOperation(bodyText); - if (operation) { - return operation; - } - // Unknown GraphQL POST bodies are classified as writes and denied by the - // caller rather than receiving an installation or user credential. - return "write"; -} - -function githubGraphqlPermissionDeniedMessage( - bodyText: string, -): string | undefined { - let parsed: unknown; - try { - parsed = JSON.parse(bodyText); - } catch { - return undefined; - } - if (!isRecord(parsed) || !Array.isArray(parsed.errors)) { - return undefined; - } - for (const error of parsed.errors) { - if (!isRecord(error) || typeof error.message !== "string") { - continue; - } - const message = error.message; - if ( - error.type === "NOT_FOUND" && - /\bCould not resolve to a Repository with the name\b/.test(message) - ) { - return `GitHub GraphQL could not access the repository: ${message}`; - } - if (/\bResource not accessible by integration\b/.test(message)) { - return `GitHub GraphQL denied access: ${message}`; - } - } - return undefined; -} - -function shouldInspectGitHubGraphqlResponse( - ctx: EgressResponseHookContext, -): boolean { - if ( - ctx.request.method.toUpperCase() !== "POST" || - ctx.response.status !== 200 - ) { - return false; - } - let upstreamUrl: URL; - try { - upstreamUrl = new URL(ctx.request.url); - } catch { - return false; - } - if (!isGitHubGraphqlUrl(upstreamUrl)) { - return false; - } - const contentType = ctx.response.headers.get("content-type"); - return contentType ? /\bjson\b/i.test(contentType) : false; -} - -function githubApiWriteGrantName( - method: string, - upstreamUrl: URL, -): "installation-write" | "user-write" | undefined { - const pathname = upstreamUrl.pathname.toLowerCase(); - if (!isGitHubApiUrl(upstreamUrl)) { - return undefined; - } - if ( - method === "POST" && - /^\/repos\/[^/]+\/[^/]+\/actions\/workflows\/[^/]+\/dispatches$/.test( - pathname, - ) - ) { - return "installation-write"; - } - if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/issues$/.test(pathname)) { - return "installation-write"; - } - if ( - method === "POST" && - /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/comments$/.test(pathname) - ) { - return "installation-write"; - } - if ( - method === "PATCH" && - /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+$/.test(pathname) - ) { - return "installation-write"; - } - if ( - (method === "POST" || method === "DELETE") && - /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/(labels|assignees)(?:\/[^/]+)?$/.test( - pathname, - ) - ) { - return "installation-write"; - } - if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/pulls$/.test(pathname)) { - return "installation-write"; - } - if ( - method === "PATCH" && - /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+$/.test(pathname) - ) { - return "installation-write"; - } - if ( - method === "POST" && - /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/ready_for_review$/.test(pathname) - ) { - return "installation-write"; - } - if ( - (method === "POST" || method === "DELETE") && - /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/requested_reviewers$/.test(pathname) - ) { - return "installation-write"; - } - if ( - /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/reviews(?:\/[^/]+(?:\/(events|dismissals))?)?$/.test( - pathname, - ) && - !HTTP_READ_METHODS.has(method) - ) { - return "user-write"; - } - return undefined; -} - -function isGitHubIssueCreateRestRequest( - method: string, - upstreamUrl: URL, -): boolean { - return ( - method === "POST" && - isGitHubApiUrl(upstreamUrl) && - /^\/repos\/[^/]+\/[^/]+\/issues$/.test(upstreamUrl.pathname.toLowerCase()) - ); -} - -function isGitHubPullCreateRestRequest( - method: string, - upstreamUrl: URL, -): boolean { - return ( - method === "POST" && - isGitHubApiUrl(upstreamUrl) && - /^\/repos\/[^/]+\/[^/]+\/pulls$/.test(upstreamUrl.pathname.toLowerCase()) - ); -} - -function isGitHubIssueCreateGraphqlMutation( - method: string, - upstreamUrl: URL, - bodyText: string | undefined, -): boolean { - if (method !== "POST" || !isGitHubGraphqlUrl(upstreamUrl)) { - return false; - } - const parsed = parseGitHubGraphqlRequest(bodyText); - if (!parsed) { - return false; - } - if (!/\bcreateIssue\b/.test(parsed.normalized)) { - return false; - } - if (!parsed.operationName) { - return /\bmutation\b/.test(parsed.normalized); - } - return new RegExp( - `\\bmutation\\s+${escapeRegExp(parsed.operationName)}\\b`, - ).test(parsed.normalized); -} - -function isGitHubPullCreateGraphqlMutation( - method: string, - upstreamUrl: URL, - bodyText: string | undefined, -): boolean { - if (method !== "POST" || !isGitHubGraphqlUrl(upstreamUrl)) { - return false; - } - const parsed = parseGitHubGraphqlRequest(bodyText); - if (!parsed) { - return false; - } - if (!/\bcreatePullRequest\b/.test(parsed.normalized)) { - return false; - } - if (!parsed.operationName) { - return /\bmutation\b/.test(parsed.normalized); - } - return new RegExp( - `\\bmutation\\s+${escapeRegExp(parsed.operationName)}\\b`, - ).test(parsed.normalized); -} - -function assertGitHubWriteAllowed(input: { - bodyText?: string; - method: string; - operation?: string; - upstreamUrl: URL; -}): void { - if (input.operation === "github.issue.create") { - return; - } - if (input.operation === "github.pull.create") { - return; - } - if ( - isGitHubIssueCreateRestRequest(input.method, input.upstreamUrl) || - isGitHubIssueCreateGraphqlMutation( - input.method, - input.upstreamUrl, - input.bodyText, - ) - ) { - throw new EgressPolicyDenied( - `GitHub issue creation must use the github_createIssue tool so Junior can own idempotency and the conversation footer. ${CREATE_TOOL_ROUTING_GUIDANCE}`, - ); - } - if ( - isGitHubPullCreateRestRequest(input.method, input.upstreamUrl) || - isGitHubPullCreateGraphqlMutation( - input.method, - input.upstreamUrl, - input.bodyText, - ) - ) { - throw new EgressPolicyDenied( - `GitHub pull request creation must use the github_createPullRequest tool so Junior can own idempotency and the conversation footer. ${CREATE_TOOL_ROUTING_GUIDANCE}`, - ); - } -} - -function grantForAccess( - access: PluginGrantAccess, - reason: GitHubGrantReason, - name: GitHubGrantName, - leaseScope?: string, -): GitHubGrant { - return { - name, - access, - ...(leaseScope ? { leaseScope } : {}), - reason, - ...(name === "user-write" ? { requirements: USER_WRITE_REQUIREMENTS } : {}), - }; -} - -function repositoryLeaseScope(upstreamUrl: URL): string { - const repository = githubRepositoryFromUrl(upstreamUrl); - if (!repository) { - throw new EgressPolicyDenied( - "GitHub write request does not identify a target repository.", - ); - } - return githubRepositoryLeaseScope(repository); -} - -async function githubGrantForEgress( - ctx: EgressHookContext, -): Promise { - const method = ctx.request.method.toUpperCase(); - const upstreamUrl = new URL(ctx.request.url); - assertGitHubWriteAllowed({ - ...(ctx.request.bodyText !== undefined - ? { bodyText: ctx.request.bodyText } - : {}), - method, - ...(ctx.request.operation ? { operation: ctx.request.operation } : {}), - upstreamUrl, - }); - if (isGitHubAssetUploadRequest(method, upstreamUrl)) { - return grantForAccess("write", "github.asset-upload", "user-write"); - } - - const smartHttpAccess = githubSmartHttpAccess(upstreamUrl); - if (smartHttpAccess) { - if (smartHttpAccess === "write") { - return grantForAccess( - "write", - "github.installation-write", - "installation-write", - repositoryLeaseScope(upstreamUrl), - ); - } - return grantForAccess( - smartHttpAccess, - "github.git-read", - "installation-read", - ); - } - - const userReadReason = githubUserReadReason(method, upstreamUrl); - if (userReadReason) { - return grantForAccess("read", userReadReason, "user-read"); - } - - const writeGrantName = githubApiWriteGrantName(method, upstreamUrl); - if (writeGrantName) { - return grantForAccess( - "write", - writeGrantName === "user-write" - ? "github.user-write" - : "github.installation-write", - writeGrantName, - repositoryLeaseScope(upstreamUrl), - ); - } - - const graphqlAccess = githubGraphqlAccess( - method, - upstreamUrl, - ctx.request.bodyText, - ); - if (graphqlAccess) { - if (graphqlAccess === "write") { - throw new EgressPolicyDenied( - "GitHub GraphQL mutations are not enabled for Junior credentials.", - ); - } - return grantForAccess( - graphqlAccess, - "github.graphql-read", - "installation-read", - ); - } - - const access = HTTP_READ_METHODS.has(method) ? "read" : "write"; - if (access === "write") { - throw new EgressPolicyDenied( - "GitHub write request is not an explicitly allowed Junior operation.", - ); - } - return grantForAccess(access, "github.api-read", "installation-read"); -} - -/** Register GitHub runtime hooks for repository workflows. */ -export function githubPlugin( - options: GitHubPluginOptions = {}, -): PluginRegistration { - const botNameEnv = options.botNameEnv ?? "GITHUB_APP_BOT_NAME"; - const botEmailEnv = options.botEmailEnv ?? "GITHUB_APP_BOT_EMAIL"; - const clientIdEnv = options.clientIdEnv ?? "GITHUB_APP_CLIENT_ID"; - const clientSecretEnv = options.clientSecretEnv ?? "GITHUB_APP_CLIENT_SECRET"; - const appIdEnv = options.appIdEnv ?? GITHUB_APP_ID_ENV; - const privateKeyEnv = options.privateKeyEnv ?? GITHUB_APP_PRIVATE_KEY_ENV; - const installationIdEnv = - options.installationIdEnv ?? GITHUB_INSTALLATION_ID_ENV; - const declaredAppPermissions = normalizePermissions(options.appPermissions); - const declaredReadPermissions = declaredAppPermissions - ? readGrantPermissions(declaredAppPermissions) - : undefined; - const loadReadPermissions = createPermissionCache(); - const appCapabilities = permissionCapabilities(declaredAppPermissions); - const userScopes = normalizeScopeList(options.additionalUserScopes); - const userScope = userScopes.length ? userScopes.join(" ") : undefined; - - return defineJuniorPlugin({ - packageName: "@sentry/junior-github", - manifest: { - name: "github", - displayName: "GitHub", - description: - "GitHub issue, pull request, and repository workflows via GitHub App", - ...(appCapabilities ? { capabilities: appCapabilities } : {}), - configKeys: ["org", "repo"], - domains: ["api.github.com", "github.com", "uploads.github.com"], - envVars: { - [appIdEnv]: {}, - [privateKeyEnv]: {}, - [installationIdEnv]: {}, - [clientIdEnv]: {}, - [clientSecretEnv]: {}, - [botNameEnv]: { exposeToCommandEnv: true }, - [botEmailEnv]: { exposeToCommandEnv: true }, - }, - oauth: { - clientIdEnv, - clientSecretEnv, - authorizeEndpoint: "https://github.com/login/oauth/authorize", - tokenEndpoint: "https://github.com/login/oauth/access_token", - // GitHub App user-to-server tokens always return scope: "" regardless - // of what was requested; treat empty response scope as unreported. - treatEmptyScopeAsUnreported: true, - ...(userScope ? { scope: userScope } : {}), - }, - commandEnv: { - [GITHUB_AUTH_TOKEN_ENV]: GITHUB_AUTH_TOKEN_PLACEHOLDER, - GIT_COMMITTER_NAME: `\${${botNameEnv}}`, - GIT_COMMITTER_EMAIL: `\${${botEmailEnv}}`, - }, - target: { - type: "repo", - configKey: "repo", - commandFlags: ["--repo", "-R"], - }, - runtimeDependencies: [ - { - type: "system", - package: "gh", - }, - { - type: "system", - package: "jq", - }, - ], - }, - hooks: { - routes(ctx) { - return [ - createGitHubWebhookRoute({ - botEmail: () => readEnv(botEmailEnv), - classifyPullRequestCommits: async ({ - number, - repositoryFullName, - }) => { - const [owner, name, ...extra] = repositoryFullName.split("/"); - if (!owner || !name || extra.length > 0) { - throw new Error( - "GitHub pull request commit classification received an invalid repository name", - ); - } - const token = await issueInstallationToken({ - appIdEnv, - privateKeyEnv, - installationIdEnv, - permissions: { pull_requests: "read" }, - repositories: [name], - }); - return await classifyGitHubPullRequestCommitComposition({ - botEmail: requireEnv(botEmailEnv), - loadPage: async (page, perPage) => - await githubRequest( - "https://api.github.com", - `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}/commits?per_page=${perPage}&page=${page}`, - { token: token.token }, - ), - }); - }, - db: ctx.db as GitHubDb, - log: ctx.log, - resourceEvents: ctx.resourceEvents, - webhookSecret: () => readEnv("GITHUB_WEBHOOK_SECRET"), - }), - ]; - }, - async operationalReport(ctx) { - return await buildGitHubOutcomeReport({ - db: ctx.db as GitHubDb, - nowMs: ctx.nowMs, - }); - }, - tools(ctx) { - return createGitHubTools(ctx); - }, - async sandboxPrepare(ctx) { - const hooksPath = `${ctx.sandbox.juniorRoot}/git-hooks`; - await ctx.sandbox.writeFile({ - path: `${hooksPath}/prepare-commit-msg`, - mode: 0o755, - content: prepareCommitMsgHook(), - }); - await configureGit(ctx, "core.hooksPath", hooksPath); - await configureGit(ctx, "commit.gpgsign", "false"); - await configureGit(ctx, "credential.helper", ""); - await configureGit(ctx, "http.emptyAuth", "true"); - }, - beforeToolExecute(ctx) { - if (ctx.tool.name !== "bash") { - return; - } - const botName = readEnv(botNameEnv); - const botEmail = readEnv(botEmailEnv); - if (!botName || !botEmail) { - return; - } - ctx.env.set("GIT_AUTHOR_NAME", botName); - ctx.env.set("GIT_AUTHOR_EMAIL", botEmail); - ctx.env.set("JUNIOR_GIT_AUTHOR_NAME", botName); - ctx.env.set("JUNIOR_GIT_AUTHOR_EMAIL", botEmail); - ctx.env.set("GIT_COMMITTER_NAME", botName); - ctx.env.set("GIT_COMMITTER_EMAIL", botEmail); - const actorTrailers = additionalActorCoauthorTrailers({ - actors: [...(ctx.actor ? [ctx.actor] : []), ...(ctx.actors ?? [])], - botEmail, - }); - ctx.env.set( - "JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS", - actorTrailers.join("\n"), - ); - }, - grantForEgress(ctx) { - return githubGrantForEgress(ctx); - }, - async onEgressResponse(ctx) { - if (!shouldInspectGitHubGraphqlResponse(ctx)) { - return; - } - const bodyText = await ctx.response.readText( - GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES, - ); - if (!bodyText) { - return; - } - const message = githubGraphqlPermissionDeniedMessage(bodyText); - if (message) { - ctx.permissionDenied(message); - } - }, - async resolveOAuthAccount(ctx) { - return await resolveUserAccount(ctx.tokens); - }, - async issueCredential(ctx) { - try { - if (ctx.grant.name === "installation-read") { - return await issueInstallationCredential({ - appIdEnv, - privateKeyEnv, - installationIdEnv, - ...(declaredReadPermissions - ? { permissions: declaredReadPermissions } - : { loadPermissions: loadReadPermissions }), - }); - } - if (ctx.grant.name === "installation-write") { - const repository = githubRepositoryFromLeaseScope( - ctx.grant.leaseScope, - ); - return await issueInstallationCredential({ - appIdEnv, - privateKeyEnv, - installationIdEnv, - // This repository-only variant cannot downscope the installed - // App envelope with an operation-specific permission body. - repositories: [repository.name], - }); - } - if (USER_TOKEN_GRANTS.has(ctx.grant.name)) { - return await issueUserCredential(ctx, { - clientIdEnv, - clientSecretEnv, - userScope, - }); - } - } catch (error) { - if (error instanceof GitHubPluginSetupError) { - return credentialUnavailable(error.message); - } - throw error; - } - throw new Error( - `GitHub plugin cannot issue unknown grant "${ctx.grant.name}".`, - ); - }, - }, - }); -} diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts new file mode 100644 index 000000000..17e30bf3b --- /dev/null +++ b/packages/junior-github/src/plugin.ts @@ -0,0 +1,813 @@ +/** + * GitHub plugin runtime boundary. + * + * This module composes GitHub hooks and owns GitHub egress policy. + */ +import { + defineJuniorPlugin, + EgressPolicyDenied, + type EgressHookContext, + type EgressResponseHookContext, + type PluginGrantAccess, + type PluginRegistration, +} from "@sentry/junior-plugin-api"; +import { + type GitHubAppPermissions, + normalizePermissions, + permissionCapabilities, + readGrantPermissions, +} from "./permissions.js"; +import { createGitHubTools } from "./tools.js"; +import { createGitHubWebhookRoute } from "./webhooks/handler.js"; +import type { GitHubDb } from "./db/database.js"; +import { buildGitHubOutcomeReport } from "./outcomes/report.js"; +import { classifyGitHubPullRequestCommitComposition } from "./pull-request-outcomes/commit-composition.js"; +import { + additionalActorCoauthorTrailers, + configureGit, + prepareCommitMsgHook, +} from "./git-config.js"; +import { + CREATE_TOOL_ROUTING_GUIDANCE, + GITHUB_APP_ID_ENV, + GITHUB_APP_PRIVATE_KEY_ENV, + GITHUB_AUTH_TOKEN_ENV, + GITHUB_AUTH_TOKEN_PLACEHOLDER, + GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES, + GITHUB_INSTALLATION_ID_ENV, + HTTP_READ_METHODS, + USER_TOKEN_GRANTS, + USER_WRITE_REQUIREMENTS, + GitHubPluginSetupError, + createPermissionCache, + credentialUnavailable, + githubRepositoryFromLeaseScope, + githubRepositoryFromUrl, + githubRepositoryLeaseScope, + githubRequest, + isRecord, + issueInstallationCredential, + issueInstallationToken, + issueUserCredential, + normalizeScopeList, + readEnv, + requireEnv, + resolveUserAccount, + type GitHubGrant, + type GitHubGrantName, + type GitHubGrantReason, +} from "./credential-support.js"; + +/** Configure the built-in GitHub plugin manifest and hooks. */ +export interface GitHubPluginOptions { + /** + * Extra OAuth `scope` values to request during GitHub App user authorization. + * + * GitHub App user tokens report empty scopes, so Junior treats this as a + * local reauthorization contract only. Effective access still comes from the + * app permissions, installation repositories, and requesting user's access. + */ + additionalUserScopes?: string[]; + + /** + * GitHub App permissions Junior should expose as capabilities and downscope + * to read for installation-read tokens. + * + * Keys may use GitHub permission names with underscores or hyphens. Junior + * records these as plugin capabilities. Installation-write tokens inherit + * the App installation's complete permission envelope. + * GitHub remains the source of truth for whether a permission exists. + */ + appPermissions?: GitHubAppPermissions; + + /** Environment variable containing the GitHub App id. */ + appIdEnv?: string; + + /** Environment variable containing Junior's Git committer email. */ + botEmailEnv?: string; + + /** Environment variable containing Junior's Git committer name. */ + botNameEnv?: string; + + /** Environment variable containing the GitHub App OAuth client id. */ + clientIdEnv?: string; + + /** Environment variable containing the GitHub App OAuth client secret. */ + clientSecretEnv?: string; + + /** Environment variable containing the GitHub App installation id. */ + installationIdEnv?: string; + + /** Environment variable containing the GitHub App private key. */ + privateKeyEnv?: string; +} + +function githubSmartHttpAccess( + upstreamUrl: URL, +): PluginGrantAccess | undefined { + const pathname = upstreamUrl.pathname.toLowerCase(); + const service = upstreamUrl.searchParams.get("service")?.toLowerCase(); + const isSmartHttpPath = + pathname.endsWith("/info/refs") || + pathname.endsWith("/git-receive-pack") || + pathname.endsWith("/git-upload-pack"); + if (!isSmartHttpPath) { + return undefined; + } + if ( + pathname.endsWith("/git-receive-pack") || + service === "git-receive-pack" + ) { + return "write"; + } + if (pathname.endsWith("/git-upload-pack") || service === "git-upload-pack") { + return "read"; + } + return undefined; +} + +function isGitHubGraphqlUrl(upstreamUrl: URL): boolean { + return ( + upstreamUrl.hostname.toLowerCase() === "api.github.com" && + upstreamUrl.pathname.toLowerCase().endsWith("/graphql") + ); +} + +function isGitHubApiUrl(upstreamUrl: URL): boolean { + return upstreamUrl.hostname.toLowerCase() === "api.github.com"; +} + +function isGitHubAssetUploadRequest(method: string, upstreamUrl: URL): boolean { + return ( + method === "POST" && + upstreamUrl.hostname.toLowerCase() === "uploads.github.com" && + upstreamUrl.pathname === "/user-attachments/assets" + ); +} + +function githubUserReadReason( + method: string, + upstreamUrl: URL, +): GitHubGrantReason | undefined { + if (method !== "GET" || !isGitHubApiUrl(upstreamUrl)) { + return undefined; + } + return upstreamUrl.pathname.toLowerCase() === "/user" + ? "github.user-read" + : undefined; +} + +function parseGitHubGraphqlOperation( + bodyText: string | undefined, +): PluginGrantAccess | undefined { + const parsed = parseGitHubGraphqlRequest(bodyText); + if (!parsed) { + return undefined; + } + const { normalized, operationName } = parsed; + if (operationName) { + const namedOperation = normalized.match( + new RegExp( + `\\b(query|mutation|subscription)\\s+${escapeRegExp(operationName)}\\b`, + ), + )?.[1]; + return namedOperation ? graphqlOperationAccess(namedOperation) : undefined; + } + const operation = normalized.match(/\b(query|mutation|subscription)\b/)?.[1]; + const operationAccess = graphqlOperationAccess(operation); + if (operationAccess) { + return operationAccess; + } + if (normalized.startsWith("{")) { + return "read"; + } + return undefined; +} + +function parseGitHubGraphqlRequest( + bodyText: string | undefined, +): { normalized: string; operationName?: string } | undefined { + if (typeof bodyText !== "string" || bodyText.trim().length === 0) { + return undefined; + } + let parsed; + try { + parsed = JSON.parse(bodyText); + } catch { + return undefined; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + const query = parsed.query; + if (typeof query !== "string") { + return undefined; + } + const operationName = + typeof parsed.operationName === "string" + ? parsed.operationName.trim() + : undefined; + const normalized = maskGraphqlStringLiterals( + query.replace(/^\s*#[^\n\r]*(?:\r?\n|$)/gm, ""), + ).trim(); + return { + normalized, + ...(operationName ? { operationName } : {}), + }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function graphqlOperationAccess( + operation: string | undefined, +): PluginGrantAccess | undefined { + if (operation === "mutation" || operation === "subscription") { + return "write"; + } + if (operation === "query") { + return "read"; + } + return undefined; +} + +function maskGraphqlStringLiterals(query: string): string { + return query.replace(/"""[\s\S]*?"""|"(?:\\.|[^"\\])*"/g, (match) => + " ".repeat(match.length), + ); +} + +function githubGraphqlAccess( + method: string, + upstreamUrl: URL, + bodyText: string | undefined, +): PluginGrantAccess | undefined { + if (!isGitHubGraphqlUrl(upstreamUrl)) { + return undefined; + } + if (HTTP_READ_METHODS.has(method)) { + return "read"; + } + const operation = parseGitHubGraphqlOperation(bodyText); + if (operation) { + return operation; + } + // Unknown GraphQL POST bodies are classified as writes and denied by the + // caller rather than receiving an installation or user credential. + return "write"; +} + +function githubGraphqlPermissionDeniedMessage( + bodyText: string, +): string | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(bodyText); + } catch { + return undefined; + } + if (!isRecord(parsed) || !Array.isArray(parsed.errors)) { + return undefined; + } + for (const error of parsed.errors) { + if (!isRecord(error) || typeof error.message !== "string") { + continue; + } + const message = error.message; + if ( + error.type === "NOT_FOUND" && + /\bCould not resolve to a Repository with the name\b/.test(message) + ) { + return `GitHub GraphQL could not access the repository: ${message}`; + } + if (/\bResource not accessible by integration\b/.test(message)) { + return `GitHub GraphQL denied access: ${message}`; + } + } + return undefined; +} + +function shouldInspectGitHubGraphqlResponse( + ctx: EgressResponseHookContext, +): boolean { + if ( + ctx.request.method.toUpperCase() !== "POST" || + ctx.response.status !== 200 + ) { + return false; + } + let upstreamUrl: URL; + try { + upstreamUrl = new URL(ctx.request.url); + } catch { + return false; + } + if (!isGitHubGraphqlUrl(upstreamUrl)) { + return false; + } + const contentType = ctx.response.headers.get("content-type"); + return contentType ? /\bjson\b/i.test(contentType) : false; +} + +function githubApiWriteGrantName( + method: string, + upstreamUrl: URL, +): "installation-write" | "user-write" | undefined { + const pathname = upstreamUrl.pathname.toLowerCase(); + if (!isGitHubApiUrl(upstreamUrl)) { + return undefined; + } + if ( + method === "POST" && + /^\/repos\/[^/]+\/[^/]+\/actions\/workflows\/[^/]+\/dispatches$/.test( + pathname, + ) + ) { + return "installation-write"; + } + if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/issues$/.test(pathname)) { + return "installation-write"; + } + if ( + method === "POST" && + /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/comments$/.test(pathname) + ) { + return "installation-write"; + } + if ( + method === "PATCH" && + /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+$/.test(pathname) + ) { + return "installation-write"; + } + if ( + (method === "POST" || method === "DELETE") && + /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/(labels|assignees)(?:\/[^/]+)?$/.test( + pathname, + ) + ) { + return "installation-write"; + } + if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/pulls$/.test(pathname)) { + return "installation-write"; + } + if ( + method === "PATCH" && + /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+$/.test(pathname) + ) { + return "installation-write"; + } + if ( + method === "POST" && + /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/ready_for_review$/.test(pathname) + ) { + return "installation-write"; + } + if ( + (method === "POST" || method === "DELETE") && + /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/requested_reviewers$/.test(pathname) + ) { + return "installation-write"; + } + if ( + /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/reviews(?:\/[^/]+(?:\/(events|dismissals))?)?$/.test( + pathname, + ) && + !HTTP_READ_METHODS.has(method) + ) { + return "user-write"; + } + return undefined; +} + +function isGitHubIssueCreateRestRequest( + method: string, + upstreamUrl: URL, +): boolean { + return ( + method === "POST" && + isGitHubApiUrl(upstreamUrl) && + /^\/repos\/[^/]+\/[^/]+\/issues$/.test(upstreamUrl.pathname.toLowerCase()) + ); +} + +function isGitHubPullCreateRestRequest( + method: string, + upstreamUrl: URL, +): boolean { + return ( + method === "POST" && + isGitHubApiUrl(upstreamUrl) && + /^\/repos\/[^/]+\/[^/]+\/pulls$/.test(upstreamUrl.pathname.toLowerCase()) + ); +} + +function isGitHubIssueCreateGraphqlMutation( + method: string, + upstreamUrl: URL, + bodyText: string | undefined, +): boolean { + if (method !== "POST" || !isGitHubGraphqlUrl(upstreamUrl)) { + return false; + } + const parsed = parseGitHubGraphqlRequest(bodyText); + if (!parsed) { + return false; + } + if (!/\bcreateIssue\b/.test(parsed.normalized)) { + return false; + } + if (!parsed.operationName) { + return /\bmutation\b/.test(parsed.normalized); + } + return new RegExp( + `\\bmutation\\s+${escapeRegExp(parsed.operationName)}\\b`, + ).test(parsed.normalized); +} + +function isGitHubPullCreateGraphqlMutation( + method: string, + upstreamUrl: URL, + bodyText: string | undefined, +): boolean { + if (method !== "POST" || !isGitHubGraphqlUrl(upstreamUrl)) { + return false; + } + const parsed = parseGitHubGraphqlRequest(bodyText); + if (!parsed) { + return false; + } + if (!/\bcreatePullRequest\b/.test(parsed.normalized)) { + return false; + } + if (!parsed.operationName) { + return /\bmutation\b/.test(parsed.normalized); + } + return new RegExp( + `\\bmutation\\s+${escapeRegExp(parsed.operationName)}\\b`, + ).test(parsed.normalized); +} + +function assertGitHubWriteAllowed(input: { + bodyText?: string; + method: string; + operation?: string; + upstreamUrl: URL; +}): void { + if (input.operation === "github.issue.create") { + return; + } + if (input.operation === "github.pull.create") { + return; + } + if ( + isGitHubIssueCreateRestRequest(input.method, input.upstreamUrl) || + isGitHubIssueCreateGraphqlMutation( + input.method, + input.upstreamUrl, + input.bodyText, + ) + ) { + throw new EgressPolicyDenied( + `GitHub issue creation must use the github_createIssue tool so Junior can own idempotency and the conversation footer. ${CREATE_TOOL_ROUTING_GUIDANCE}`, + ); + } + if ( + isGitHubPullCreateRestRequest(input.method, input.upstreamUrl) || + isGitHubPullCreateGraphqlMutation( + input.method, + input.upstreamUrl, + input.bodyText, + ) + ) { + throw new EgressPolicyDenied( + `GitHub pull request creation must use the github_createPullRequest tool so Junior can own idempotency and the conversation footer. ${CREATE_TOOL_ROUTING_GUIDANCE}`, + ); + } +} + +function grantForAccess( + access: PluginGrantAccess, + reason: GitHubGrantReason, + name: GitHubGrantName, + leaseScope?: string, +): GitHubGrant { + return { + name, + access, + ...(leaseScope ? { leaseScope } : {}), + reason, + ...(name === "user-write" ? { requirements: USER_WRITE_REQUIREMENTS } : {}), + }; +} + +function repositoryLeaseScope(upstreamUrl: URL): string { + const repository = githubRepositoryFromUrl(upstreamUrl); + if (!repository) { + throw new EgressPolicyDenied( + "GitHub write request does not identify a target repository.", + ); + } + return githubRepositoryLeaseScope(repository); +} + +async function githubGrantForEgress( + ctx: EgressHookContext, +): Promise { + const method = ctx.request.method.toUpperCase(); + const upstreamUrl = new URL(ctx.request.url); + assertGitHubWriteAllowed({ + ...(ctx.request.bodyText !== undefined + ? { bodyText: ctx.request.bodyText } + : {}), + method, + ...(ctx.request.operation ? { operation: ctx.request.operation } : {}), + upstreamUrl, + }); + if (isGitHubAssetUploadRequest(method, upstreamUrl)) { + return grantForAccess("write", "github.asset-upload", "user-write"); + } + + const smartHttpAccess = githubSmartHttpAccess(upstreamUrl); + if (smartHttpAccess) { + if (smartHttpAccess === "write") { + return grantForAccess( + "write", + "github.installation-write", + "installation-write", + repositoryLeaseScope(upstreamUrl), + ); + } + return grantForAccess( + smartHttpAccess, + "github.git-read", + "installation-read", + ); + } + + const userReadReason = githubUserReadReason(method, upstreamUrl); + if (userReadReason) { + return grantForAccess("read", userReadReason, "user-read"); + } + + const writeGrantName = githubApiWriteGrantName(method, upstreamUrl); + if (writeGrantName) { + return grantForAccess( + "write", + writeGrantName === "user-write" + ? "github.user-write" + : "github.installation-write", + writeGrantName, + repositoryLeaseScope(upstreamUrl), + ); + } + + const graphqlAccess = githubGraphqlAccess( + method, + upstreamUrl, + ctx.request.bodyText, + ); + if (graphqlAccess) { + if (graphqlAccess === "write") { + throw new EgressPolicyDenied( + "GitHub GraphQL mutations are not enabled for Junior credentials.", + ); + } + return grantForAccess( + graphqlAccess, + "github.graphql-read", + "installation-read", + ); + } + + const access = HTTP_READ_METHODS.has(method) ? "read" : "write"; + if (access === "write") { + throw new EgressPolicyDenied( + "GitHub write request is not an explicitly allowed Junior operation.", + ); + } + return grantForAccess(access, "github.api-read", "installation-read"); +} + +/** Register GitHub runtime hooks for repository workflows. */ +export function githubPlugin( + options: GitHubPluginOptions = {}, +): PluginRegistration { + const botNameEnv = options.botNameEnv ?? "GITHUB_APP_BOT_NAME"; + const botEmailEnv = options.botEmailEnv ?? "GITHUB_APP_BOT_EMAIL"; + const clientIdEnv = options.clientIdEnv ?? "GITHUB_APP_CLIENT_ID"; + const clientSecretEnv = options.clientSecretEnv ?? "GITHUB_APP_CLIENT_SECRET"; + const appIdEnv = options.appIdEnv ?? GITHUB_APP_ID_ENV; + const privateKeyEnv = options.privateKeyEnv ?? GITHUB_APP_PRIVATE_KEY_ENV; + const installationIdEnv = + options.installationIdEnv ?? GITHUB_INSTALLATION_ID_ENV; + const declaredAppPermissions = normalizePermissions(options.appPermissions); + const declaredReadPermissions = declaredAppPermissions + ? readGrantPermissions(declaredAppPermissions) + : undefined; + const loadReadPermissions = createPermissionCache(); + const appCapabilities = permissionCapabilities(declaredAppPermissions); + const userScopes = normalizeScopeList(options.additionalUserScopes); + const userScope = userScopes.length ? userScopes.join(" ") : undefined; + + return defineJuniorPlugin({ + packageName: "@sentry/junior-github", + manifest: { + name: "github", + displayName: "GitHub", + description: + "GitHub issue, pull request, and repository workflows via GitHub App", + ...(appCapabilities ? { capabilities: appCapabilities } : {}), + configKeys: ["org", "repo"], + domains: ["api.github.com", "github.com", "uploads.github.com"], + envVars: { + [appIdEnv]: {}, + [privateKeyEnv]: {}, + [installationIdEnv]: {}, + [clientIdEnv]: {}, + [clientSecretEnv]: {}, + [botNameEnv]: { exposeToCommandEnv: true }, + [botEmailEnv]: { exposeToCommandEnv: true }, + }, + oauth: { + clientIdEnv, + clientSecretEnv, + authorizeEndpoint: "https://github.com/login/oauth/authorize", + tokenEndpoint: "https://github.com/login/oauth/access_token", + // GitHub App user-to-server tokens always return scope: "" regardless + // of what was requested; treat empty response scope as unreported. + treatEmptyScopeAsUnreported: true, + ...(userScope ? { scope: userScope } : {}), + }, + commandEnv: { + [GITHUB_AUTH_TOKEN_ENV]: GITHUB_AUTH_TOKEN_PLACEHOLDER, + GIT_COMMITTER_NAME: `\${${botNameEnv}}`, + GIT_COMMITTER_EMAIL: `\${${botEmailEnv}}`, + }, + target: { + type: "repo", + configKey: "repo", + commandFlags: ["--repo", "-R"], + }, + runtimeDependencies: [ + { + type: "system", + package: "gh", + }, + { + type: "system", + package: "jq", + }, + ], + }, + hooks: { + routes(ctx) { + return [ + createGitHubWebhookRoute({ + botEmail: () => readEnv(botEmailEnv), + classifyPullRequestCommits: async ({ + number, + repositoryFullName, + }) => { + const [owner, name, ...extra] = repositoryFullName.split("/"); + if (!owner || !name || extra.length > 0) { + throw new Error( + "GitHub pull request commit classification received an invalid repository name", + ); + } + const token = await issueInstallationToken({ + appIdEnv, + privateKeyEnv, + installationIdEnv, + permissions: { pull_requests: "read" }, + repositories: [name], + }); + return await classifyGitHubPullRequestCommitComposition({ + botEmail: requireEnv(botEmailEnv), + loadPage: async (page, perPage) => + await githubRequest( + "https://api.github.com", + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}/commits?per_page=${perPage}&page=${page}`, + { token: token.token }, + ), + }); + }, + db: ctx.db as GitHubDb, + log: ctx.log, + resourceEvents: ctx.resourceEvents, + webhookSecret: () => readEnv("GITHUB_WEBHOOK_SECRET"), + }), + ]; + }, + async operationalReport(ctx) { + return await buildGitHubOutcomeReport({ + db: ctx.db as GitHubDb, + nowMs: ctx.nowMs, + }); + }, + tools(ctx) { + return createGitHubTools(ctx); + }, + async sandboxPrepare(ctx) { + const hooksPath = `${ctx.sandbox.juniorRoot}/git-hooks`; + await ctx.sandbox.writeFile({ + path: `${hooksPath}/prepare-commit-msg`, + mode: 0o755, + content: prepareCommitMsgHook(), + }); + await configureGit(ctx, "core.hooksPath", hooksPath); + await configureGit(ctx, "commit.gpgsign", "false"); + await configureGit(ctx, "credential.helper", ""); + await configureGit(ctx, "http.emptyAuth", "true"); + }, + beforeToolExecute(ctx) { + if (ctx.tool.name !== "bash") { + return; + } + const botName = readEnv(botNameEnv); + const botEmail = readEnv(botEmailEnv); + if (!botName || !botEmail) { + return; + } + ctx.env.set("GIT_AUTHOR_NAME", botName); + ctx.env.set("GIT_AUTHOR_EMAIL", botEmail); + ctx.env.set("JUNIOR_GIT_AUTHOR_NAME", botName); + ctx.env.set("JUNIOR_GIT_AUTHOR_EMAIL", botEmail); + ctx.env.set("GIT_COMMITTER_NAME", botName); + ctx.env.set("GIT_COMMITTER_EMAIL", botEmail); + const actorTrailers = additionalActorCoauthorTrailers({ + actors: [...(ctx.actor ? [ctx.actor] : []), ...(ctx.actors ?? [])], + botEmail, + }); + ctx.env.set( + "JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS", + actorTrailers.join("\n"), + ); + }, + grantForEgress(ctx) { + return githubGrantForEgress(ctx); + }, + async onEgressResponse(ctx) { + if (!shouldInspectGitHubGraphqlResponse(ctx)) { + return; + } + const bodyText = await ctx.response.readText( + GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES, + ); + if (!bodyText) { + return; + } + const message = githubGraphqlPermissionDeniedMessage(bodyText); + if (message) { + ctx.permissionDenied(message); + } + }, + async resolveOAuthAccount(ctx) { + return await resolveUserAccount(ctx.tokens); + }, + async issueCredential(ctx) { + try { + if (ctx.grant.name === "installation-read") { + return await issueInstallationCredential({ + appIdEnv, + privateKeyEnv, + installationIdEnv, + ...(declaredReadPermissions + ? { permissions: declaredReadPermissions } + : { loadPermissions: loadReadPermissions }), + }); + } + if (ctx.grant.name === "installation-write") { + const repository = githubRepositoryFromLeaseScope( + ctx.grant.leaseScope, + ); + return await issueInstallationCredential({ + appIdEnv, + privateKeyEnv, + installationIdEnv, + // This repository-only variant cannot downscope the installed + // App envelope with an operation-specific permission body. + repositories: [repository.name], + }); + } + if (USER_TOKEN_GRANTS.has(ctx.grant.name)) { + return await issueUserCredential(ctx, { + clientIdEnv, + clientSecretEnv, + userScope, + }); + } + } catch (error) { + if (error instanceof GitHubPluginSetupError) { + return credentialUnavailable(error.message); + } + throw error; + } + throw new Error( + `GitHub plugin cannot issue unknown grant "${ctx.grant.name}".`, + ); + }, + }, + }); +} diff --git a/policies/code-comments.md b/policies/code-comments.md index 73cb9f144..080a23509 100644 --- a/policies/code-comments.md +++ b/policies/code-comments.md @@ -17,6 +17,9 @@ They are not there to narrate obvious code. handlers/factories, wire or storage formats, signing, durable state changes, reply gates, or retry/resume/compaction/session policy. - Comment non-obvious invariants, tradeoffs, and policy-driven behavior. +- When an owning boundary intentionally omits behavior a maintainer would + reasonably expect, document that absence when it affects correctness, + security, privacy, delivery, or recovery. - Transitional compatibility branches and fallbacks require a removal TODO in the form `TODO(vX.Y.Z): Remove ...` where `vX.Y.Z` is the next release after the compatibility path is introduced. The comment must name the legacy state diff --git a/policies/interface-design.md b/policies/interface-design.md index 70587549e..34855b50f 100644 --- a/policies/interface-design.md +++ b/policies/interface-design.md @@ -34,7 +34,11 @@ Interfaces should expose the smallest useful capability while keeping ownership, - Keep exported interfaces role-shaped and small. A bounded legacy-import port with only `read` is clearer than a broad adapter that exposes unrelated state, Redis, or queue details. -- Prefer import-site readability over globally unique names. If a name is only clear because it includes five qualifiers, the module boundary is probably doing too little work. +- Prefer import-site readability over globally unique names. Shared exported + names should include a canonical domain term when an exact repository search + would otherwise mix unrelated concepts. Do not add qualifiers merely to + repeat the module path; if a name needs five qualifiers, the module boundary + is probably doing too little work. - When a term is overloaded in the product or platform, define it once in the owning module documentation and avoid using it for nearby concepts. - Add an interface only when it removes real coupling or represents a stable boundary. diff --git a/scripts/check-file-length.mjs b/scripts/check-file-length.mjs new file mode 100644 index 000000000..59163724e --- /dev/null +++ b/scripts/check-file-length.mjs @@ -0,0 +1,100 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { fileLengthExceptions } from "./file-length-exceptions.mjs"; + +export const MAX_CODE_FILE_LINES = 1_000; + +const CODE_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".ts", ".tsx"]); +const IGNORED_DIRECTORIES = new Set([ + ".git", + ".next", + ".turbo", + "coverage", + "dist", + "node_modules", +]); + +/** Count physical lines without treating a final newline as another line. */ +export function countFileLines(contents) { + if (contents.length === 0) { + return 0; + } + const breaks = contents.match(/\r\n|\r|\n/g)?.length ?? 0; + return breaks + (/\r\n$|\r$|\n$/.test(contents) ? 0 : 1); +} + +/** Report oversized files and stale or invalid exceptions. */ +export function checkFileLengths( + files, + exceptions, + maxLines = MAX_CODE_FILE_LINES, +) { + const errors = []; + const filesByPath = new Map(files.map((file) => [file.path, file.lines])); + + for (const [filePath, reason] of Object.entries(exceptions)) { + if (typeof reason !== "string" || !reason.trim()) { + errors.push(`${filePath}: file-length exception needs a reason`); + continue; + } + const lines = filesByPath.get(filePath); + if (lines === undefined) { + errors.push( + `${filePath}: file-length exception points to a missing file`, + ); + } else if (lines <= maxLines) { + errors.push( + `${filePath}: now has ${lines} lines; remove its file-length exception`, + ); + } + } + + for (const file of files) { + if (file.lines <= maxLines || exceptions[file.path]) { + continue; + } + errors.push( + `${file.path}: ${file.lines} lines exceeds the ${maxLines}-line limit`, + ); + } + + return errors; +} + +function collectCodeFiles(root, directory = root, files = []) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) { + continue; + } + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + collectCodeFiles(root, absolutePath, files); + continue; + } + if (!entry.isFile() || !CODE_EXTENSIONS.has(path.extname(entry.name))) { + continue; + } + files.push({ + path: path.relative(root, absolutePath).split(path.sep).join("/"), + lines: countFileLines(fs.readFileSync(absolutePath, "utf8")), + }); + } + return files; +} + +function main() { + const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); + const root = path.resolve(scriptDirectory, ".."); + const errors = checkFileLengths(collectCodeFiles(root), fileLengthExceptions); + if (errors.length === 0) { + console.log(`All code files are at most ${MAX_CODE_FILE_LINES} lines.`); + return; + } + console.error(["File length check failed:", ...errors].join("\n")); + process.exitCode = 1; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/check-file-length.test.mjs b/scripts/check-file-length.test.mjs new file mode 100644 index 000000000..ec96a36a3 --- /dev/null +++ b/scripts/check-file-length.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { checkFileLengths, countFileLines } from "./check-file-length.mjs"; + +test("counts physical lines", () => { + assert.equal(countFileLines(""), 0); + assert.equal(countFileLines("one"), 1); + assert.equal(countFileLines("one\n"), 1); + assert.equal(countFileLines("one\r\ntwo\r\n"), 2); +}); + +test("reports an oversized file without an exception", () => { + assert.deepEqual( + checkFileLengths([{ path: "src/large.ts", lines: 1_001 }], {}, 1_000), + ["src/large.ts: 1001 lines exceeds the 1000-line limit"], + ); +}); + +test("accepts an oversized file with a reason", () => { + assert.deepEqual( + checkFileLengths( + [{ path: "src/large.ts", lines: 1_001 }], + { "src/large.ts": "Split this existing parser by format." }, + 1_000, + ), + [], + ); +}); + +test("reports stale and invalid exceptions", () => { + assert.deepEqual( + checkFileLengths( + [{ path: "src/small.ts", lines: 10 }], + { + "src/missing.ts": "Existing file.", + "src/small.ts": "", + }, + 1_000, + ), + [ + "src/missing.ts: file-length exception points to a missing file", + "src/small.ts: file-length exception needs a reason", + ], + ); +}); diff --git a/scripts/file-length-exceptions.mjs b/scripts/file-length-exceptions.mjs new file mode 100644 index 000000000..c9f7fac92 --- /dev/null +++ b/scripts/file-length-exceptions.mjs @@ -0,0 +1,87 @@ +/** + * Existing files allowed to exceed 1,000 lines. + * + * Every entry needs a reason. Remove the entry when the file is split. + */ +export const fileLengthExceptions = { + "packages/junior-evals/src/behavior-harness.ts": + "Existing eval harness; split by harness concern.", + "packages/junior-evals/src/helpers.ts": + "Existing shared eval helpers; split by helper concern.", + "packages/junior-dashboard/src/mock-reporting/fixtures.ts": + "Large static reporting fixture set.", + "packages/junior-dashboard/tests/telemetry-components.test.tsx": + "Existing broad telemetry component suite; split by component.", + "packages/junior-github/tests/github-plugin.test.ts": + "Existing broad plugin suite; split with plugin modules.", + "packages/junior-github/tests/webhook-outcomes.test.ts": + "Existing broad webhook outcome suite; split by outcome.", + "packages/junior-memory/src/store.ts": + "Existing memory store; split by storage concern.", + "packages/junior-memory/tests/storage.test.ts": + "Existing broad memory storage suite; split by storage concern.", + "packages/junior-scheduler/src/store.ts": + "Existing scheduler store; split by storage concern.", + "packages/junior/src/chat/agent/index.ts": + "Existing agent run lifecycle; split only at a clear lifecycle boundary.", + "packages/junior/src/chat/logging.ts": + "Existing logging module; split console, Sentry, and model usage concerns.", + "packages/junior/src/chat/plugins/agent-hooks.ts": + "Existing plugin hook runtime; split by hook phase.", + "packages/junior/src/chat/plugins/manifest.ts": + "Existing manifest parser; split parsing from validation.", + "packages/junior/src/chat/runtime/reply-executor.ts": + "Existing reply lifecycle; split only at a clear lifecycle boundary.", + "packages/junior/src/chat/runtime/slack-runtime.ts": + "Existing Slack runtime; split by runtime phase.", + "packages/junior/src/chat/state/turn-session.ts": + "Existing turn session persistence; split by persistence concern.", + "packages/junior/src/chat/task-execution/state.ts": + "Existing mailbox and lease store; split without separating shared locks.", + "packages/junior/src/cli/check.ts": + "Existing CLI check command; split checks by subject.", + "packages/junior/tests/component/conversation-sql-store.test.ts": + "Existing broad conversation SQL suite; split by behavior.", + "packages/junior/tests/component/conversation-storage-sql.test.ts": + "Existing broad conversation storage suite; split by behavior.", + "packages/junior/tests/component/misc/sandbox-executor.test.ts": + "Existing broad sandbox executor suite; split by behavior.", + "packages/junior/tests/component/plugins/plugin-registry-packages.test.ts": + "Existing broad plugin registry suite; split by package behavior.", + "packages/junior/tests/component/runtime/agent-run-mcp-progressive-loading.test.ts": + "Existing broad MCP loading suite; split by behavior.", + "packages/junior/tests/component/services/turn-session-record.test.ts": + "Existing broad turn session record suite; split by behavior.", + "packages/junior/tests/component/task-execution/conversation-work.test.ts": + "Existing broad conversation work suite; split by behavior.", + "packages/junior/tests/component/task-execution/slack-conversation-work.test.ts": + "Existing broad Slack conversation work suite; split by behavior.", + "packages/junior/tests/integration/agent-continue-slack.test.ts": + "Existing broad Slack continuation suite; split by behavior.", + "packages/junior/tests/integration/heartbeat.test.ts": + "Existing broad heartbeat suite; split by behavior.", + "packages/junior/tests/integration/local-agent-runner.test.ts": + "Existing broad local runner suite; split by behavior.", + "packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts": + "Existing broad MCP auth suite; split by behavior.", + "packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts": + "Existing broad MCP OAuth suite; split by behavior.", + "packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts": + "Existing broad provider retry suite; split by behavior.", + "packages/junior/tests/integration/sandbox-egress-proxy.test.ts": + "Existing broad sandbox egress suite; split by behavior.", + "packages/junior/tests/integration/slack/bot-handlers.test.ts": + "Existing broad Slack handler suite; split by handler.", + "packages/junior/tests/integration/slack/bot-image-hydration.test.ts": + "Existing broad image hydration suite; split by behavior.", + "packages/junior/tests/integration/slack-schedule-tools.test.ts": + "Existing broad Slack scheduler suite; split by tool.", + "packages/junior/tests/integration/slack/subscribed-message-behavior.test.ts": + "Existing broad subscribed-message suite; split by behavior.", + "packages/junior/tests/unit/api/conversation-events.test.ts": + "Existing broad conversation events suite; split by behavior.", + "packages/junior/tests/unit/handlers/sandbox-egress-proxy.test.ts": + "Existing broad egress handler suite; split by behavior.", + "packages/junior/tests/unit/plugins/agent-hooks.test.ts": + "Existing broad agent hooks suite; split by hook.", +};