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.
This commit is contained in:
parent
dadcbc48d6
commit
1015264558
5 changed files with 252 additions and 0 deletions
69
__tests__/git/pr.test.ts
Normal file
69
__tests__/git/pr.test.ts
Normal file
|
|
@ -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') })
|
||||
})
|
||||
})
|
||||
85
__tests__/update-job-status-auto-pr.test.ts
Normal file
85
__tests__/update-job-status-auto-pr.test.ts
Normal file
|
|
@ -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<typeof vi.fn> }
|
||||
task: { findUnique: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
const mockCreatePr = createPullRequest as ReturnType<typeof vi.fn>
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
38
src/git/pr.ts
Normal file
38
src/git/pr.ts
Normal file
|
|
@ -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)}` }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string | null> {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue