feat(ST-mhj9f2la): add mark_pbi_pr_merged MCP tool

- Implement mark_pbi_pr_merged: sets pr_merged_at = now() on a PBI
- Requires pr_url to be set; returns error if not (geen gekoppelde PR)
- Idempotent: re-calling overwrites the timestamp
- AuthZ via requireWriteAccess + userCanAccessProduct through pbi.product_id
- 6 tests: happy path, no-pr_url, idempotent, no-access, not-found, demo-denied
- Update README tools table with mark_pbi_pr_merged entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-03 15:58:01 +02:00
parent 5265442e6f
commit d6bfdb1884
4 changed files with 164 additions and 0 deletions

View file

@ -29,6 +29,7 @@ activity and create todos via native tool calls instead of curl.
| `update_job_status` | Report job transition to `running`, `done`, or `failed`; triggers SSE event to UI; cleans up worktree on terminal transitions | no |
| `verify_task_against_plan` | Compare frozen `plan_snapshot` against current plan + story logs + commits; returns per-AC ✓/✗/? heuristic and drift-score | yes (read-only) |
| `set_pbi_pr` | Write `pr_url` on a PBI and clear `pr_merged_at`. Idempotent: re-calling overwrites `pr_url` and resets `pr_merged_at` to null | no |
| `mark_pbi_pr_merged` | Set `pr_merged_at = now()` on a PBI. Requires `pr_url` to already be set. Idempotent: re-calling overwrites the timestamp | no |
Demo accounts may read but writes return `PERMISSION_DENIED`.

View file

@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
pbi: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', () => ({
requireWriteAccess: vi.fn(),
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(message = 'Demo accounts cannot perform write operations') {
super(message)
this.name = 'PermissionDeniedError'
}
},
}))
vi.mock('../src/access.js', () => ({
userCanAccessProduct: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess, PermissionDeniedError } from '../src/auth.js'
import { userCanAccessProduct } from '../src/access.js'
import { handleMarkPbiPrMerged } from '../src/tools/mark-pbi-pr-merged.js'
const mockPrisma = prisma as unknown as {
pbi: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
}
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
const PBI_ID = 'pbi-abc123'
const PR_URL = 'https://github.com/owner/repo/pull/42'
const MERGED_AT = new Date('2026-05-03T12:00:00Z')
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({ userId: 'user-1', tokenId: 'tok-1', username: 'alice', isDemo: false })
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: 'prod-1', pr_url: PR_URL })
mockUserCanAccessProduct.mockResolvedValue(true)
mockPrisma.pbi.update.mockResolvedValue({ id: PBI_ID, pr_url: PR_URL, pr_merged_at: MERGED_AT })
})
describe('handleMarkPbiPrMerged', () => {
it('happy path: sets pr_merged_at and returns ok', async () => {
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(result.isError).toBeFalsy()
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: PBI_ID },
data: { pr_merged_at: expect.any(Date) },
select: { id: true, pr_url: true, pr_merged_at: true },
})
const text = result.content[0].type === 'text' ? result.content[0].text : ''
const parsed = JSON.parse(text)
expect(parsed.ok).toBe(true)
expect(parsed.pbi_id).toBe(PBI_ID)
expect(parsed.pr_url).toBe(PR_URL)
})
it('returns error when PBI has no pr_url', async () => {
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: 'prod-1', pr_url: null })
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(result.isError).toBe(true)
const text = result.content[0].type === 'text' ? result.content[0].text : ''
expect(text).toMatch(/geen gekoppelde PR/)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('idempotent: re-calling overwrites pr_merged_at timestamp', async () => {
await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(mockPrisma.pbi.update).toHaveBeenCalledTimes(2)
expect(mockPrisma.pbi.update.mock.calls[0][0].data.pr_merged_at).toBeInstanceOf(Date)
expect(mockPrisma.pbi.update.mock.calls[1][0].data.pr_merged_at).toBeInstanceOf(Date)
})
it('returns error when user has no access', async () => {
mockUserCanAccessProduct.mockResolvedValue(false)
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(result.isError).toBe(true)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('returns error when PBI not found', async () => {
mockPrisma.pbi.findUnique.mockResolvedValue(null)
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(result.isError).toBe(true)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('returns PERMISSION_DENIED for demo accounts', async () => {
mockRequireWriteAccess.mockRejectedValue(new PermissionDeniedError())
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(result.isError).toBe(true)
const text = result.content[0].type === 'text' ? result.content[0].text : ''
expect(text).toMatch(/PERMISSION_DENIED/)
})
})

View file

@ -22,6 +22,7 @@ import { registerUpdateJobStatusTool } from './tools/update-job-status.js'
import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js'
import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js'
import { registerSetPbiPrTool } from './tools/set-pbi-pr.js'
import { registerMarkPbiPrMergedTool } from './tools/mark-pbi-pr-merged.js'
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
import { getAuth } from './auth.js'
import { registerWorker } from './presence/worker.js'
@ -61,6 +62,7 @@ async function main() {
registerVerifyTaskAgainstPlanTool(server)
registerCleanupMyWorktreesTool(server)
registerSetPbiPrTool(server)
registerMarkPbiPrMergedTool(server)
registerImplementNextStoryPrompt(server)
// Presence bootstrap MUST run before server.connect — the stdio transport

View file

@ -0,0 +1,48 @@
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const inputSchema = z.object({
pbi_id: z.string().min(1),
})
export async function handleMarkPbiPrMerged({ pbi_id }: z.infer<typeof inputSchema>) {
return withToolErrors(async () => {
const auth = await requireWriteAccess()
const pbi = await prisma.pbi.findUnique({
where: { id: pbi_id },
select: { product_id: true, pr_url: true },
})
if (!pbi || !(await userCanAccessProduct(pbi.product_id, auth.userId))) {
return toolError(`PBI ${pbi_id} not found or not accessible`)
}
if (!pbi.pr_url) {
return toolError(`PBI ${pbi_id} heeft geen gekoppelde PR`)
}
const updated = await prisma.pbi.update({
where: { id: pbi_id },
data: { pr_merged_at: new Date() },
select: { id: true, pr_url: true, pr_merged_at: true },
})
return toolJson({ ok: true, pbi_id, pr_url: updated.pr_url, pr_merged_at: updated.pr_merged_at })
})
}
export function registerMarkPbiPrMergedTool(server: McpServer) {
server.registerTool(
'mark_pbi_pr_merged',
{
title: 'Mark PBI PR Merged',
description:
'Set pr_merged_at = now() on a PBI, signalling the PR has been merged. Requires pr_url to already be set. Idempotent: re-calling overwrites the timestamp. Forbidden for demo accounts.',
inputSchema,
},
handleMarkPbiPrMerged,
)
}