Merge pull request #37 from madhura68/feat/sprint-aolrn6ui

Sprint: pbi-55
This commit is contained in:
Janpeter Visser 2026-05-07 21:46:51 +02:00 committed by GitHub
commit 85a95e5bba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 70 additions and 19 deletions

View file

@ -3,3 +3,9 @@ DATABASE_URL="postgresql://user:pass@host:5432/dbname"
# API token from Scrum4Me → /settings/tokens # API token from Scrum4Me → /settings/tokens
SCRUM4ME_TOKEN="" SCRUM4ME_TOKEN=""
# Internal push endpoint (main-app) for web-push notifications
# Set to the main-app /api/internal/push/send URL; leave empty to disable push (feature-gated).
INTERNAL_PUSH_URL="https://scrum4me.example.com/api/internal/push/send"
# Shared secret (≥32 chars) — must match INTERNAL_PUSH_SECRET in the main-app env.
INTERNAL_PUSH_SECRET="<generate-with: openssl rand -hex 32>"

View file

@ -296,6 +296,10 @@ Minimale agent-prompt (geen CLAUDE.md-context nodig):
> *Pak de volgende job uit de Scrum4Me-queue.* > *Pak de volgende job uit de Scrum4Me-queue.*
## Web-push integration
When `INTERNAL_PUSH_URL` and `INTERNAL_PUSH_SECRET` are set, the MCP server fires a fire-and-forget push notification to the main-app's internal endpoint (`/api/internal/push/send`) on two events: when `ask_user_question` creates a new question (tag `claude-q-<id>`), and when `update_job_status` transitions a job to `done` or `failed` (tag `job-<id>`). Both calls are wrapped in a 5 s `AbortController` timeout and a `try/catch` so a push failure never interrupts the tool response. Omitting the env vars disables the feature entirely. The `INTERNAL_PUSH_SECRET` value must match the one configured in the main-app; generate a fresh secret with `openssl rand -hex 32`.
## Schema sync ## Schema sync
The Prisma schema is the source of truth in the upstream Scrum4Me The Prisma schema is the source of truth in the upstream Scrum4Me

22
src/lib/push-trigger.ts Normal file
View file

@ -0,0 +1,22 @@
export type PushPayload = { title: string; body: string; url: string; tag?: string };
export async function triggerPush(userId: string, payload: PushPayload): Promise<void> {
const url = process.env.INTERNAL_PUSH_URL;
const secret = process.env.INTERNAL_PUSH_SECRET;
if (!url || !secret) return; // feature-gated
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` },
body: JSON.stringify({ userId, payload }),
signal: controller.signal,
});
if (!res.ok) console.warn('[push-trigger] non-2xx', res.status);
} catch (err) {
console.error('[push-trigger]', err);
} finally {
clearTimeout(timeout);
}
}

View file

@ -10,6 +10,7 @@ import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js' import { requireWriteAccess } from '../auth.js'
import { userCanAccessStory, userOwnsIdea } from '../access.js' import { userCanAccessStory, userOwnsIdea } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js' import { toolError, toolJson, withToolErrors } from '../errors.js'
import { triggerPush } from '../lib/push-trigger.js'
const PENDING_TTL_HOURS = 24 const PENDING_TTL_HOURS = 24
const POLL_INTERVAL_MS = 2_000 const POLL_INTERVAL_MS = 2_000
@ -127,6 +128,13 @@ export function registerAskUserQuestionTool(server: McpServer) {
}, },
}) })
void triggerPush(auth.userId, {
title: 'Claude heeft een vraag',
body: question.slice(0, 120),
url: '/notifications',
tag: `claude-q-${created.id}`,
})
// Async-mode (default): return direct. // Async-mode (default): return direct.
if (!wait_seconds || wait_seconds === 0) { if (!wait_seconds || wait_seconds === 0) {
return toolJson(summarize(created)) return toolJson(summarize(created))

View file

@ -24,6 +24,7 @@ import { pushBranchForJob } from '../git/push.js'
import { createPullRequest, markPullRequestReady } from '../git/pr.js' import { createPullRequest, markPullRequestReady } from '../git/pr.js'
import { cancelPbiOnFailure } from '../cancel/pbi-cascade.js' import { cancelPbiOnFailure } from '../cancel/pbi-cascade.js'
import { propagateStatusUpwards } from '../lib/tasks-status-update.js' import { propagateStatusUpwards } from '../lib/tasks-status-update.js'
import { triggerPush } from '../lib/push-trigger.js'
import { transition as prFlowTransition } from '../flow/pr-flow.js' import { transition as prFlowTransition } from '../flow/pr-flow.js'
import { transition as sprintRunTransition } from '../flow/sprint-run.js' import { transition as sprintRunTransition } from '../flow/sprint-run.js'
import { executeEffects } from '../flow/effects.js' import { executeEffects } from '../flow/effects.js'
@ -887,30 +888,40 @@ export function registerUpdateJobStatusTool(server: McpServer) {
try { try {
const pg = new Client({ connectionString: process.env.DATABASE_URL }) const pg = new Client({ connectionString: process.env.DATABASE_URL })
await pg.connect() await pg.connect()
await pg.query( const notifyPayload: Record<string, unknown> = {
`SELECT pg_notify('scrum4me_changes', $1)`, type: 'claude_job_status',
[ job_id: updated.id,
JSON.stringify({ user_id: job.user_id,
type: 'claude_job_status', product_id: job.product_id,
job_id: updated.id, status: actualStatus,
task_id: job.task_id, branch: updated.branch ?? undefined,
user_id: job.user_id, pushed_at: updated.pushed_at?.toISOString() ?? undefined,
product_id: job.product_id, pr_url: updated.pr_url ?? undefined,
status: actualStatus, verify_result: updated.verify_result?.toLowerCase() ?? undefined,
branch: updated.branch ?? undefined, summary: updated.summary ?? undefined,
pushed_at: updated.pushed_at?.toISOString() ?? undefined, error: updated.error ?? undefined,
pr_url: updated.pr_url ?? undefined, }
verify_result: updated.verify_result?.toLowerCase() ?? undefined, if (job.task_id) notifyPayload.task_id = job.task_id
summary: updated.summary ?? undefined, if (job.idea_id) {
error: updated.error ?? undefined, notifyPayload.idea_id = job.idea_id
}), notifyPayload.kind = job.kind
], }
) await pg.query(`SELECT pg_notify('scrum4me_changes', $1)`, [JSON.stringify(notifyPayload)])
await pg.end() await pg.end()
} catch { } catch {
// non-fatal — status is already persisted // non-fatal — status is already persisted
} }
if (actualStatus === 'failed' || actualStatus === 'done') {
const isFailed = actualStatus === 'failed'
void triggerPush(job.user_id, {
title: isFailed ? 'Job gefaald' : 'Job klaar',
body: (updated.summary ?? updated.error ?? `Job ${updated.id}`).slice(0, 120),
url: updated.pr_url ?? '/dashboard',
tag: `job-${updated.id}`,
})
}
// Best-effort worktree cleanup on terminal transitions (skip if push failed — worktree preserved) // Best-effort worktree cleanup on terminal transitions (skip if push failed — worktree preserved)
if ( if (
(actualStatus === 'done' || actualStatus === 'failed' || actualStatus === 'skipped') && (actualStatus === 'done' || actualStatus === 'failed' || actualStatus === 'skipped') &&