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:
Janpeter Visser 2026-05-01 13:30:38 +02:00
parent dadcbc48d6
commit 1015264558
5 changed files with 252 additions and 0 deletions

View file

@ -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,