diff --git a/README.md b/README.md index 047b98e..62e1462 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ activity and create todos via native tool calls instead of curl. | `wait_for_job` | Block until a QUEUED ClaudeJob is available, claim it atomically, return full task context with frozen `plan_snapshot`, `worktree_path`, and `branch_name` | no | | `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`. @@ -71,6 +73,61 @@ Compares the immutable snapshot captured at claim time against the current state - Plan_snapshot is NULL voor jobs die zijn geclaimed vóór versie met snapshot-feature — rapport meldt "no baseline" - Gebruik het rapport als startpunt, niet als definitief oordeel; PR-review blijft leidend +### set_pbi_pr + +Links a GitHub Pull Request to a PBI and clears any previous merge timestamp. Safe to call multiple times — idempotent. + +**Input** + +```json +{ "pbi_id": "cmoprewcf000q...", "pr_url": "https://github.com/owner/repo/pull/42" } +``` + +`pr_url` must match `^https://github\.com/[^/]+/[^/]+/pull/\d+$`. Any other format is rejected with a schema error. + +**Output** + +```json +{ "ok": true, "pbi_id": "cmoprewcf000q...", "pr_url": "https://github.com/owner/repo/pull/42" } +``` + +**Errors** + +| Condition | Message | +|---|---| +| PBI not found or inaccessible | `PBI not found or not accessible` | +| Demo account | `PERMISSION_DENIED: Demo accounts cannot perform write operations` | +| Invalid URL format | `VALIDATION_ERROR: pr_url: Invalid` | + +### mark_pbi_pr_merged + +Records that the linked PR has been merged by setting `pr_merged_at = now()`. Requires `set_pbi_pr` to have been called first. Idempotent: re-calling overwrites the timestamp. + +**Input** + +```json +{ "pbi_id": "cmoprewcf000q..." } +``` + +**Output** + +```json +{ + "ok": true, + "pbi_id": "cmoprewcf000q...", + "pr_url": "https://github.com/owner/repo/pull/42", + "pr_merged_at": "2026-05-03T12:00:00.000Z" +} +``` + +**Errors** + +| Condition | Message | +|---|---| +| PBI not found or inaccessible | `PBI not found or not accessible` | +| `pr_url` not set | `PBI heeft geen gekoppelde PR` | +| Demo account | `PERMISSION_DENIED: Demo accounts cannot perform write operations` | + ## Prompts - `implement_next_story` — full workflow: fetch context, log plan, walk 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/__tests__/set-pbi-pr.test.ts b/__tests__/set-pbi-pr.test.ts new file mode 100644 index 0000000..e9bc30e --- /dev/null +++ b/__tests__/set-pbi-pr.test.ts @@ -0,0 +1,129 @@ +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 { handleSetPbiPr, inputSchema } from '../src/tools/set-pbi-pr.js' + +const mockPrisma = prisma as unknown as { + pbi: { findUnique: ReturnType; update: ReturnType } +} +const mockRequireWriteAccess = requireWriteAccess as ReturnType +const mockUserCanAccessProduct = userCanAccessProduct as ReturnType + +const VALID_PR_URL = 'https://github.com/owner/repo/pull/42' +const PBI_ID = 'pbi-abc123' +const USER_ID = 'user-1' + +beforeEach(() => { + vi.clearAllMocks() + mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false }) + mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: 'prod-1' }) + mockUserCanAccessProduct.mockResolvedValue(true) + mockPrisma.pbi.update.mockResolvedValue({}) +}) + +describe('handleSetPbiPr', () => { + it('happy path: updates pr_url and clears pr_merged_at', async () => { + const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL }) + + expect(result.isError).toBeFalsy() + expect(mockPrisma.pbi.update).toHaveBeenCalledWith({ + where: { id: PBI_ID }, + data: { pr_url: VALID_PR_URL, pr_merged_at: null }, + }) + const text = result.content[0].type === 'text' ? result.content[0].text : '' + const parsed = JSON.parse(text) + expect(parsed).toEqual({ ok: true, pbi_id: PBI_ID, pr_url: VALID_PR_URL }) + }) + + it('idempotent: second call with different url overwrites', async () => { + const newUrl = 'https://github.com/owner/repo/pull/99' + await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: newUrl }) + + expect(mockPrisma.pbi.update).toHaveBeenCalledWith({ + where: { id: PBI_ID }, + data: { pr_url: newUrl, pr_merged_at: null }, + }) + }) + + it('returns error when PBI not found', async () => { + mockPrisma.pbi.findUnique.mockResolvedValue(null) + + const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL }) + + expect(result.isError).toBe(true) + const text = result.content[0].type === 'text' ? result.content[0].text : '' + expect(text).toMatch(PBI_ID) + expect(mockPrisma.pbi.update).not.toHaveBeenCalled() + }) + + it('returns error when user has no access to the product', async () => { + mockUserCanAccessProduct.mockResolvedValue(false) + + const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL }) + + 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 handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL }) + + expect(result.isError).toBe(true) + const text = result.content[0].type === 'text' ? result.content[0].text : '' + expect(text).toMatch(/PERMISSION_DENIED/) + }) +}) + +describe('inputSchema validation', () => { + it('accepts a valid GitHub PR URL', () => { + const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: VALID_PR_URL }) + expect(r.success).toBe(true) + }) + + it('rejects a URL pointing to an issue instead of a pull', () => { + const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://github.com/owner/repo/issues/42' }) + expect(r.success).toBe(false) + }) + + it('rejects a non-GitHub URL', () => { + const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://gitlab.com/owner/repo/pull/42' }) + expect(r.success).toBe(false) + }) + + it('rejects a URL without a numeric PR number', () => { + const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://github.com/owner/repo/pull/abc' }) + expect(r.success).toBe(false) + }) + + it('rejects an empty pbi_id', () => { + const r = inputSchema.safeParse({ pbi_id: '', pr_url: VALID_PR_URL }) + expect(r.success).toBe(false) + }) +}) diff --git a/package-lock.json b/package-lock.json index 936c214..5dc073b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrum4me-mcp", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me-mcp", - "version": "0.1.0", + "version": "0.2.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1092,9 +1092,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1112,9 +1109,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1132,9 +1126,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1152,9 +1143,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1172,9 +1160,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1192,9 +1177,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2659,9 +2641,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2683,9 +2662,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2707,9 +2683,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2731,9 +2704,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index a1c0135..e9e3420 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrum4me-mcp", - "version": "0.1.0", + "version": "0.2.0", "description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol", "type": "module", "bin": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 489b23f..2dde3c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -161,8 +161,10 @@ model Pbi { description String? priority Int sort_order Float - status PbiStatus @default(READY) - created_at DateTime @default(now()) + status PbiStatus @default(READY) + pr_url String? + pr_merged_at DateTime? + created_at DateTime @default(now()) updated_at DateTime @updatedAt stories Story[] diff --git a/src/index.ts b/src/index.ts index 0d287f7..b88532c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,13 +21,15 @@ import { registerWaitForJobTool } from './tools/wait-for-job.js' 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' import { startHeartbeat } from './presence/heartbeat.js' import { registerShutdownHandlers } from './presence/shutdown.js' -const VERSION = '0.1.0' +const VERSION = '0.2.0' async function main() { const server = new McpServer( @@ -59,6 +61,8 @@ async function main() { registerUpdateJobStatusTool(server) 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, + ) +} diff --git a/src/tools/set-pbi-pr.ts b/src/tools/set-pbi-pr.ts new file mode 100644 index 0000000..ec0a6ef --- /dev/null +++ b/src/tools/set-pbi-pr.ts @@ -0,0 +1,45 @@ +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' + +export const inputSchema = z.object({ + pbi_id: z.string().min(1), + pr_url: z.string().regex(/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/), +}) + +export async function handleSetPbiPr({ pbi_id, pr_url }: z.infer) { + return withToolErrors(async () => { + const auth = await requireWriteAccess() + + const pbi = await prisma.pbi.findUnique({ + where: { id: pbi_id }, + select: { product_id: true }, + }) + if (!pbi || !(await userCanAccessProduct(pbi.product_id, auth.userId))) { + return toolError(`PBI ${pbi_id} not found or not accessible`) + } + + await prisma.pbi.update({ + where: { id: pbi_id }, + data: { pr_url, pr_merged_at: null }, + }) + + return toolJson({ ok: true, pbi_id, pr_url }) + }) +} + +export function registerSetPbiPrTool(server: McpServer) { + server.registerTool( + 'set_pbi_pr', + { + title: 'Set PBI PR URL', + description: + 'Write pr_url on a PBI and clear pr_merged_at. Idempotent: re-calling overwrites pr_url and resets pr_merged_at to null. Forbidden for demo accounts.', + inputSchema, + }, + handleSetPbiPr, + ) +}