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:
parent
5265442e6f
commit
d6bfdb1884
4 changed files with 164 additions and 0 deletions
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
113
__tests__/mark-pbi-pr-merged.test.ts
Normal file
113
__tests__/mark-pbi-pr-merged.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
48
src/tools/mark-pbi-pr-merged.ts
Normal file
48
src/tools/mark-pbi-pr-merged.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue