From 1015264558f75c5af10a487a546d9d3c1a39ca5b Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 13:30:38 +0200 Subject: [PATCH] feat(M13): auto-PR via gh CLI after successful push (auto_pr=true) New src/git/pr.ts helper wraps 'gh pr create'; returns { url } or { error }. maybeCreateAutoPr() in update-job-status checks product.auto_pr, builds title from story.code + task.title, writes pr_url to DB. Non-fatal: gh failure logs a warning and leaves DONE status intact. Also syncs schema: auto_pr on Product, pr_url on ClaudeJob. --- __tests__/git/pr.test.ts | 69 +++++++++++++++++ __tests__/update-job-status-auto-pr.test.ts | 85 +++++++++++++++++++++ prisma/schema.prisma | 2 + src/git/pr.ts | 38 +++++++++ src/tools/update-job-status.ts | 58 ++++++++++++++ 5 files changed, 252 insertions(+) create mode 100644 __tests__/git/pr.test.ts create mode 100644 __tests__/update-job-status-auto-pr.test.ts create mode 100644 src/git/pr.ts diff --git a/__tests__/git/pr.test.ts b/__tests__/git/pr.test.ts new file mode 100644 index 0000000..6d8cc72 --- /dev/null +++ b/__tests__/git/pr.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() })) + +vi.mock('node:child_process', () => ({ execFile: mockExecFile })) +vi.mock('node:util', () => ({ + promisify: + (fn: (...args: unknown[]) => void) => + (...args: unknown[]) => + new Promise((resolve, reject) => + fn(...args, (err: Error | null, result: unknown) => (err ? reject(err) : resolve(result))), + ), +})) + +import { createPullRequest } from '../../src/git/pr.js' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('createPullRequest', () => { + it('returns PR URL when gh succeeds', async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: null, res: { stdout: string; stderr: string }) => void) => + cb(null, { stdout: 'Creating pull request...\nhttps://github.com/org/repo/pull/42\n', stderr: '' }), + ) + + const result = await createPullRequest({ + worktreePath: '/worktrees/job-abc', + branchName: 'feat/job-abc', + title: 'SCRUM-1: Add feature', + body: 'Summary\n\n---\n\n*Auto-generated*', + }) + + expect(result).toEqual({ url: 'https://github.com/org/repo/pull/42' }) + }) + + it('returns error when gh is not installed (ENOENT)', async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => + cb(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })), + ) + + const result = await createPullRequest({ + worktreePath: '/worktrees/job-abc', + branchName: 'feat/job-abc', + title: 'My PR', + body: 'Body', + }) + + expect(result).toMatchObject({ error: expect.stringContaining('gh CLI not found') }) + }) + + it('returns error on generic gh failure', async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => + cb(new Error('authentication required')), + ) + + const result = await createPullRequest({ + worktreePath: '/worktrees/job-abc', + branchName: 'feat/job-abc', + title: 'My PR', + body: 'Body', + }) + + expect(result).toMatchObject({ error: expect.stringContaining('gh pr create failed') }) + }) +}) diff --git a/__tests__/update-job-status-auto-pr.test.ts b/__tests__/update-job-status-auto-pr.test.ts new file mode 100644 index 0000000..55db4cf --- /dev/null +++ b/__tests__/update-job-status-auto-pr.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + product: { findUnique: vi.fn() }, + task: { findUnique: vi.fn() }, + }, +})) + +vi.mock('../src/git/pr.js', () => ({ + createPullRequest: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { createPullRequest } from '../src/git/pr.js' +import { maybeCreateAutoPr } from '../src/tools/update-job-status.js' + +const mockPrisma = prisma as unknown as { + product: { findUnique: ReturnType } + task: { findUnique: ReturnType } +} +const mockCreatePr = createPullRequest as ReturnType + +const BASE_OPTS = { + jobId: 'job-abc', + productId: 'prod-1', + taskId: 'task-1', + worktreePath: '/wt/job-abc', + branchName: 'feat/job-abc', + summary: 'Implemented the feature', +} + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true }) + mockPrisma.task.findUnique.mockResolvedValue({ + title: 'Add feature', + story: { code: 'SCRUM-42' }, + }) + mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' }) +}) + +describe('maybeCreateAutoPr', () => { + it('returns PR URL when auto_pr=true and gh succeeds', async () => { + const url = await maybeCreateAutoPr(BASE_OPTS) + expect(url).toBe('https://github.com/org/repo/pull/99') + expect(mockCreatePr).toHaveBeenCalledWith({ + worktreePath: BASE_OPTS.worktreePath, + branchName: BASE_OPTS.branchName, + title: 'SCRUM-42: Add feature', + body: expect.stringContaining(BASE_OPTS.summary), + }) + }) + + it('returns null when auto_pr=false', async () => { + mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false }) + const url = await maybeCreateAutoPr(BASE_OPTS) + expect(url).toBeNull() + expect(mockCreatePr).not.toHaveBeenCalled() + }) + + it('uses task title without code prefix when story has no code', async () => { + mockPrisma.task.findUnique.mockResolvedValue({ + title: 'Add feature', + story: { code: null }, + }) + await maybeCreateAutoPr(BASE_OPTS) + expect(mockCreatePr).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Add feature' }), + ) + }) + + it('returns null and does not throw when gh fails', async () => { + mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' }) + const url = await maybeCreateAutoPr(BASE_OPTS) + expect(url).toBeNull() + }) + + it('returns null when product not found', async () => { + mockPrisma.product.findUnique.mockResolvedValue(null) + const url = await maybeCreateAutoPr(BASE_OPTS) + expect(url).toBeNull() + expect(mockCreatePr).not.toHaveBeenCalled() + }) +}) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b26ee00..19ddd81 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -127,6 +127,7 @@ model Product { description String? repo_url String? definition_of_done String + auto_pr Boolean @default(false) archived Boolean @default(false) created_at DateTime @default(now()) updated_at DateTime @updatedAt @@ -267,6 +268,7 @@ model ClaudeJob { pushed_at DateTime? plan_snapshot String? branch String? + pr_url String? summary String? error String? verify_result VerifyResult? diff --git a/src/git/pr.ts b/src/git/pr.ts new file mode 100644 index 0000000..2f98b92 --- /dev/null +++ b/src/git/pr.ts @@ -0,0 +1,38 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +const exec = promisify(execFile) + +export async function createPullRequest(opts: { + worktreePath: string + branchName: string + title: string + body: string +}): Promise<{ url: string } | { error: string }> { + const { worktreePath, branchName, title, body } = opts + + try { + const { stdout } = await exec( + 'gh', + ['pr', 'create', '--title', title, '--body', body, '--head', branchName], + { cwd: worktreePath }, + ) + // gh prints the PR URL as the last non-empty line + const lines = stdout.trim().split('\n').filter(Boolean) + const url = lines[lines.length - 1]?.trim() ?? '' + if (!url.startsWith('http')) { + return { error: `gh pr create produced unexpected output: ${stdout.slice(0, 200)}` } + } + return { url } + } catch (err: unknown) { + const msg = (err as { message?: string }).message ?? String(err) + const isNotFound = + msg.includes('command not found') || + msg.includes('is not recognized') || + msg.includes('ENOENT') + if (isNotFound) { + return { error: 'gh CLI not found — install GitHub CLI to enable auto-PR' } + } + return { error: `gh pr create failed: ${msg.slice(0, 300)}` } + } +} diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 12c85b7..fb5fe17 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -13,6 +13,7 @@ import { toolJson, toolError, withToolErrors } from '../errors.js' import { removeWorktreeForJob } from '../git/worktree.js' import { resolveRepoRoot } from './wait-for-job.js' import { pushBranchForJob } from '../git/push.js' +import { createPullRequest } from '../git/pr.js' const inputSchema = z.object({ job_id: z.string().min(1), @@ -96,6 +97,40 @@ const DB_STATUS_MAP = { failed: 'FAILED', } as const +export async function maybeCreateAutoPr(opts: { + jobId: string + productId: string + taskId: string + worktreePath: string + branchName: string + summary: string | undefined +}): Promise { + const { jobId, productId, taskId, worktreePath, branchName, summary } = opts + + const product = await prisma.product.findUnique({ + where: { id: productId }, + select: { auto_pr: true }, + }) + if (!product?.auto_pr) return null + + const task = await prisma.task.findUnique({ + where: { id: taskId }, + select: { title: true, story: { select: { code: true } } }, + }) + if (!task) return null + + const title = task.story.code ? `${task.story.code}: ${task.title}` : task.title + const body = summary + ? `${summary}\n\n---\n\n*Auto-generated by Scrum4Me agent*` + : '*Auto-generated by Scrum4Me agent*' + + const result = await createPullRequest({ worktreePath, branchName, title, body }) + if ('url' in result) return result.url + + console.warn(`[update_job_status] auto-PR skipped for job ${jobId}:`, result.error) + return null +} + export function registerUpdateJobStatusTool(server: McpServer) { server.registerTool( 'update_job_status', @@ -149,6 +184,25 @@ export function registerUpdateJobStatusTool(server: McpServer) { skipWorktreeCleanup = plan.skipWorktreeCleanup } + // Auto-PR: best-effort, only when push actually happened + let prUrl: string | null = null + if (actualStatus === 'done' && pushedAt && branchToWrite) { + const worktreeDir = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? + path.join(os.homedir(), '.scrum4me-agent-worktrees') + prUrl = await maybeCreateAutoPr({ + jobId: job_id, + productId: job.product_id, + taskId: job.task_id, + worktreePath: path.join(worktreeDir, job_id), + branchName: branchToWrite, + summary, + }).catch((err) => { + console.warn(`[update_job_status] auto-PR error for job ${job_id}:`, err) + return null + }) + } + const dbStatus = DB_STATUS_MAP[actualStatus as keyof typeof DB_STATUS_MAP] const now = new Date() const updated = await prisma.claudeJob.update({ @@ -161,12 +215,14 @@ export function registerUpdateJobStatusTool(server: McpServer) { ...(pushedAt !== undefined ? { pushed_at: pushedAt } : {}), ...(summary !== undefined ? { summary } : {}), ...(errorToWrite !== undefined ? { error: errorToWrite } : {}), + ...(prUrl !== null ? { pr_url: prUrl } : {}), }, select: { id: true, status: true, branch: true, pushed_at: true, + pr_url: true, summary: true, error: true, started_at: true, @@ -190,6 +246,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { status: actualStatus, branch: updated.branch ?? undefined, pushed_at: updated.pushed_at?.toISOString() ?? undefined, + pr_url: updated.pr_url ?? undefined, summary: updated.summary ?? undefined, error: updated.error ?? undefined, }), @@ -210,6 +267,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { status: actualStatus, branch: updated.branch, pushed_at: updated.pushed_at?.toISOString() ?? null, + pr_url: updated.pr_url ?? null, summary: updated.summary, error: updated.error, started_at: updated.started_at?.toISOString() ?? null,