import { githubRunReplyState } from "./chat-run-publications.js"; import { stageReceiptReactionRemovals } from "./chat-receipt-reactions.js"; import { chatRunSourceMessageId } from "./chat-channels/run-origin.js"; import { boundRunIssueId } from "./run-task-binding.js"; import { GitHubPublicationLeaseLost, withGitHubPublicationLease, } from "./chat-github-publication-lease.js"; import { runtimePublicOrigin } from "./cloud-runtime-identity.js"; import { projectSafeChatPublicationText } from "./chat-publication-projection.js"; import { githubReviewCheckService } from "./chat-github-checks.js"; import { githubReviewHeading } from "./chat-channels/github/review-heading.js"; import { githubPublicationRetry, GITHUB_INLINE_RETRY_WINDOW_MS, GITHUB_INLINE_MAX_ATTEMPTS } from "./chat-channels/github/retry.js"; import { createHash } from "node:crypto"; import { githubResponseCommentService, writeGitHubResponseComment } from "./chat-github-response-comments.js"; import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm"; import { z } from "zod"; import { toolInvocations, agents, chatActions, chatConversations, chatDeliveries, chatEndpointResources, chatEndpoints, chatGitHubConfigurations, chatGitHubReviews, chatMessageLinks, companies, heartbeatRuns, issues, projects, type Db, } from "@paperclipai/db"; import { githubCommitSchema, type GitHubAutomaticEventContext, type GitHubReviewEventContext, type GitHubReviewPolicy, } from "@paperclipai/shared"; import { HttpError, conflict, forbidden, notFound } from "../errors.js"; import { githubBotRepositoryToken, githubBotRequest, } from "./chat-github-client.js"; import { githubChatPrincipalAccess } from "./chat-github-access.js"; import { effectiveGitHubReviewPolicy, githubReviewConclusion, githubReviewPathIsExcluded, githubReviewLineIsInPatch, validateGitHubReviewAssessment, } from "./chat-github-review-policy.js"; import { isIssueWithinLowTrustBoundary, resolveCoreTrustPreset, } from "./trust-preset-resolver.js"; import { githubBotToolsForSession } from "./chat-github-tools.js"; import { toolAccessPolicyService } from "./tool-access-policy.js"; import { githubAutomaticAdmission, githubPreviousAssessment, } from "./chat-github-events.js"; import { logActivity } from "./activity-log.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; type Session = { companyId: string; agentId: string | null; issueId: string | null; runId: string | null; }; type Pull = { number: number; title: string; body: string | null; state: string; draft: boolean; head: { sha: string }; base: { sha: string; ref: string }; user: { id: number; login: string; type: string }; labels: Array<{ name: string }>; }; type File = { filename: string; previous_filename?: string; patch?: string; status: string; additions: number; deletions: number; }; const hash = (value: unknown) => createHash("sha256") .update( JSON.stringify(value, (_key, entry) => entry && typeof entry === "object" && !Array.isArray(entry) ? Object.fromEntries( Object.keys(entry) .sort() .map((key) => [key, entry[key]]), ) : entry, ), ) .digest("hex"); const marker = (kind: string, key: string) => ``; const readSchema = z .object({ section: z.enum([ "metadata", "files", "comments", "reviews", "changes_since_review", ]), page: z.number().int().min(1).max(100).default(1), }) .strict(); const commentSchema = z .object({ body: z.string().trim().min(1).max(24000), idempotencyKey: z.string().min(1).max(160), }) .strict(); const formalSchema = commentSchema .extend({ event: z.enum(["APPROVE", "REQUEST_CHANGES"]), reviewedCommit: githubCommitSchema, }) .strict(); export function githubChatReviewService(db: Db, fetchImpl = fetch) { async function scope(session: Session, publishing = false) { if (!session.agentId || !session.issueId || !session.runId) throw forbidden( "GitHub bot tools require an active assigned-agent task run", ); const [run] = await db .select() .from(heartbeatRuns) .where( and( eq(heartbeatRuns.id, session.runId), eq(heartbeatRuns.companyId, session.companyId), eq(heartbeatRuns.agentId, session.agentId), ), ); const taskId = run ? boundRunIssueId(run) : null; if ( !run || taskId !== session.issueId || (!publishing && run.status !== "running") || (publishing && !["running", "succeeded"].includes(run.status)) ) throw forbidden("This task run is no longer authorized"); const commentId = await chatRunSourceMessageId(db, run, { allowLegacyWakeComment: true, provider: "github" }); const [source] = typeof commentId === "string" ? await db .select({ conversation: chatConversations, delivery: chatDeliveries, endpoint: chatEndpoints, resource: chatEndpointResources, config: chatGitHubConfigurations, }) .from(chatMessageLinks) .innerJoin( chatDeliveries, eq(chatDeliveries.id, chatMessageLinks.deliveryId), ) .innerJoin( chatConversations, eq(chatConversations.id, chatMessageLinks.conversationId), ) .innerJoin( chatEndpoints, eq(chatEndpoints.id, chatConversations.endpointId), ) .innerJoin( chatEndpointResources, eq(chatEndpointResources.id, chatConversations.resourceId), ) .innerJoin( chatGitHubConfigurations, eq(chatGitHubConfigurations.endpointId, chatEndpoints.id), ) .where( and( eq(chatMessageLinks.companyId, session.companyId), eq(chatMessageLinks.commentId, commentId), eq(chatConversations.issueId, session.issueId), eq(chatEndpoints.assignedAgentId, session.agentId), eq(chatEndpoints.provider, "github"), ), ) : []; if ( !source || !source.delivery.principalId || !source.config.configuration.toolsEnabled || !["active", "verifying"].includes(source.endpoint.status) || !source.resource.enabled || source.resource.availability !== "available" ) throw forbidden( "This run has no active GitHub bot capability for its conversation", ); const access = await githubChatPrincipalAccess( db, source.endpoint, source.delivery.principalId, ); if (!access?.allowed) throw forbidden("The initiating GitHub person is no longer authorized"); const automatic = (source.delivery.normalizedEvent.githubAutomatic ?? source.delivery.normalizedEvent.githubIssue) as { context: GitHubAutomaticEventContext } | undefined; if ( automatic && !(await githubAutomaticAdmission(db, source.endpoint, automatic.context)) ?.allowed ) throw forbidden("Automatic GitHub task authority is no longer available"); const repositoryId = String( source.resource.metadata?.providerRepositoryId ?? "", ); const repository = source.resource.providerResourceId; if ( !/^[1-9][0-9]*$/.test(repositoryId) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ) throw conflict( "Refresh the bot's repository access before using its tools", ); const match = /^github:([^:]+):(?:(issue):)?([1-9][0-9]*)(?::rc:([1-9][0-9]*))?$/.exec( source.conversation.externalThreadId, ); if (!match || match[1]!.toLowerCase() !== repository.toLowerCase()) throw forbidden("Invalid task repository binding"); const [agent] = await db .select() .from(agents) .where( and( eq(agents.companyId, session.companyId), eq(agents.id, session.agentId), ), ); const [issue] = await db .select() .from(issues) .where( and( eq(issues.companyId, session.companyId), eq(issues.id, session.issueId), ), ); if (!agent || !issue || issue.assigneeAgentId !== agent.id) throw forbidden("The assigned agent changed"); const [project] = issue.projectId ? await db .select() .from(projects) .where( and( eq(projects.companyId, session.companyId), eq(projects.id, issue.projectId), ), ) : []; const trust = resolveCoreTrustPreset({ companyId: session.companyId, agent, issue, project, run: { companyId: session.companyId, executionPolicy: run.contextSnapshot?.executionPolicy, }, }); if (trust.kind === "denied") throw forbidden("The agent's trust policy is incomplete"); // The caller's task/run binding is fixed; never export credentials. Review // submissions are governed output, not authority to mutate other tasks. if ( trust.kind === "low_trust_review" && (!(trust.boundary.allowedToolClasses ?? []).includes("github.pr.read") || !isIssueWithinLowTrustBoundary(trust.boundary, issue)) ) throw forbidden( "The low-trust boundary does not permit GitHub review tools", ); return { ...source, run, agent, issue, access, repositoryId, repository, number: Number(match[3]), isIssue: !!match[2], replyId: match[4] ?? null, policy: effectiveGitHubReviewPolicy( source.config.configuration, repositoryId, ), }; } async function client( source: Awaited>, requestFetch = fetchImpl, ) { const token = await githubBotRepositoryToken( db, source.endpoint.companyId, source.endpoint.id, source.repositoryId, requestFetch, ); const prefix = `/repos/${source.repository.split("/").map(encodeURIComponent).join("/")}`; return { prefix, request: ( path: string, options?: Parameters[3], ) => githubBotRequest(requestFetch, token, `${prefix}${path}`, options), }; } async function reviewForHead( source: Awaited>, pull: Pull, ) { const [prior] = await db .select() .from(chatGitHubReviews) .where( and( eq(chatGitHubReviews.companyId, source.endpoint.companyId), eq(chatGitHubReviews.endpointId, source.endpoint.id), eq(chatGitHubReviews.issueId, source.issue.id), eq(chatGitHubReviews.headSha, pull.head.sha), ), ) .orderBy(desc(chatGitHubReviews.createdAt)) .limit(1); if (prior && prior.runId === source.run.id) return prior; if (prior && !prior.runId && prior.deliveryId === source.delivery.id) { const [claimed] = await db .update(chatGitHubReviews) .set({ runId: source.run.id, state: "running", updatedAt: new Date() }) .where( and( eq(chatGitHubReviews.id, prior.id), isNull(chatGitHubReviews.runId), ), ) .returning(); if (claimed) return claimed; return reviewForHead(source, pull); } const manual = source.delivery.normalizedEvent.githubManual as | { policy: GitHubReviewPolicy; revision: number; event: "mention" | "comment"; } | undefined; const automatic = source.delivery.normalizedEvent.githubAutomatic as | { context: GitHubReviewEventContext; policy: GitHubReviewPolicy; revision: number; } | undefined; const context: GitHubReviewEventContext = { event: manual?.event ?? "mention", deliveryId: source.delivery.id, repositoryId: source.repositoryId, repository: source.repository, pullNumber: source.number, title: pull.title, body: pull.body ?? "", headSha: pull.head.sha, baseSha: pull.base.sha, baseBranch: pull.base.ref, draft: pull.draft, author: { id: String(pull.user.id), login: pull.user.login, isBot: pull.user.type === "Bot", }, sender: { id: String( (source.delivery.normalizedEvent.principal as { externalId?: string }) ?.externalId ?? "", ), login: String( (source.delivery.normalizedEvent.principal as { handle?: string }) ?.handle ?? "", ), }, labels: pull.labels.map((l) => l.name), ...(automatic ? automatic.context : {}), }; const [review] = await db .insert(chatGitHubReviews) .values({ companyId: source.endpoint.companyId, endpointId: source.endpoint.id, issueId: source.issue.id, runId: source.run.id, repositoryId: source.repositoryId, repository: source.repository, pullNumber: source.number, headSha: pull.head.sha, deliveryId: `run:${source.run.id}:${pull.head.sha}`, configurationRevision: manual?.revision ?? automatic?.revision ?? source.config.revision, policySnapshot: manual?.policy ?? automatic?.policy ?? source.policy, event: context, state: "running", }) .onConflictDoNothing() .returning(); if (review) { await githubReviewCheckService(db, fetchImpl).enqueue( source.endpoint, context, true, "authorized_manual_request", ); return review; } const [existing] = await db .select() .from(chatGitHubReviews) .where( and( eq(chatGitHubReviews.endpointId, source.endpoint.id), eq( chatGitHubReviews.deliveryId, `run:${source.run.id}:${pull.head.sha}`, ), ), ); return existing!; } async function stage( source: Awaited>, operation: string, payload: Record, key: string, invocationId: string | undefined, toolName: string, parameters: unknown, writer: Db | Parameters[0]>[0] = db, ): Promise { if (writer === db) return db.transaction(tx => stage(source, operation, payload, key, invocationId, toolName, parameters, tx)); const actionKey = `github_publication:${source.issue.id}:${operation}:${hash(key)}`; await notifyDeliveryWork(writer, DELIVERY_QUEUES.chatGitHubPublications); const values = { companyId: source.endpoint.companyId, endpointId: source.endpoint.id, conversationId: source.conversation.id, principalId: source.delivery.principalId, deliveryId: source.delivery.id, kind: "github_review_publication", providerActionId: actionKey, payload: { version: 1, operation, invocationId, toolName, parameters, runtimeGeneration: (source.endpoint.setup as { runtimeGeneration?: number }) .runtimeGeneration ?? 0, session: { companyId: source.endpoint.companyId, agentId: source.agent.id, issueId: source.issue.id, runId: source.run.id, }, ...payload, }, status: "received", }; const [action] = await writer .insert(chatActions) .values(values) .onConflictDoNothing() .returning(); if (!action) { const [existing] = await writer .select() .from(chatActions) .where( and( eq(chatActions.endpointId, source.endpoint.id), eq(chatActions.providerActionId, actionKey), ), ); if ( !existing || hash({ ...existing.payload, invocationId: undefined }) !== hash({ ...values.payload, invocationId: undefined }) ) throw conflict( "This publication key was already used for different content", ); return existing.id; } return action.id; } async function receipt(actionId: string) { const [action] = await db .select({ status: chatActions.status, result: chatActions.result }) .from(chatActions) .where(eq(chatActions.id, actionId)); return { actionId, status: action?.status ?? "received", receipt: action?.result ?? null, }; } function publicationIsDue(action: { status: string; result: Record | null }) { return action.status === "received" || (action.status === "failed" && action.result?.retryable === true && Date.parse(String(action.result.retryAt)) <= Date.now()); } async function publishAndWait(actionId: string, deadline: number) { let failures = 0; while (Date.now() < deadline) { await processPublication(actionId); const result = await receipt(actionId); if (result.status !== "received" && !(result.status === "failed" && result.receipt?.retryable === true)) return result; if (result.status === "failed") failures++; const delay = result.status === "received" ? 1000 : Math.max(0, Date.parse(String(result.receipt?.retryAt)) - Date.now()); if (!Number.isFinite(delay) || Date.now() + delay >= deadline || failures >= GITHUB_INLINE_MAX_ATTEMPTS) return result; await new Promise(resolve => setTimeout(resolve, delay)); } return receipt(actionId); } async function readPublicationReceipt(session: Session, name: string, input: unknown, invocationId: string) { const source = await scope(session); const [action] = await db.select().from(chatActions).where(and( eq(chatActions.companyId, session.companyId), eq(chatActions.endpointId, source.endpoint.id), eq(chatActions.conversationId, source.conversation.id), eq(chatActions.kind, "github_review_publication"), sql`${chatActions.payload}->>'invocationId' = ${invocationId}`, sql`${chatActions.payload}->>'toolName' = ${name}`, )); if (!action) return null; const binding = action.payload.session as Session; if (binding.companyId !== session.companyId || binding.agentId !== session.agentId || binding.runId !== session.runId || binding.issueId !== session.issueId || hash(action.payload.parameters) !== hash(input)) throw conflict("This GitHub invocation is bound to a different publication"); await assertPublicationAuthority(source, action); const result = await receipt(action.id); if (name !== "submit_review") return result; const [review] = await db.select().from(chatGitHubReviews).where(and( eq(chatGitHubReviews.companyId, session.companyId), eq(chatGitHubReviews.id, String(action.payload.reviewId)), eq(chatGitHubReviews.runId, session.runId!), eq(chatGitHubReviews.endpointId, source.endpoint.id), )); if (!review?.assessment) throw conflict("GitHub publication assessment is unavailable"); return { reviewId: review.id, ...result, score: review.assessment.score, conclusion: review.conclusion }; } async function execute( session: Session, name: string, input: unknown, invocationId?: string, ) { const deadline = Date.now() + GITHUB_INLINE_RETRY_WINDOW_MS; const source = await scope(session); if (invocationId && ["comment", "update_comment", "submit_review"].includes(name)) { const [existing] = await db.select().from(chatActions).where(and( eq(chatActions.companyId, session.companyId), eq(chatActions.endpointId, source.endpoint.id), eq(chatActions.conversationId, source.conversation.id), eq(chatActions.kind, "github_review_publication"), sql`${chatActions.payload}->>'invocationId' = ${invocationId}`, sql`${chatActions.payload}->>'toolName' = ${name}`, )); if (existing) { const binding = existing.payload.session as Session; if (binding.companyId !== session.companyId || binding.agentId !== session.agentId || binding.runId !== session.runId || binding.issueId !== session.issueId || hash(existing.payload.parameters) !== hash(input)) throw conflict("This GitHub invocation is bound to a different publication"); const result = await publishAndWait(existing.id, deadline); if (name !== "submit_review") return result; const [review] = await db.select().from(chatGitHubReviews).where(and( eq(chatGitHubReviews.companyId, session.companyId), eq(chatGitHubReviews.id, String(existing.payload.reviewId)), eq(chatGitHubReviews.runId, session.runId!), eq(chatGitHubReviews.endpointId, source.endpoint.id), )); if (!review?.assessment) throw conflict("GitHub publication assessment is unavailable"); return { reviewId: review.id, ...result, score: review.assessment.score, conclusion: review.conclusion }; } } const api = await client(source); if (name === "comment" || name === "update_comment") { const parsed = commentSchema.parse(input); const actionId = await stage( source, name, parsed, parsed.idempotencyKey, invocationId, name, input, ); return publishAndWait(actionId, deadline); } if (source.isIssue) throw conflict( "This task is bound to a GitHub issue. Pull request review tools require a PR conversation.", ); const pull = await api.request(`/pulls/${source.number}`); const automatic = source.delivery.normalizedEvent.githubAutomatic as { context: GitHubReviewEventContext } | undefined; if (automatic && automatic.context.headSha !== pull.head.sha) throw conflict( "This automatic review was requested for an older pull request head. Continue with the newest authorized PR event or an authorized manual request.", ); if (name === "read_pull_request") { const parsed = readSchema.parse(input); if (parsed.section === "metadata") { const configuration = (source.delivery.normalizedEvent.githubManual ?? source.delivery.normalizedEvent.githubAutomatic) as { policy: GitHubReviewPolicy; revision: number } | undefined; const previous = await githubPreviousAssessment( db, source.endpoint, source.repositoryId, source.number, ); return { untrusted: true, pull, reviewPolicy: configuration?.policy ?? source.policy, configurationRevision: configuration?.revision ?? source.config.revision, previousAssessment: previous ? { reviewedCommit: previous.headSha, assessment: previous.assessment, taskId: previous.issueId, runId: previous.runId, } : null, }; } if (parsed.section === "changes_since_review") { const previous = await githubPreviousAssessment( db, source.endpoint, source.repositoryId, source.number, ); if (!previous) return { untrusted: true, items: [], priorReviewedCommit: null, reviewedCommit: pull.head.sha, guidance: "No previous assessment. Read all current pull request files.", }; const comparison = await api.request<{ files?: File[]; status: string; total_commits: number; }>(`/compare/${previous.headSha}...${pull.head.sha}?per_page=1&page=1`); const files = comparison.files ?? []; return { untrusted: true, priorReviewedCommit: previous.headSha, reviewedCommit: pull.head.sha, status: comparison.status, totalCommits: comparison.total_commits, items: files.filter( (file) => !githubReviewPathIsExcluded(file.filename, source.policy) && !githubReviewPathIsExcluded( file.previous_filename ?? file.filename, source.policy, ), ), potentiallyTruncated: files.length >= 300, guidance: "GitHub limits comparison files to 300. This delta is supporting context; read all current PR files before claiming complete coverage. Force pushes can change ancestry.", }; } const route = parsed.section === "comments" ? `/issues/${source.number}/comments` : `/pulls/${source.number}/${parsed.section}`; const rows = await api.request>>( `${route}?per_page=100&page=${parsed.page}`, ); return { untrusted: true, items: parsed.section === "files" ? rows.filter( (file) => !githubReviewPathIsExcluded( String(file.filename), source.policy, ) && !githubReviewPathIsExcluded( String(file.previous_filename ?? file.filename), source.policy, ), ) : rows, hasMore: rows.length === 100, page: parsed.page, reviewedCommit: pull.head.sha, }; } if (name === "read_file") { const parsed = z .object({ path: z.string().min(1).max(1024), revision: z.enum(["head", "base"]), }) .strict() .parse(input); if ( parsed.path.startsWith("/") || parsed.path.split("/").includes("..") || parsed.path.includes("\\") || githubReviewPathIsExcluded(parsed.path, source.policy) ) throw forbidden("This path is excluded from the review"); const result = await api.request>( `/contents/${parsed.path.split("/").map(encodeURIComponent).join("/")}?ref=${parsed.revision === "head" ? pull.head.sha : pull.base.sha}`, ); // Provider URLs can contain credentials or direct download capabilities. return { untrusted: true, path: parsed.path, encoding: result.encoding, content: result.content, sha: result.sha, size: result.size, }; } if (name === "begin_review") { const parsed = z .object({ reviewedCommit: githubCommitSchema }) .strict() .safeParse(input); if (!parsed.success) throw new HttpError(400, "Provide the reviewedCommit from PR metadata"); if (parsed.data.reviewedCommit !== pull.head.sha) throw conflict( "The PR head changed. Read current metadata before starting a review.", ); const review = await reviewForHead(source, pull); return { reviewId: review.id, reviewedCommit: review.headSha, state: review.state, }; } if (name === "submit_review") { const review = await reviewForHead(source, pull); const assessment = validateGitHubReviewAssessment(input, pull.head.sha, { ...review.policySnapshot, ignoredPaths: [ ...review.policySnapshot.ignoredPaths, ...source.policy.ignoredPaths, ], }); const files: File[] = []; for (let page = 1; page <= 30; page++) { const batch = await api.request( `/pulls/${source.number}/files?per_page=100&page=${page}`, ); files.push(...batch); if (batch.length < 100) break; if (page === 30 && assessment.complete) throw conflict( "GitHub's file listing was truncated. Submit an incomplete review.", ); } const permittedFiles = files.filter( (f) => !githubReviewPathIsExcluded(f.filename, source.policy) && !githubReviewPathIsExcluded( f.previous_filename ?? f.filename, source.policy, ), ); const paths = new Set(permittedFiles.map((f) => f.filename)); const byPath = new Map( permittedFiles.map((file) => [file.filename, file]), ); if ( assessment.findings.some( (finding) => !githubReviewLineIsInPatch( byPath.get(finding.path)?.patch, finding.line, finding.side, ), ) ) throw conflict( "Each inline finding must identify a line and side in GitHub's current diff. Move findings outside the available diff into the summary.", ); if ( assessment.findings.some((f) => !paths.has(f.path)) || assessment.coverage.reviewedPaths.some((path) => !paths.has(path)) ) throw conflict( "Assessment coverage and findings must refer to this PR's allowed changed files", ); if ( assessment.complete && (assessment.coverage.omittedPaths.some((path) => paths.has(path)) || permittedFiles.some( (file) => !assessment.coverage.reviewedPaths.includes(file.filename), )) ) throw conflict( "A complete assessment must cover every non-excluded changed file", ); // Commit the immutable assessment and its publication together. A restart // must never leave a stored result with no durable publication intent. const actionId = await db.transaction(async (tx) => { const [current] = await tx .select() .from(chatGitHubReviews) .where(eq(chatGitHubReviews.id, review.id)) .for("update"); if ( !current || (current.assessment && hash(current.assessment) !== hash(assessment)) ) throw conflict( "This run already submitted its assessment. Start another review to revise it.", ); await tx .update(chatGitHubReviews) .set({ assessment, conclusion: githubReviewConclusion( assessment, review.policySnapshot.ratingThreshold, ), state: assessment.complete ? "completed" : "incomplete", updatedAt: new Date(), }) .where( and( eq(chatGitHubReviews.id, review.id), eq(chatGitHubReviews.companyId, session.companyId), ), ); return stage( source, "assessment", { reviewId: review.id, reviewedCommit: pull.head.sha }, review.id, invocationId, name, input, tx, ); }); return { reviewId: review.id, ...(await publishAndWait(actionId, deadline)), score: assessment.score, conclusion: githubReviewConclusion( assessment, review.policySnapshot.ratingThreshold, ), }; } if (name === "formal_review") { const parsed = formalSchema.parse(input); if (parsed.reviewedCommit !== pull.head.sha) throw conflict("The PR head changed. Review the new commits first."); assertFormalPermission(source.policy, parsed.event); const [assessed] = await db .select() .from(chatGitHubReviews) .where( and( eq(chatGitHubReviews.endpointId, source.endpoint.id), eq(chatGitHubReviews.issueId, source.issue.id), eq(chatGitHubReviews.runId, source.run.id), eq(chatGitHubReviews.headSha, pull.head.sha), eq(chatGitHubReviews.state, "completed"), ), ); if (!assessed?.assessment?.complete) throw conflict("Submit a complete assessment before a formal review"); const actionId = await stage( source, "formal_review", parsed, parsed.idempotencyKey, invocationId, name, input, ); return publishAndWait(actionId, deadline); } throw notFound("Unknown GitHub bot tool"); } function assertFormalPermission( policy: GitHubReviewPolicy, event: "APPROVE" | "REQUEST_CHANGES", ) { if ( !(event === "APPROVE" ? policy.allowApprove : policy.allowRequestChanges) ) throw forbidden(`Formal ${event} reviews are disabled for this bot`); } async function assertPublicationAuthority( source: Awaited>, action: typeof chatActions.$inferSelect, ) { if ( (source.endpoint.setup as { runtimeGeneration?: number }) .runtimeGeneration !== action.payload.runtimeGeneration && !( (source.endpoint.setup as { runtimeGeneration?: number }) .runtimeGeneration === undefined && action.payload.runtimeGeneration === 0 ) ) throw forbidden( "The bot connection changed after this operation was requested", ); const toolName = String(action.payload.toolName); const tool = ( await githubBotToolsForSession(db, { companyId: source.endpoint.companyId, agentId: source.agent.id, issueId: source.issue.id, runId: source.run.id, }) ).find( (tool) => tool.connectionId === source.endpoint.connectionId && tool.upstreamToolName === toolName, ); if (!tool) throw forbidden("The bot tool is no longer available"); const decision = await toolAccessPolicyService(db).decide({ companyId: source.endpoint.companyId, actor: { actorType: "agent", actorId: source.agent.id, agentId: source.agent.id, }, runContext: { heartbeatRunId: source.run.id, issueId: source.issue.id, projectId: source.issue.projectId, }, request: { toolName: tool.name, applicationId: tool.applicationId, applicationKey: tool.applicationKey, connectionId: tool.connectionId, catalogEntryId: tool.catalogEntryId, providerType: tool.providerType, upstreamToolName: toolName, riskLevel: tool.risk, arguments: action.payload.parameters, sideEffecting: true, }, consumeRateLimit: false, }); if (decision.allowed) return; if ( decision.decision === "require_approval" && typeof action.payload.invocationId === "string" ) { const [invocation] = await db .select() .from(toolInvocations) .where( and( eq(toolInvocations.id, action.payload.invocationId), eq(toolInvocations.companyId, source.endpoint.companyId), eq(toolInvocations.runId, source.run.id), eq(toolInvocations.agentId, source.agent.id), eq(toolInvocations.connectionId, source.endpoint.connectionId), eq(toolInvocations.toolName, tool.name), ), ); if ( invocation?.approvalState === "approved" && ["executing", "completed", "succeeded", "failed", "timed_out"].includes(invocation.status) ) return; } throw forbidden(decision.explanation); } async function processPublication(actionId: string) { // Existing chat outbox maintenance retries this action after a restart. // A durable per-PR lease serializes dispatch; markers recover unknown writes. const [action] = await db .select() .from(chatActions) .where( and( eq(chatActions.id, actionId), eq(chatActions.kind, "github_review_publication"), ), ) .limit(1); if ( !action || !publicationIsDue(action) ) return; const [binding] = await db .select({ thread: chatConversations.externalThreadId, repository: chatEndpointResources.metadata, }) .from(chatConversations) .innerJoin( chatEndpointResources, eq(chatEndpointResources.id, chatConversations.resourceId), ) .where( and( eq(chatConversations.id, action.conversationId!), eq(chatConversations.companyId, action.companyId), eq(chatConversations.endpointId, action.endpointId), ), ); const number = binding?.thread.match( /^github:[^:]+:(?:issue:)?([1-9][0-9]*)(?::rc:[1-9][0-9]*)?$/, )?.[1]; if (!number || !binding?.repository.providerRepositoryId) { await db.transaction(async tx => { await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubPublications); await tx.update(chatActions).set({ status: "cancelled", result: { code: "publication_binding_unavailable", retryable: false }, updatedAt: new Date() }) .where(and(eq(chatActions.id, actionId), eq(chatActions.companyId, action.companyId))); }); return; } await withGitHubPublicationLease( db, { companyId: action.companyId, endpointId: action.endpointId, repositoryId: String(binding.repository.providerRepositoryId), number: Number(number), }, fetchImpl, async (lease) => { const [fresh] = await db .select() .from(chatActions) .where(eq(chatActions.id, actionId)); if (!fresh || !publicationIsDue(fresh)) return; const session = action.payload.session as Session; try { if (!session || session.companyId !== action.companyId) throw forbidden("Invalid GitHub publication binding"); const source = await scope(session, true); if ( source.endpoint.id !== action.endpointId || source.conversation.id !== action.conversationId || source.delivery.id !== action.deliveryId ) throw forbidden("GitHub publication context changed"); await assertPublicationAuthority(source, action); const providerApi = await client(source, lease.fetch); const api: typeof providerApi = { ...providerApi, request: async (path, options) => { if (options?.method && ["POST", "PATCH"].includes(options.method) && /\/(?:comments|reviews)(?:\/|$)/.test(path)) { // Persist uncertainty before transport. A stale head or lost // authority after a write cannot prove the comment never arrived. await lease.commit(async tx => { await tx.update(chatActions).set({ result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || '{"replyWriteStarted":true}'::jsonb`, updatedAt: new Date() }) .where(eq(chatActions.id, action.id)); }); } return providerApi.request(path, options); } }; const publicationMarker = marker( "publication", action.providerActionId, ); const findByMarker = async (route: string, bodyMarker: string) => { for (let page = 1; page <= 100; page++) { const rows = await api.request< Array<{ id: number; body?: string; html_url: string; user?: { login?: string }; }> >(`${route}?per_page=100&page=${page}`); const found = rows.find( (row) => row.body?.includes(bodyMarker) && row.user?.login === source.endpoint.botUsername, ); if (found) return found; if (rows.length < 100) return null; } throw conflict( "GitHub history is too large to safely resolve a publication retry", ); }; const currentHead = async (expected: string) => { const currentSource = await scope(session, true); await assertPublicationAuthority(currentSource, action); if (action.payload.operation === "formal_review") assertFormalPermission( currentSource.policy, action.payload.event as "APPROVE" | "REQUEST_CHANGES", ); const [newer] = await db .select({ id: chatGitHubReviews.id }) .from(chatGitHubReviews) .where( and( eq(chatGitHubReviews.endpointId, source.endpoint.id), eq(chatGitHubReviews.repositoryId, source.repositoryId), eq(chatGitHubReviews.pullNumber, source.number), gt(chatGitHubReviews.createdAt, source.run.createdAt), sql`${chatGitHubReviews.runId} is distinct from ${source.run.id}`, sql`${chatGitHubReviews.assessment} is not null`, ), ) .limit(1); if (newer) throw conflict("github_review_superseded"); const current = await api.request(`/pulls/${source.number}`); if (current.head.sha !== expected) throw conflict("github_review_superseded"); return currentSource; }; const operation = action.payload.operation; let receipt: Record; if (operation === "comment" || operation === "update_comment") { receipt = await writeGitHubResponseComment(db, source, api, lease, { body: String(action.payload.body), final: operation === "comment", versionAt: action.createdAt, }, async () => assertPublicationAuthority(await scope(session, true), action)); if (operation === "comment") await githubResponseCommentService(db, fetchImpl).finishRun({ companyId: session.companyId, issueId: source.issue.id, runId: source.run.id, endpointId: source.endpoint.id }, source, receipt as { id: string; url: string }, api, lease, async () => assertPublicationAuthority(await scope(session, true), action)); } else if (operation === "formal_review") { const parsed = formalSchema.parse({ body: action.payload.body, event: action.payload.event, reviewedCommit: action.payload.reviewedCommit, idempotencyKey: action.payload.idempotencyKey, }); assertFormalPermission(source.policy, parsed.event); await currentHead(parsed.reviewedCommit); const prior = await findByMarker( `/pulls/${source.number}/reviews`, publicationMarker, ); const posted = prior ?? (await api.request<{ id: number; html_url: string }>( `/pulls/${source.number}/reviews`, { method: "POST", body: { commit_id: parsed.reviewedCommit, event: parsed.event, body: `${projectSafeChatPublicationText(parsed.body)}\n\n${publicationMarker}`, }, }, )); receipt = { id: String(posted.id), url: posted.html_url }; } else if (operation === "assessment") { const [review] = await db .select() .from(chatGitHubReviews) .where( and( eq(chatGitHubReviews.id, String(action.payload.reviewId)), eq(chatGitHubReviews.companyId, action.companyId), eq(chatGitHubReviews.endpointId, action.endpointId), eq(chatGitHubReviews.runId, session.runId!), ), ) .limit(1); if (!review?.assessment) throw conflict("Review assessment is unavailable"); await currentHead(review.headSha); const assessment = validateGitHubReviewAssessment( review.assessment, review.headSha, { ...review.policySnapshot, ignoredPaths: [ ...review.policySnapshot.ignoredPaths, ...source.policy.ignoredPaths, ], }, ); const conclusion = githubReviewConclusion( assessment, review.policySnapshot.ratingThreshold, ); const origin = runtimePublicOrigin(); const [company] = await db .select({ prefix: companies.issuePrefix }) .from(companies) .where(eq(companies.id, source.endpoint.companyId)); const board = origin && company ? `${origin}/${encodeURIComponent(company.prefix)}` : null; const taskLink = board ? `[${source.issue.identifier}](${board}/issues/${source.issue.id})` : source.issue.identifier; const runLink = board ? `[Run](${board}/agents/${source.agent.id}/runs/${source.run.id})` : `Run: ${source.run.id}`; const historyLink = board ? ` · [Review history](${board}/apps/chat/${source.endpoint.id}/reviews)` : ""; const summary = projectSafeChatPublicationText( `${githubReviewHeading({ botDisplayName: source.endpoint.botDisplayName, botUsername: source.endpoint.botUsername, agentName: source.agent.name, score: assessment.complete ? assessment.score : null, })}\n\n${assessment.summary}\n\n${assessment.rationale}\n\nReviewed commit: \`${review.headSha}\`\n\nCoverage: ${assessment.coverage.reviewedPaths.length} files.\n${assessment.coverage.limitations.join("\n")}\n\nTask: ${taskLink} · ${runLink}${historyLink}`, ); const summaryMarker = marker( "review", `${source.endpoint.id}:${source.repositoryId}:${source.number}`, ); const receipts = { ...review.publicationReceipts }; let summaryReceipt: { id: number; html_url: string } | null = null; if (source.policy.publishSummary) { const currentSummarySource = await currentHead(review.headSha); if (!currentSummarySource.policy.publishSummary) throw forbidden("Summary publication is disabled"); const published = await writeGitHubResponseComment(db, source, api, lease, { body: `${summary}\n\n${summaryMarker}`, final: true, versionAt: action.createdAt, }, async () => { await currentHead(review.headSha); }); summaryReceipt = { id: Number(published.id), html_url: published.url }; receipts.summary = { id: published.id, url: published.url, digest: hash(summary) }; await lease.commit(async tx => { await tx.update(chatGitHubReviews).set({ summaryId: published.id, summaryUrl: published.url, publicationReceipts: receipts, updatedAt: new Date() }).where(eq(chatGitHubReviews.id, review.id)); }); await githubResponseCommentService(db, fetchImpl).finishRun({ companyId: session.companyId, issueId: source.issue.id, runId: source.run.id, endpointId: source.endpoint.id }, source, published, api, lease, async () => { await currentHead(review.headSha); }); } let firstInlineReceipt: { id: string; url: string } | null = null; const severity = { info: 0, warning: 1, error: 2 }; if (source.policy.publishInline) for (const finding of assessment.findings) { if ( severity[finding.severity] < severity[source.policy.minimumCommentSeverity] ) continue; const key = hash({ key: finding.key, path: finding.path, line: finding.line, side: finding.side, }); const findingMarker = marker( "finding", `${source.endpoint.id}:${source.repositoryId}:${source.number}:${key}`, ); const prior = await findByMarker( `/pulls/${source.number}/comments`, findingMarker, ); const currentFindingSource = await currentHead(review.headSha); if ( !currentFindingSource.policy.publishInline || severity[finding.severity] < severity[currentFindingSource.policy.minimumCommentSeverity] ) continue; if ( githubReviewPathIsExcluded( finding.path, currentFindingSource.policy, ) ) throw forbidden("Finding path is now excluded"); const posted = prior ?? (await api.request<{ id: number; html_url: string }>( `/pulls/${source.number}/comments`, { method: "POST", body: { body: `**${finding.severity} · ${finding.category}**\n\n${projectSafeChatPublicationText(finding.body)}\n\n${findingMarker}`, commit_id: review.headSha, path: finding.path, line: finding.line, side: finding.side, }, }, )); // Replies to a finding continue the task that published it. The // provider gives inline threads their own root comment ID, so // bind that native thread before returning the publication. // Retries recover the same provider comment and preserve any // established ownership instead of moving existing follow-ups. await lease.commit(async (tx) => { await tx .insert(chatConversations) .values({ companyId: source.endpoint.companyId, endpointId: source.endpoint.id, resourceId: source.resource.id, issueId: source.issue.id, externalConversationId: source.conversation.externalConversationId, externalThreadId: `github:${source.repository}:${source.number}:rc:${posted.id}`, sessionGeneration: 1, externalLabel: source.conversation.externalLabel, providerUrl: posted.html_url, isDirectMessage: false, state: "active", lastActivityAt: new Date(), }) .onConflictDoNothing(); }); receipts[key] = { id: String(posted.id), url: posted.html_url, digest: hash(finding) }; firstInlineReceipt ??= { id: String(posted.id), url: posted.html_url }; await lease.commit(async tx => { await tx.update(chatGitHubReviews).set({ publicationReceipts: receipts, updatedAt: new Date() }) .where(eq(chatGitHubReviews.id, review.id)); }); } if (!source.policy.publishSummary && firstInlineReceipt) { await githubResponseCommentService(db, fetchImpl).finishRun({ companyId: session.companyId, issueId: source.issue.id, runId: source.run.id, endpointId: source.endpoint.id, closePrimary: true, }, source, firstInlineReceipt, api, lease, async () => { await currentHead(review.headSha); }); } await currentHead(review.headSha); const checks = await api.request<{ check_runs: Array<{ id: number; external_id?: string; app?: { id?: number }; }>; }>( `/commits/${review.headSha}/check-runs?check_name=Paperclip%20Review&per_page=100`, ); const check = checks.check_runs.find( (item) => item.external_id === `${source.endpoint.id}:${source.number}:${review.headSha}` && String(item.app?.id) === source.endpoint.botExternalId, ); await currentHead(review.headSha); const postedCheck = await api.request<{ id: number; html_url: string; }>(check ? `/check-runs/${check.id}` : "/check-runs", { method: check ? "PATCH" : "POST", body: { name: "Paperclip Review", head_sha: review.headSha, external_id: `${source.endpoint.id}:${source.number}:${review.headSha}`, ...(board ? { details_url: `${board}/issues/${source.issue.id}` } : {}), status: "completed", conclusion, output: { title: assessment.complete ? `${assessment.score}/5` : "Incomplete review", summary, }, }, }); await lease.commit(async (tx) => { await tx .update(chatGitHubReviews) .set({ conclusion, checkId: String(postedCheck.id), checkUrl: postedCheck.html_url, summaryId: summaryReceipt ? String(summaryReceipt.id) : review.summaryId, summaryUrl: summaryReceipt?.html_url ?? review.summaryUrl, publicationReceipts: receipts, updatedAt: new Date(), }) .where(eq(chatGitHubReviews.id, review.id)); }); receipt = { reviewId: review.id, checkUrl: postedCheck.html_url, summaryUrl: summaryReceipt?.html_url ?? null, }; } else throw forbidden("Unknown GitHub publication"); await lease.commit(async (tx) => { await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubPublications); await tx .update(chatActions) .set({ status: "processed", result: receipt, updatedAt: new Date(), }) .where(eq(chatActions.id, action.id)); // A confirmed tool reply completes the acknowledgement even while // the model continues its turn. Commit cleanup in the same outbox // transaction; a restart or failed removal is retried independently. if ( (await githubRunReplyState(tx as unknown as Db, { companyId: action.companyId, endpointId: action.endpointId, issueId: source.issue.id, runId: source.run.id, })) === "confirmed" ) { await stageReceiptReactionRemovals(tx as unknown as Db, { endpoint: source.endpoint, binding: { companyId: action.companyId, endpointId: action.endpointId, conversationId: source.conversation.id, issueId: source.issue.id, }, runId: source.run.id, runtimeContext: source.delivery.normalizedEvent .runtimeContext as { generation: number; credentialFingerprint: string; }, }); } await logActivity(tx as unknown as Db, { companyId: action.companyId, actorType: "agent", actorId: source.agent.id, action: "chat_github.published", entityType: "issue", entityId: source.issue.id, runId: source.run.id, details: { actionId: action.id, operation, ...receipt }, }); }); } catch (error) { if (error instanceof GitHubPublicationLeaseLost) return; const attempts = Number(fresh.result?.attempts ?? 0) + 1; const denied = error instanceof HttpError && error.status === 403; const superseded = error instanceof Error && error.message === "github_review_superseded"; if (superseded && typeof action.payload.reviewId === "string") await lease.commit(async (tx) => { await tx .update(chatGitHubReviews) .set({ state: "superseded", updatedAt: new Date() }) .where( and( eq(chatGitHubReviews.id, String(action.payload.reviewId)), eq(chatGitHubReviews.companyId, action.companyId), ), ); }); await lease.commit(async (tx) => { await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubPublications); await tx .update(chatActions) .set({ status: superseded || denied ? "cancelled" : "failed", result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify({ ...githubPublicationRetry(error, attempts), ...(superseded || denied ? { retryable: false } : {}), code: superseded ? "stale_head" : denied ? "authorization_changed" : "publication_failed", })}::jsonb`, updatedAt: new Date(), }) .where(eq(chatActions.id, action.id)); }); } }, ); } async function processPending(limit = 10) { const rows = await db .select({ id: chatActions.id, status: chatActions.status, result: chatActions.result, }) .from(chatActions) .where( and( eq(chatActions.kind, "github_review_publication"), sql`coalesce(${publicationLeaseExpiresAt()}, now()) <= now()`, or( eq(chatActions.status, "received"), and( eq(chatActions.status, "failed"), sql`${chatActions.result}->>'retryable' = 'true'`, sql`(${chatActions.result}->>'retryAt')::timestamptz <= now()`, ), ), ), ) .orderBy(chatActions.createdAt) .limit(limit); for (const row of rows) if ( row.status === "received" || (row.result?.retryable === true && Date.parse(String(row.result.retryAt)) <= Date.now()) ) await processPublication(row.id); return rows.length; } function publicationLeaseExpiresAt() { return sql`(select max(l.expires_at) from chat_endpoint_leases l inner join chat_conversations c on c.id = ${chatActions.conversationId} and c.company_id = ${chatActions.companyId} inner join chat_endpoint_resources r on r.id = c.resource_id and r.company_id = ${chatActions.companyId} where l.company_id = ${chatActions.companyId} and l.endpoint_id = ${chatActions.endpointId} and l.lease_key = 'github-publication:' || (r.metadata->>'providerRepositoryId') || ':' || substring(c.external_thread_id from '^github:[^:]+:(?:issue:)?([1-9][0-9]*)'))`; } async function nextPendingAt(): Promise { const [row] = await db.select({ at: sql`min(greatest( case when ${chatActions.status} = 'received' then now() else (${chatActions.result}->>'retryAt')::timestamptz end, ${publicationLeaseExpiresAt()} ))` }).from(chatActions).where(and( eq(chatActions.kind, "github_review_publication"), or(eq(chatActions.status, "received"), and(eq(chatActions.status, "failed"), sql`${chatActions.result}->>'retryable' = 'true'`)), )); return row?.at ? new Date(row.at).getTime() : null; } return { execute, readPublicationReceipt, processPending, nextPendingAt, scope }; }