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
57
README.md
57
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 <id> 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 <id> not found or not accessible` |
|
||||
| `pr_url` not set | `PBI <id> 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
|
||||
|
|
|
|||
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/)
|
||||
})
|
||||
})
|
||||
129
__tests__/set-pbi-pr.test.ts
Normal file
129
__tests__/set-pbi-pr.test.ts
Normal file
|
|
@ -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<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 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)
|
||||
})
|
||||
})
|
||||
34
package-lock.json
generated
34
package-lock.json
generated
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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,
|
||||
)
|
||||
}
|
||||
45
src/tools/set-pbi-pr.ts
Normal file
45
src/tools/set-pbi-pr.ts
Normal file
|
|
@ -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<typeof inputSchema>) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue