feat(mcp): set_pbi_pr + mark_pbi_pr_merged tools voor PBI-PR-gating (#18)
* feat(ST-mhj9f2la): add set_pbi_pr MCP tool - Add pr_url and pr_merged_at fields to Pbi model in schema - Implement set_pbi_pr tool: writes pr_url, clears pr_merged_at (idempotent) - AuthZ via requireWriteAccess + userCanAccessProduct through pbi.product_id - 10 tests: happy path, not-found, no-access, demo-denied, schema validation - Update README tools table and bump version to 0.2.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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> * docs(ST-mhj9f2la): expand README with set_pbi_pr + mark_pbi_pr_merged docs Add full signature/input/output/error documentation sections for both new tools, following the verify_task_against_plan pattern. Version already bumped to 0.2.0 in earlier commit. Tag + MCP_GIT_REF pin in scrum4me-docker to be done by maintainer after merge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2c85f4d239
commit
3ce2c044c4
9 changed files with 404 additions and 36 deletions
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/)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue