diff --git a/CLAUDE.md b/CLAUDE.md index f4d7c280..9162eff4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,13 +90,24 @@ CASCADE stores all project configuration in PostgreSQL (Supabase). The `config/p ### Database Scripts ```bash -npm run db:generate # Generate migration SQL from schema changes -npm run db:migrate # Apply migrations -npm run db:push # Push schema directly (dev) -npm run db:studio # Open Drizzle Studio -npm run db:seed # Seed DB from config/projects.json +npm run db:generate # Generate migration SQL from schema changes +npm run db:migrate # Apply pending migrations +npm run db:push # Push schema directly (dev only) +npm run db:studio # Open Drizzle Studio +npm run db:seed # Seed DB from config/projects.json +npm run db:bootstrap-journal # Bootstrap migration journal (one-time setup for existing DBs) ``` +### Migration Workflow + +Migrations are hand-written SQL files in `src/db/migrations/` tracked by drizzle-kit's journal (`meta/_journal.json`). When adding a new migration: + +1. Create `src/db/migrations/NNNN_description.sql` +2. Add a corresponding entry to `src/db/migrations/meta/_journal.json` with a unique `when` timestamp (ms since epoch) and `tag` matching the filename without `.sql` +3. Run `npm run db:migrate` to apply + +For databases initially set up with `drizzle-kit push` (no migration journal), run `npm run db:bootstrap-journal` once to register existing migrations in the `drizzle.__drizzle_migrations` tracking table. + ### Per-Project Secrets Credentials are stored in the `credentials` table (org-scoped) with optional per-project overrides via `project_credential_overrides`. diff --git a/package.json b/package.json index 8e069c90..0e3a7ffe 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,11 @@ "tool:run-local": "node --env-file=.env --import tsx tools/run-local.ts", "tool:debug-run": "node --env-file=.env --import tsx tools/debug-run.ts", "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio", - "db:seed": "node --env-file=.env --import tsx tools/seed-config-from-json.ts" + "db:migrate": "node --env-file=.env ./node_modules/.bin/drizzle-kit migrate", + "db:push": "node --env-file=.env ./node_modules/.bin/drizzle-kit push", + "db:studio": "node --env-file=.env ./node_modules/.bin/drizzle-kit studio", + "db:seed": "node --env-file=.env --import tsx tools/seed-config-from-json.ts", + "db:bootstrap-journal": "node --env-file=.env --import tsx tools/db-bootstrap-journal.ts" }, "keywords": [ "trello", diff --git a/src/agents/respond-to-pr-comment.ts b/src/agents/respond-to-pr-comment.ts index e0db74d1..26abc141 100644 --- a/src/agents/respond-to-pr-comment.ts +++ b/src/agents/respond-to-pr-comment.ts @@ -1,132 +1,19 @@ -import { githubClient } from '../github/client.js'; -import type { AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; +import type { AgentResult } from '../types/index.js'; import { createPRAgentGadgets } from './shared/gadgets.js'; +import { type GitHubAgentDefinition, executeGitHubAgent } from './shared/githubAgent.js'; import { - type GitHubAgentContext, - type GitHubAgentDefinition, - type GitHubAgentInput, - createInitialPRComment, - executeGitHubAgent, -} from './shared/githubAgent.js'; -import { resolveModelConfig } from './shared/modelResolution.js'; -import { - formatPRComments, - formatPRDetails, - formatPRDiff, - formatPRIssueComments, - formatPRReviews, -} from './shared/prFormatting.js'; -import { - injectContextFiles, - injectDirectoryListing, - injectSquintContext, - injectSyntheticCall, -} from './shared/syntheticCalls.js'; - -interface RespondToPRCommentAgentInput extends GitHubAgentInput { - triggerCommentId: number; - triggerCommentBody: string; - triggerCommentPath: string; - triggerCommentUrl: string; - acknowledgmentCommentId?: number; -} - -// ============================================================================ -// Context Building -// ============================================================================ - -interface PRCommentContextData extends GitHubAgentContext { - contextFiles: Awaited>['contextFiles']; - prDetailsFormatted: string; - commentsFormatted: string; - reviewsFormatted: string; - issueCommentsFormatted: string; - diffFormatted: string; -} - -async function buildPRCommentContext( - owner: string, - repo: string, - prNumber: number, - prBranch: string, - repoDir: string, - project: ProjectConfig, - config: CascadeConfig, - log: { info: (msg: string, ctx?: Record) => void }, - modelOverride?: string, -): Promise { - // respond-to-pr-comment shares model/iteration config with 'review' agent - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ - agentType: 'respond-to-pr-comment', - project, - config, - repoDir, - modelOverride, - configKey: 'review', - }); - - // Fetch PR details, comments, reviews, issue comments, and diff - log.info('Fetching PR details, comments, reviews, issue comments, and diff', { - owner, - repo, - prNumber, - }); - const prDetails = await githubClient.getPR(owner, repo, prNumber); - const prComments = await githubClient.getPRReviewComments(owner, repo, prNumber); - const prReviews = await githubClient.getPRReviews(owner, repo, prNumber); - const prIssueComments = await githubClient.getPRIssueComments(owner, repo, prNumber); - const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); - - // Format PR data - const prDetailsFormatted = formatPRDetails(prDetails); - const commentsFormatted = formatPRComments(prComments); - const reviewsFormatted = formatPRReviews(prReviews); - const issueCommentsFormatted = formatPRIssueComments(prIssueComments); - const diffFormatted = formatPRDiff(prDiff); - - // Build prompt - const prompt = buildPRCommentPrompt(prBranch, prNumber, owner, repo); - - return { - systemPrompt, - model, - maxIterations, - contextFiles, - prDetailsFormatted, - commentsFormatted, - reviewsFormatted, - issueCommentsFormatted, - diffFormatted, - prompt, - }; -} - -function buildPRCommentPrompt( - prBranch: string, - prNumber: number, - owner: string, - repo: string, -): string { - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -A user @mentioned you in a PR comment. Read their request and execute it. - -## GitHub Context - -Owner: ${owner} -Repo: ${repo} -PR Number: ${prNumber} - -Use these values when calling GitHub gadgets (GetPRComments, ReplyToReviewComment, PostPRComment, UpdatePRComment).`; -} - -// ============================================================================ -// Agent Definition -// ============================================================================ + type PRResponseAgentInput, + type PRResponseContextData, + buildPRResponseContext, + buildPRResponsePrompt, + injectPRResponseSyntheticCalls, + postInitialPRResponseComment, +} from './shared/prResponseAgent.js'; +import { injectSyntheticCall } from './shared/syntheticCalls.js'; const respondToPRCommentDefinition: GitHubAgentDefinition< - RespondToPRCommentAgentInput, - PRCommentContextData + PRResponseAgentInput, + PRResponseContextData > = { agentType: 'respond-to-pr-comment', headerMessage: '🤖 Working on your request...', @@ -136,21 +23,10 @@ const respondToPRCommentDefinition: GitHubAgentDefinition< getGadgets: () => createPRAgentGadgets({ includeReviewComments: true }), - async postInitialComment(input, id, headerMessage) { - if (input.acknowledgmentCommentId) { - const comment = await githubClient.updatePRComment( - id.owner, - id.repo, - input.acknowledgmentCommentId, - headerMessage, - ); - return { id: comment.id, htmlUrl: comment.htmlUrl, gadgetName: 'UpdatePRComment' }; - } - return createInitialPRComment(input.prNumber, id, headerMessage); - }, + postInitialComment: postInitialPRResponseComment, buildContext: ({ owner, repo }, input, repoDir, log) => - buildPRCommentContext( + buildPRResponseContext( owner, repo, input.prNumber, @@ -159,108 +35,47 @@ const respondToPRCommentDefinition: GitHubAgentDefinition< input.project, input.config, log, + 'respond-to-pr-comment', + (prBranch, prNumber, o, r) => + buildPRResponsePrompt( + prBranch, + prNumber, + o, + r, + 'A user @mentioned you in a PR comment. Read their request and execute it.', + 'GetPRComments, ReplyToReviewComment, PostPRComment, UpdatePRComment', + ), input.modelOverride, ), - async injectSyntheticCalls({ - builder, - ctx, - trackingContext, - repoDir, - id: { owner, repo }, - input, - }) { - let b = injectDirectoryListing(builder, trackingContext); - - // Inject the triggering comment prominently - b = injectSyntheticCall( - b, - trackingContext, - 'TriggeringComment', - { - comment: - 'The @mention comment that triggered this agent — this is your primary instruction', - commentId: input.triggerCommentId, - url: input.triggerCommentUrl, - path: input.triggerCommentPath || '(general PR comment)', - }, - input.triggerCommentBody, - 'gc_triggering_comment', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDetails', - { comment: 'Pre-fetching PR details for context', owner, repo, prNumber: input.prNumber }, - ctx.prDetailsFormatted, - 'gc_pr_details', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRComments', - { - comment: 'Pre-fetching line-specific review comments for context', - owner, - repo, - prNumber: input.prNumber, + async injectSyntheticCalls(params) { + return injectPRResponseSyntheticCalls(params, { + preSyntheticCalls: (builder, trackingContext, input) => + injectSyntheticCall( + builder, + trackingContext, + 'TriggeringComment', + { + comment: + 'The @mention comment that triggered this agent — this is your primary instruction', + commentId: input.triggerCommentId, + url: input.triggerCommentUrl, + path: input.triggerCommentPath || '(general PR comment)', + }, + input.triggerCommentBody, + 'gc_triggering_comment', + ), + commentDescriptions: { + prComments: 'Pre-fetching line-specific review comments for context', + prReviews: 'Pre-fetching review submissions for context', + prIssueComments: 'Pre-fetching general PR comments for context', }, - ctx.commentsFormatted, - 'gc_pr_comments', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRReviews', - { - comment: 'Pre-fetching review submissions for context', - owner, - repo, - prNumber: input.prNumber, - }, - ctx.reviewsFormatted, - 'gc_pr_reviews', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRIssueComments', - { - comment: 'Pre-fetching general PR comments for context', - owner, - repo, - prNumber: input.prNumber, - }, - ctx.issueCommentsFormatted, - 'gc_pr_issue_comments', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDiff', - { comment: 'Pre-fetching PR diff for context', owner, repo, prNumber: input.prNumber }, - ctx.diffFormatted, - 'gc_pr_diff', - ); - - b = injectContextFiles(b, trackingContext, ctx.contextFiles); - b = injectSquintContext(b, trackingContext, repoDir); - - return b; + }); }, }; -// ============================================================================ -// PR Comment Agent Execution -// ============================================================================ - export async function executeRespondToPRCommentAgent( - input: RespondToPRCommentAgentInput, + input: PRResponseAgentInput, ): Promise { return executeGitHubAgent(respondToPRCommentDefinition, input); } diff --git a/src/agents/respond-to-review.ts b/src/agents/respond-to-review.ts index 6ef05c6f..6c5c0299 100644 --- a/src/agents/respond-to-review.ts +++ b/src/agents/respond-to-review.ts @@ -1,132 +1,18 @@ -import { githubClient } from '../github/client.js'; -import type { AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; +import type { AgentResult } from '../types/index.js'; import { createPRAgentGadgets } from './shared/gadgets.js'; +import { type GitHubAgentDefinition, executeGitHubAgent } from './shared/githubAgent.js'; import { - type GitHubAgentContext, - type GitHubAgentDefinition, - type GitHubAgentInput, - createInitialPRComment, - executeGitHubAgent, -} from './shared/githubAgent.js'; -import { resolveModelConfig } from './shared/modelResolution.js'; -import { - formatPRComments, - formatPRDetails, - formatPRDiff, - formatPRIssueComments, - formatPRReviews, -} from './shared/prFormatting.js'; -import { - injectContextFiles, - injectDirectoryListing, - injectSquintContext, - injectSyntheticCall, -} from './shared/syntheticCalls.js'; - -interface RespondToReviewAgentInput extends GitHubAgentInput { - triggerCommentId: number; - triggerCommentBody: string; - triggerCommentPath: string; - triggerCommentUrl: string; - acknowledgmentCommentId?: number; -} - -// ============================================================================ -// Context Building -// ============================================================================ - -interface ReviewContextData extends GitHubAgentContext { - contextFiles: Awaited>['contextFiles']; - prDetailsFormatted: string; - commentsFormatted: string; - reviewsFormatted: string; - issueCommentsFormatted: string; - diffFormatted: string; -} - -async function buildReviewContext( - owner: string, - repo: string, - prNumber: number, - prBranch: string, - repoDir: string, - project: ProjectConfig, - config: CascadeConfig, - log: { info: (msg: string, ctx?: Record) => void }, - modelOverride?: string, -): Promise { - // respond-to-review shares model/iteration config with 'review' agent - const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ - agentType: 'respond-to-review', - project, - config, - repoDir, - modelOverride, - configKey: 'review', - }); - - // Fetch PR details, comments, reviews, issue comments, and diff - log.info('Fetching PR details, comments, reviews, issue comments, and diff', { - owner, - repo, - prNumber, - }); - const prDetails = await githubClient.getPR(owner, repo, prNumber); - const prComments = await githubClient.getPRReviewComments(owner, repo, prNumber); - const prReviews = await githubClient.getPRReviews(owner, repo, prNumber); - const prIssueComments = await githubClient.getPRIssueComments(owner, repo, prNumber); - const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); - - // Format PR data - const prDetailsFormatted = formatPRDetails(prDetails); - const commentsFormatted = formatPRComments(prComments); - const reviewsFormatted = formatPRReviews(prReviews); - const issueCommentsFormatted = formatPRIssueComments(prIssueComments); - const diffFormatted = formatPRDiff(prDiff); - - // Build prompt - const prompt = buildReviewPrompt(prBranch, prNumber, owner, repo); - - return { - systemPrompt, - model, - maxIterations, - contextFiles, - prDetailsFormatted, - commentsFormatted, - reviewsFormatted, - issueCommentsFormatted, - diffFormatted, - prompt, - }; -} - -function buildReviewPrompt( - prBranch: string, - prNumber: number, - owner: string, - repo: string, -): string { - return `You are on the branch \`${prBranch}\` for PR #${prNumber}. - -Address the review comments and push your changes. - -## GitHub Context - -Owner: ${owner} -Repo: ${repo} -PR Number: ${prNumber} - -Use these values when calling GitHub gadgets (GetPRComments, ReplyToReviewComment).`; -} - -// ============================================================================ -// Agent Definition -// ============================================================================ + type PRResponseAgentInput, + type PRResponseContextData, + buildPRResponseContext, + buildPRResponsePrompt, + injectPRResponseSyntheticCalls, + postInitialPRResponseComment, +} from './shared/prResponseAgent.js'; const respondToReviewDefinition: GitHubAgentDefinition< - RespondToReviewAgentInput, - ReviewContextData + PRResponseAgentInput, + PRResponseContextData > = { agentType: 'respond-to-review', headerMessage: '🤖 Working on addressing the review feedback...', @@ -136,21 +22,10 @@ const respondToReviewDefinition: GitHubAgentDefinition< getGadgets: () => createPRAgentGadgets({ includeReviewComments: true }), - async postInitialComment(input, id, headerMessage) { - if (input.acknowledgmentCommentId) { - const comment = await githubClient.updatePRComment( - id.owner, - id.repo, - input.acknowledgmentCommentId, - headerMessage, - ); - return { id: comment.id, htmlUrl: comment.htmlUrl, gadgetName: 'UpdatePRComment' }; - } - return createInitialPRComment(input.prNumber, id, headerMessage); - }, + postInitialComment: postInitialPRResponseComment, buildContext: ({ owner, repo }, input, repoDir, log) => - buildReviewContext( + buildPRResponseContext( owner, repo, input.prNumber, @@ -159,92 +34,26 @@ const respondToReviewDefinition: GitHubAgentDefinition< input.project, input.config, log, + 'respond-to-review', + (prBranch, prNumber, o, r) => + buildPRResponsePrompt( + prBranch, + prNumber, + o, + r, + 'Address the review comments and push your changes.', + 'GetPRComments, ReplyToReviewComment', + ), input.modelOverride, ), - async injectSyntheticCalls({ - builder, - ctx, - trackingContext, - repoDir, - id: { owner, repo }, - input, - }) { - let b = injectDirectoryListing(builder, trackingContext); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDetails', - { comment: 'Pre-fetching PR details for context', owner, repo, prNumber: input.prNumber }, - ctx.prDetailsFormatted, - 'gc_pr_details', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRComments', - { - comment: 'Pre-fetching line-specific review comments to address', - owner, - repo, - prNumber: input.prNumber, - }, - ctx.commentsFormatted, - 'gc_pr_comments', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRReviews', - { - comment: 'Pre-fetching review submissions (approve/request changes with body text)', - owner, - repo, - prNumber: input.prNumber, - }, - ctx.reviewsFormatted, - 'gc_pr_reviews', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRIssueComments', - { - comment: 'Pre-fetching general PR comments (issue-style conversation)', - owner, - repo, - prNumber: input.prNumber, - }, - ctx.issueCommentsFormatted, - 'gc_pr_issue_comments', - ); - - b = injectSyntheticCall( - b, - trackingContext, - 'GetPRDiff', - { comment: 'Pre-fetching PR diff for context', owner, repo, prNumber: input.prNumber }, - ctx.diffFormatted, - 'gc_pr_diff', - ); - - b = injectContextFiles(b, trackingContext, ctx.contextFiles); - b = injectSquintContext(b, trackingContext, repoDir); - - return b; + async injectSyntheticCalls(params) { + return injectPRResponseSyntheticCalls(params); }, }; -// ============================================================================ -// Review Agent Execution -// ============================================================================ - export async function executeRespondToReviewAgent( - input: RespondToReviewAgentInput, + input: PRResponseAgentInput, ): Promise { return executeGitHubAgent(respondToReviewDefinition, input); } diff --git a/src/agents/shared/prResponseAgent.ts b/src/agents/shared/prResponseAgent.ts new file mode 100644 index 00000000..ca4d7418 --- /dev/null +++ b/src/agents/shared/prResponseAgent.ts @@ -0,0 +1,247 @@ +import { githubClient } from '../../github/client.js'; +import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; +import type { TrackingContext } from '../utils/tracking.js'; +import type { BuilderType } from './builderFactory.js'; +import type { + GitHubAgentContext, + GitHubAgentInput, + InitialCommentResult, + RepoIdentifier, +} from './githubAgent.js'; +import { createInitialPRComment } from './githubAgent.js'; +import { resolveModelConfig } from './modelResolution.js'; +import { + formatPRComments, + formatPRDetails, + formatPRDiff, + formatPRIssueComments, + formatPRReviews, +} from './prFormatting.js'; +import { + injectContextFiles, + injectDirectoryListing, + injectSquintContext, + injectSyntheticCall, +} from './syntheticCalls.js'; + +// ============================================================================ +// Shared Types +// ============================================================================ + +export interface PRResponseAgentInput extends GitHubAgentInput { + triggerCommentId: number; + triggerCommentBody: string; + triggerCommentPath: string; + triggerCommentUrl: string; + acknowledgmentCommentId?: number; +} + +export interface PRResponseContextData extends GitHubAgentContext { + contextFiles: Awaited>['contextFiles']; + prDetailsFormatted: string; + commentsFormatted: string; + reviewsFormatted: string; + issueCommentsFormatted: string; + diffFormatted: string; +} + +// ============================================================================ +// Context Builder +// ============================================================================ + +export async function buildPRResponseContext( + owner: string, + repo: string, + prNumber: number, + prBranch: string, + repoDir: string, + project: ProjectConfig, + config: CascadeConfig, + log: { info: (msg: string, ctx?: Record) => void }, + agentType: string, + promptBuilder: (prBranch: string, prNumber: number, owner: string, repo: string) => string, + modelOverride?: string, +): Promise { + const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ + agentType, + project, + config, + repoDir, + modelOverride, + configKey: 'review', + }); + + log.info('Fetching PR details, comments, reviews, issue comments, and diff', { + owner, + repo, + prNumber, + }); + const prDetails = await githubClient.getPR(owner, repo, prNumber); + const prComments = await githubClient.getPRReviewComments(owner, repo, prNumber); + const prReviews = await githubClient.getPRReviews(owner, repo, prNumber); + const prIssueComments = await githubClient.getPRIssueComments(owner, repo, prNumber); + const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); + + const prDetailsFormatted = formatPRDetails(prDetails); + const commentsFormatted = formatPRComments(prComments); + const reviewsFormatted = formatPRReviews(prReviews); + const issueCommentsFormatted = formatPRIssueComments(prIssueComments); + const diffFormatted = formatPRDiff(prDiff); + + const prompt = promptBuilder(prBranch, prNumber, owner, repo); + + return { + systemPrompt, + model, + maxIterations, + contextFiles, + prDetailsFormatted, + commentsFormatted, + reviewsFormatted, + issueCommentsFormatted, + diffFormatted, + prompt, + }; +} + +// ============================================================================ +// Prompt Builder +// ============================================================================ + +export function buildPRResponsePrompt( + prBranch: string, + prNumber: number, + owner: string, + repo: string, + instructionLine: string, + gadgetNames: string, +): string { + return `You are on the branch \`${prBranch}\` for PR #${prNumber}. + +${instructionLine} + +## GitHub Context + +Owner: ${owner} +Repo: ${repo} +PR Number: ${prNumber} + +Use these values when calling GitHub gadgets (${gadgetNames}).`; +} + +// ============================================================================ +// Initial Comment Handler +// ============================================================================ + +export async function postInitialPRResponseComment( + input: PRResponseAgentInput, + id: RepoIdentifier, + headerMessage: string, +): Promise { + if (input.acknowledgmentCommentId) { + const comment = await githubClient.updatePRComment( + id.owner, + id.repo, + input.acknowledgmentCommentId, + headerMessage, + ); + return { id: comment.id, htmlUrl: comment.htmlUrl, gadgetName: 'UpdatePRComment' }; + } + return createInitialPRComment(input.prNumber, id, headerMessage); +} + +// ============================================================================ +// Synthetic Call Injection +// ============================================================================ + +/** Default comment descriptions used by respond-to-review. */ +const DEFAULT_COMMENT_DESCRIPTIONS = { + prComments: 'Pre-fetching line-specific review comments to address', + prReviews: 'Pre-fetching review submissions (approve/request changes with body text)', + prIssueComments: 'Pre-fetching general PR comments (issue-style conversation)', +}; + +export interface InjectPRResponseSyntheticCallsParams { + builder: BuilderType; + ctx: PRResponseContextData; + trackingContext: TrackingContext; + repoDir: string; + id: RepoIdentifier; + input: PRResponseAgentInput; +} + +export interface InjectPRResponseSyntheticCallsOptions { + /** Callback to inject additional synthetic calls before the standard PR data calls. */ + preSyntheticCalls?: ( + builder: BuilderType, + trackingContext: TrackingContext, + input: PRResponseAgentInput, + ) => BuilderType; + /** Override default comment descriptions for specific calls. */ + commentDescriptions?: Partial; +} + +export function injectPRResponseSyntheticCalls( + params: InjectPRResponseSyntheticCallsParams, + options?: InjectPRResponseSyntheticCallsOptions, +): BuilderType { + const { ctx, trackingContext, repoDir, input } = params; + const { owner, repo } = params.id; + const descriptions = { ...DEFAULT_COMMENT_DESCRIPTIONS, ...options?.commentDescriptions }; + + let b = injectDirectoryListing(params.builder, trackingContext); + + if (options?.preSyntheticCalls) { + b = options.preSyntheticCalls(b, trackingContext, input); + } + + b = injectSyntheticCall( + b, + trackingContext, + 'GetPRDetails', + { comment: 'Pre-fetching PR details for context', owner, repo, prNumber: input.prNumber }, + ctx.prDetailsFormatted, + 'gc_pr_details', + ); + + b = injectSyntheticCall( + b, + trackingContext, + 'GetPRComments', + { comment: descriptions.prComments, owner, repo, prNumber: input.prNumber }, + ctx.commentsFormatted, + 'gc_pr_comments', + ); + + b = injectSyntheticCall( + b, + trackingContext, + 'GetPRReviews', + { comment: descriptions.prReviews, owner, repo, prNumber: input.prNumber }, + ctx.reviewsFormatted, + 'gc_pr_reviews', + ); + + b = injectSyntheticCall( + b, + trackingContext, + 'GetPRIssueComments', + { comment: descriptions.prIssueComments, owner, repo, prNumber: input.prNumber }, + ctx.issueCommentsFormatted, + 'gc_pr_issue_comments', + ); + + b = injectSyntheticCall( + b, + trackingContext, + 'GetPRDiff', + { comment: 'Pre-fetching PR diff for context', owner, repo, prNumber: input.prNumber }, + ctx.diffFormatted, + 'gc_pr_diff', + ); + + b = injectContextFiles(b, trackingContext, ctx.contextFiles); + b = injectSquintContext(b, trackingContext, repoDir); + + return b; +} diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index f04877e7..953a35e2 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -1 +1,41 @@ -{"version":"7","dialect":"postgresql","entries":[]} \ No newline at end of file +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1736000000000, + "tag": "0001_three_tier_normalization", + "breakpoints": false + }, + { + "idx": 1, + "version": "7", + "when": 1737000000000, + "tag": "0002_agent_run_tracking", + "breakpoints": false + }, + { + "idx": 2, + "version": "7", + "when": 1738000000000, + "tag": "0003_organizations_and_credentials", + "breakpoints": false + }, + { + "idx": 3, + "version": "7", + "when": 1739000000000, + "tag": "0004_agent_credential_overrides", + "breakpoints": false + }, + { + "idx": 4, + "version": "7", + "when": 1740000000000, + "tag": "0005_config_schema_cleanup", + "breakpoints": false + } + ] +} diff --git a/tests/unit/agents/shared/prResponseAgent.test.ts b/tests/unit/agents/shared/prResponseAgent.test.ts new file mode 100644 index 00000000..9065b763 --- /dev/null +++ b/tests/unit/agents/shared/prResponseAgent.test.ts @@ -0,0 +1,399 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/github/client.js', () => ({ + githubClient: { + getPR: vi.fn(), + getPRReviewComments: vi.fn(), + getPRReviews: vi.fn(), + getPRIssueComments: vi.fn(), + getPRDiff: vi.fn(), + updatePRComment: vi.fn(), + createPRComment: vi.fn(), + }, +})); + +vi.mock('../../../../src/agents/shared/modelResolution.js', () => ({ + resolveModelConfig: vi.fn(), +})); + +vi.mock('../../../../src/agents/shared/prFormatting.js', () => ({ + formatPRDetails: vi.fn((v) => `details:${v}`), + formatPRComments: vi.fn((v) => `comments:${v}`), + formatPRReviews: vi.fn((v) => `reviews:${v}`), + formatPRIssueComments: vi.fn((v) => `issueComments:${v}`), + formatPRDiff: vi.fn((v) => `diff:${v}`), +})); + +vi.mock('../../../../src/agents/shared/syntheticCalls.js', () => ({ + injectDirectoryListing: vi.fn((_b, _tc) => 'builder-after-dir'), + injectSyntheticCall: vi.fn((_b, _tc, name) => `builder-after-${name}`), + injectContextFiles: vi.fn((_b, _tc, _cf) => 'builder-after-context-files'), + injectSquintContext: vi.fn((_b, _tc, _rd) => 'builder-after-squint'), +})); + +vi.mock('../../../../src/agents/shared/githubAgent.js', () => ({ + createInitialPRComment: vi.fn(), +})); + +import { createInitialPRComment } from '../../../../src/agents/shared/githubAgent.js'; +import { resolveModelConfig } from '../../../../src/agents/shared/modelResolution.js'; +import { + type InjectPRResponseSyntheticCallsParams, + type PRResponseAgentInput, + type PRResponseContextData, + buildPRResponseContext, + buildPRResponsePrompt, + injectPRResponseSyntheticCalls, + postInitialPRResponseComment, +} from '../../../../src/agents/shared/prResponseAgent.js'; +import { + injectContextFiles, + injectDirectoryListing, + injectSquintContext, + injectSyntheticCall, +} from '../../../../src/agents/shared/syntheticCalls.js'; +import { githubClient } from '../../../../src/github/client.js'; + +const mockGithub = vi.mocked(githubClient); +const mockResolveModelConfig = vi.mocked(resolveModelConfig); +const mockCreateInitialPRComment = vi.mocked(createInitialPRComment); +const mockInjectDirectoryListing = vi.mocked(injectDirectoryListing); +const mockInjectSyntheticCall = vi.mocked(injectSyntheticCall); +const mockInjectContextFiles = vi.mocked(injectContextFiles); +const mockInjectSquintContext = vi.mocked(injectSquintContext); + +describe('prResponseAgent shared module', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ======================================================================== + // buildPRResponsePrompt + // ======================================================================== + + describe('buildPRResponsePrompt', () => { + it('generates prompt with the correct template values', () => { + const result = buildPRResponsePrompt( + 'feature/xyz', + 42, + 'myorg', + 'myrepo', + 'Address the review comments.', + 'GetPRComments, ReplyToReviewComment', + ); + + expect(result).toContain('`feature/xyz`'); + expect(result).toContain('PR #42'); + expect(result).toContain('Address the review comments.'); + expect(result).toContain('Owner: myorg'); + expect(result).toContain('Repo: myrepo'); + expect(result).toContain('PR Number: 42'); + expect(result).toContain('GetPRComments, ReplyToReviewComment'); + }); + + it('uses the instruction line and gadget names provided', () => { + const result = buildPRResponsePrompt( + 'fix/bug', + 7, + 'owner', + 'repo', + 'A user @mentioned you. Execute their request.', + 'PostPRComment, UpdatePRComment', + ); + + expect(result).toContain('A user @mentioned you. Execute their request.'); + expect(result).toContain('PostPRComment, UpdatePRComment'); + }); + }); + + // ======================================================================== + // postInitialPRResponseComment + // ======================================================================== + + describe('postInitialPRResponseComment', () => { + const id = { owner: 'org', repo: 'repo' }; + const baseInput = { + prNumber: 10, + prBranch: 'feat', + repoFullName: 'org/repo', + triggerCommentId: 1, + triggerCommentBody: 'body', + triggerCommentPath: 'path', + triggerCommentUrl: 'url', + } as PRResponseAgentInput; + + it('updates existing comment when acknowledgmentCommentId is set', async () => { + const input = { ...baseInput, acknowledgmentCommentId: 555 }; + mockGithub.updatePRComment.mockResolvedValue({ + id: 555, + htmlUrl: 'https://example.com/555', + } as ReturnType extends Promise ? R : never); + + const result = await postInitialPRResponseComment(input, id, 'header'); + + expect(mockGithub.updatePRComment).toHaveBeenCalledWith('org', 'repo', 555, 'header'); + expect(result).toEqual({ + id: 555, + htmlUrl: 'https://example.com/555', + gadgetName: 'UpdatePRComment', + }); + }); + + it('creates a new comment when no acknowledgmentCommentId', async () => { + mockCreateInitialPRComment.mockResolvedValue({ + id: 999, + htmlUrl: 'https://example.com/999', + gadgetName: 'PostPRComment', + }); + + const result = await postInitialPRResponseComment(baseInput, id, 'header'); + + expect(mockCreateInitialPRComment).toHaveBeenCalledWith(10, id, 'header'); + expect(result).toEqual({ + id: 999, + htmlUrl: 'https://example.com/999', + gadgetName: 'PostPRComment', + }); + }); + }); + + // ======================================================================== + // buildPRResponseContext + // ======================================================================== + + describe('buildPRResponseContext', () => { + const mockLog = { info: vi.fn() }; + + beforeEach(() => { + mockResolveModelConfig.mockResolvedValue({ + systemPrompt: 'sys', + model: 'gpt-4', + maxIterations: 10, + contextFiles: [{ path: 'CLAUDE.md', content: '# test' }], + }); + + mockGithub.getPR.mockResolvedValue('pr-raw' as never); + mockGithub.getPRReviewComments.mockResolvedValue('comments-raw' as never); + mockGithub.getPRReviews.mockResolvedValue('reviews-raw' as never); + mockGithub.getPRIssueComments.mockResolvedValue('issue-comments-raw' as never); + mockGithub.getPRDiff.mockResolvedValue('diff-raw' as never); + }); + + it('resolves model config with the correct agent type and configKey', async () => { + const promptBuilder = vi.fn().mockReturnValue('prompt'); + + await buildPRResponseContext( + 'org', + 'repo', + 42, + 'feat', + '/tmp/repo', + { id: 'proj' } as never, + { defaults: {} } as never, + mockLog, + 'respond-to-review', + promptBuilder, + ); + + expect(mockResolveModelConfig).toHaveBeenCalledWith({ + agentType: 'respond-to-review', + project: { id: 'proj' }, + config: { defaults: {} }, + repoDir: '/tmp/repo', + modelOverride: undefined, + configKey: 'review', + }); + }); + + it('fetches all 5 PR endpoints', async () => { + const promptBuilder = vi.fn().mockReturnValue('prompt'); + + await buildPRResponseContext( + 'org', + 'repo', + 42, + 'feat', + '/tmp/repo', + { id: 'proj' } as never, + { defaults: {} } as never, + mockLog, + 'respond-to-review', + promptBuilder, + ); + + expect(mockGithub.getPR).toHaveBeenCalledWith('org', 'repo', 42); + expect(mockGithub.getPRReviewComments).toHaveBeenCalledWith('org', 'repo', 42); + expect(mockGithub.getPRReviews).toHaveBeenCalledWith('org', 'repo', 42); + expect(mockGithub.getPRIssueComments).toHaveBeenCalledWith('org', 'repo', 42); + expect(mockGithub.getPRDiff).toHaveBeenCalledWith('org', 'repo', 42); + }); + + it('returns combined context data with formatted values', async () => { + const promptBuilder = vi.fn().mockReturnValue('my-prompt'); + + const result = await buildPRResponseContext( + 'org', + 'repo', + 42, + 'feat', + '/tmp/repo', + { id: 'proj' } as never, + { defaults: {} } as never, + mockLog, + 'respond-to-review', + promptBuilder, + ); + + expect(result).toEqual({ + systemPrompt: 'sys', + model: 'gpt-4', + maxIterations: 10, + contextFiles: [{ path: 'CLAUDE.md', content: '# test' }], + prDetailsFormatted: 'details:pr-raw', + commentsFormatted: 'comments:comments-raw', + reviewsFormatted: 'reviews:reviews-raw', + issueCommentsFormatted: 'issueComments:issue-comments-raw', + diffFormatted: 'diff:diff-raw', + prompt: 'my-prompt', + }); + }); + + it('passes modelOverride through to resolveModelConfig', async () => { + const promptBuilder = vi.fn().mockReturnValue('prompt'); + + await buildPRResponseContext( + 'org', + 'repo', + 42, + 'feat', + '/tmp/repo', + { id: 'proj' } as never, + { defaults: {} } as never, + mockLog, + 'respond-to-pr-comment', + promptBuilder, + 'custom-model', + ); + + expect(mockResolveModelConfig).toHaveBeenCalledWith( + expect.objectContaining({ + modelOverride: 'custom-model', + agentType: 'respond-to-pr-comment', + }), + ); + }); + }); + + // ======================================================================== + // injectPRResponseSyntheticCalls + // ======================================================================== + + describe('injectPRResponseSyntheticCalls', () => { + const baseParams: InjectPRResponseSyntheticCallsParams = { + builder: 'initial-builder' as never, + ctx: { + prDetailsFormatted: 'pd', + commentsFormatted: 'c', + reviewsFormatted: 'r', + issueCommentsFormatted: 'ic', + diffFormatted: 'd', + contextFiles: [], + systemPrompt: 'sys', + model: 'm', + maxIterations: 5, + prompt: 'p', + }, + trackingContext: {} as never, + repoDir: '/tmp/repo', + id: { owner: 'org', repo: 'repo' }, + input: { prNumber: 42 } as PRResponseAgentInput, + }; + + it('injects calls in correct order: dir → PR details → comments → reviews → issue comments → diff → context files → squint', () => { + injectPRResponseSyntheticCalls(baseParams); + + expect(mockInjectDirectoryListing).toHaveBeenCalledTimes(1); + + const syntheticNames = mockInjectSyntheticCall.mock.calls.map((c) => c[2]); + expect(syntheticNames).toEqual([ + 'GetPRDetails', + 'GetPRComments', + 'GetPRReviews', + 'GetPRIssueComments', + 'GetPRDiff', + ]); + + expect(mockInjectContextFiles).toHaveBeenCalledTimes(1); + expect(mockInjectSquintContext).toHaveBeenCalledTimes(1); + }); + + it('uses default comment descriptions (respond-to-review style)', () => { + injectPRResponseSyntheticCalls(baseParams); + + const commentsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRComments'); + expect(commentsCall?.[3]).toEqual( + expect.objectContaining({ + comment: 'Pre-fetching line-specific review comments to address', + }), + ); + + const reviewsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRReviews'); + expect(reviewsCall?.[3]).toEqual( + expect.objectContaining({ + comment: 'Pre-fetching review submissions (approve/request changes with body text)', + }), + ); + + const issueCommentsCall = mockInjectSyntheticCall.mock.calls.find( + (c) => c[2] === 'GetPRIssueComments', + ); + expect(issueCommentsCall?.[3]).toEqual( + expect.objectContaining({ + comment: 'Pre-fetching general PR comments (issue-style conversation)', + }), + ); + }); + + it('calls preSyntheticCalls callback before standard calls', () => { + const preSyntheticCalls = vi.fn().mockReturnValue('builder-after-pre'); + + injectPRResponseSyntheticCalls(baseParams, { preSyntheticCalls }); + + expect(preSyntheticCalls).toHaveBeenCalledTimes(1); + expect(preSyntheticCalls).toHaveBeenCalledWith( + 'builder-after-dir', + baseParams.trackingContext, + baseParams.input, + ); + }); + + it('overrides comment descriptions when provided', () => { + injectPRResponseSyntheticCalls(baseParams, { + commentDescriptions: { + prComments: 'Pre-fetching line-specific review comments for context', + prReviews: 'Pre-fetching review submissions for context', + prIssueComments: 'Pre-fetching general PR comments for context', + }, + }); + + const commentsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRComments'); + expect(commentsCall?.[3]).toEqual( + expect.objectContaining({ + comment: 'Pre-fetching line-specific review comments for context', + }), + ); + + const reviewsCall = mockInjectSyntheticCall.mock.calls.find((c) => c[2] === 'GetPRReviews'); + expect(reviewsCall?.[3]).toEqual( + expect.objectContaining({ comment: 'Pre-fetching review submissions for context' }), + ); + + const issueCommentsCall = mockInjectSyntheticCall.mock.calls.find( + (c) => c[2] === 'GetPRIssueComments', + ); + expect(issueCommentsCall?.[3]).toEqual( + expect.objectContaining({ comment: 'Pre-fetching general PR comments for context' }), + ); + }); + }); +}); diff --git a/tools/db-bootstrap-journal.ts b/tools/db-bootstrap-journal.ts new file mode 100644 index 00000000..091b4153 --- /dev/null +++ b/tools/db-bootstrap-journal.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env tsx +/** + * Bootstrap the drizzle migration journal in the database. + * + * This script reads the local migration journal (meta/_journal.json) and + * inserts rows into drizzle.__drizzle_migrations for any migrations that + * are not yet tracked. This is needed when a database was initially set up + * with `drizzle-kit push` (no journal) and later switched to `drizzle-kit migrate`. + * + * Safe to run multiple times — only inserts migrations with timestamps newer + * than the latest tracked migration. + * + * Usage: + * npx tsx tools/db-bootstrap-journal.ts + */ + +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { sql } from 'drizzle-orm'; +import { closeDb, getDb } from '../src/db/client.js'; + +interface JournalEntry { + idx: number; + version: string; + when: number; + tag: string; + breakpoints: boolean; +} + +interface Journal { + version: string; + dialect: string; + entries: JournalEntry[]; +} + +const MIGRATIONS_DIR = 'src/db/migrations'; + +async function main() { + const db = getDb(); + + // Ensure schema and table exist + await db.execute(sql`CREATE SCHEMA IF NOT EXISTS drizzle`); + await db.execute(sql` + CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at bigint + ) + `); + + // Read current DB state + const dbRows = await db.execute( + sql`SELECT id, hash, created_at FROM drizzle.__drizzle_migrations ORDER BY created_at DESC LIMIT 1`, + ); + const lastApplied = dbRows.rows[0] as { created_at: string } | undefined; + const lastTimestamp = lastApplied ? Number(lastApplied.created_at) : 0; + console.log(`Last tracked migration timestamp: ${lastTimestamp || '(none)'}`); + + // Read journal + const journalPath = `${MIGRATIONS_DIR}/meta/_journal.json`; + const journal: Journal = JSON.parse(readFileSync(journalPath, 'utf-8')); + console.log(`Journal has ${journal.entries.length} entries`); + + // Insert missing entries + let inserted = 0; + for (const entry of journal.entries) { + if (entry.when <= lastTimestamp) { + console.log(` skip: ${entry.tag} (already tracked)`); + continue; + } + + const sqlContent = readFileSync(`${MIGRATIONS_DIR}/${entry.tag}.sql`, 'utf-8'); + const hash = createHash('sha256').update(sqlContent).digest('hex'); + + await db.execute( + sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${hash}, ${entry.when})`, + ); + console.log(` inserted: ${entry.tag} (hash=${hash.slice(0, 12)}..., when=${entry.when})`); + inserted++; + } + + console.log(`\nDone. Inserted ${inserted} migration(s).`); + await closeDb(); +} + +main().catch((err) => { + console.error('Error:', err); + process.exit(1); +});