diff --git a/README.md b/README.md index fe0c9bf..f777a3e 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/__tests__/mark-pbi-pr-merged.test.ts b/__tests__/mark-pbi-pr-merged.test.ts new file mode 100644 index 0000000..0a3e069 --- /dev/null +++ b/__tests__/mark-pbi-pr-merged.test.ts @@ -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; update: ReturnType } +} +const mockRequireWriteAccess = requireWriteAccess as ReturnType +const mockUserCanAccessProduct = userCanAccessProduct as ReturnType + +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/) + }) +}) diff --git a/src/index.ts b/src/index.ts index c9c525a..b88532c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/tools/mark-pbi-pr-merged.ts b/src/tools/mark-pbi-pr-merged.ts new file mode 100644 index 0000000..b659056 --- /dev/null +++ b/src/tools/mark-pbi-pr-merged.ts @@ -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) { + 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, + ) +}