Compare commits
3 commits
main
...
feat/story
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26cbacc27d | ||
|
|
2093a7788e | ||
|
|
834a71e47f |
8 changed files with 115 additions and 9 deletions
24
AGENTS.md
24
AGENTS.md
|
|
@ -18,6 +18,28 @@ For Claude Code specifically, CLAUDE.md is loaded automatically. Start there.
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Start run | `git checkout -b feat/<batch-slug>` | `gh pr create` |
|
| Start run | `git checkout -b feat/<batch-slug>` | `gh pr create` |
|
||||||
| Na elke taak | `git add -A && git commit -m "<type>(ST-XXX): <title>"` | `git push` |
|
| Na elke taak | `git add -A && git commit -m "<type>(ST-XXX): <title>"` | `git push` |
|
||||||
| Queue leeg | `git push -u origin <branch>` + `gh pr create` | — |
|
| Queue leeg | `git push -u origin <branch>` + `gh pr create` | `gh pr merge` |
|
||||||
|
| Na PR-create | `mcp__scrum4me__set_pbi_pr(pbi_id, pr_url)` → **STOP** | idem |
|
||||||
|
|
||||||
Full details: [docs/runbooks/branch-and-commit.md § Agent-batch flow](./docs/runbooks/branch-and-commit.md)
|
Full details: [docs/runbooks/branch-and-commit.md § Agent-batch flow](./docs/runbooks/branch-and-commit.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-merge & PBI-volgorde
|
||||||
|
|
||||||
|
**De agent mergt nooit zelf een PR.** Na `gh pr create`:
|
||||||
|
|
||||||
|
1. Roep direct `mcp__scrum4me__set_pbi_pr(pbi_id, pr_url)` aan zodat de gate weet dat deze PBI in review zit.
|
||||||
|
2. Stop — wacht op handmatige merge door de PB-owner.
|
||||||
|
3. Als de gebruiker daarna een nieuwe batch voor een andere PBI start en `enqueueClaudeJobsBatchAction` de error `open_pr_url` teruggeeft: toon de foutmelding aan de gebruiker en stop. Niet opnieuw proberen; de gebruiker moet eerst de open PR mergen.
|
||||||
|
|
||||||
|
**Verboden:**
|
||||||
|
- `gh pr merge` (ook niet met `--auto`, `--squash`, of `--rebase`)
|
||||||
|
- Een PR sluiten en herforken om de gate te omzeilen
|
||||||
|
- Opnieuw enqueuen na een gate-weigering zonder gebruikersbevestiging
|
||||||
|
|
||||||
|
**PR-body** bevat minimaal:
|
||||||
|
- Lijst van geïmplementeerde taak-titels (één per regel)
|
||||||
|
- Link naar de PBI (`pbi_id` of de Scrum4Me-URL)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: produ
|
||||||
5. Verifieer: `npm run lint && npm test && npm run build`
|
5. Verifieer: `npm run lint && npm test && npm run build`
|
||||||
6. Commit per laag: `git add -A && git commit` — **geen** `git push` — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md)
|
6. Commit per laag: `git add -A && git commit` — **geen** `git push` — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md)
|
||||||
7. Herhaal stap 2–6 per story; branch blijft dezelfde
|
7. Herhaal stap 2–6 per story; branch blijft dezelfde
|
||||||
8. Queue leeg → `git push -u origin <branch>` + `gh pr create`
|
8. Queue leeg → `git push -u origin <branch>` + `gh pr create` (body: taak-titels + PBI-link) → roep `mcp__scrum4me__set_pbi_pr(pbi_id, pr_url)` aan → **STOP** (nooit `gh pr merge`)
|
||||||
|
|
||||||
**Track B — manueel:**
|
**Track B — manueel:**
|
||||||
1. Lees taak in `docs/backlog/index.md`
|
1. Lees taak in `docs/backlog/index.md`
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const {
|
||||||
mockGetSession,
|
mockGetSession,
|
||||||
mockFindFirstProduct,
|
mockFindFirstProduct,
|
||||||
mockFindFirstSprint,
|
mockFindFirstSprint,
|
||||||
|
mockFindFirstPbi,
|
||||||
mockFindManyTask,
|
mockFindManyTask,
|
||||||
mockTransaction,
|
mockTransaction,
|
||||||
mockExecuteRaw,
|
mockExecuteRaw,
|
||||||
|
|
@ -16,6 +17,7 @@ const {
|
||||||
mockGetSession: vi.fn(),
|
mockGetSession: vi.fn(),
|
||||||
mockFindFirstProduct: vi.fn(),
|
mockFindFirstProduct: vi.fn(),
|
||||||
mockFindFirstSprint: vi.fn(),
|
mockFindFirstSprint: vi.fn(),
|
||||||
|
mockFindFirstPbi: vi.fn(),
|
||||||
mockFindManyTask: vi.fn(),
|
mockFindManyTask: vi.fn(),
|
||||||
mockTransaction: vi.fn(),
|
mockTransaction: vi.fn(),
|
||||||
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|
@ -28,6 +30,7 @@ vi.mock('@/lib/prisma', () => ({
|
||||||
task: { findMany: mockFindManyTask },
|
task: { findMany: mockFindManyTask },
|
||||||
product: { findFirst: mockFindFirstProduct },
|
product: { findFirst: mockFindFirstProduct },
|
||||||
sprint: { findFirst: mockFindFirstSprint },
|
sprint: { findFirst: mockFindFirstSprint },
|
||||||
|
pbi: { findFirst: mockFindFirstPbi },
|
||||||
claudeJob: { create: vi.fn() },
|
claudeJob: { create: vi.fn() },
|
||||||
$executeRaw: mockExecuteRaw,
|
$executeRaw: mockExecuteRaw,
|
||||||
$transaction: mockTransaction,
|
$transaction: mockTransaction,
|
||||||
|
|
@ -66,8 +69,9 @@ const makePbi2Task = (id: string, status = 'TO_DO', pbiStatus = 'READY') => ({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const makeBatchTask = (id: string, hasActiveJob = false) => ({
|
const makeBatchTask = (id: string, hasActiveJob = false, pbiId = 'pbi-1') => ({
|
||||||
id,
|
id,
|
||||||
|
story: { pbi_id: pbiId },
|
||||||
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
|
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -85,6 +89,7 @@ beforeEach(() => {
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
mockFindFirstSprint.mockResolvedValue({ id: SPRINT_ID })
|
mockFindFirstSprint.mockResolvedValue({ id: SPRINT_ID })
|
||||||
|
mockFindFirstPbi.mockResolvedValue(null) // geen open PR by default
|
||||||
})
|
})
|
||||||
|
|
||||||
// =============================================================
|
// =============================================================
|
||||||
|
|
@ -229,4 +234,55 @@ describe('enqueueClaudeJobsBatchAction — 2 PBI scenario', () => {
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
||||||
expect(mockTransaction).not.toHaveBeenCalled()
|
expect(mockTransaction).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('geen open PR → enqueue OK (gate niet actief)', async () => {
|
||||||
|
mockFindFirstPbi.mockResolvedValue(null)
|
||||||
|
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1'), makeBatchTask('pbi2-t1', false, 'pbi-2')])
|
||||||
|
mockTransaction.mockResolvedValue([
|
||||||
|
{ id: 'job-a', task_id: 'pbi1-t1' },
|
||||||
|
{ id: 'job-b', task_id: 'pbi2-t1' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi2-t1'])
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, count: 2 })
|
||||||
|
expect(mockTransaction).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('open PR op PBI-A, input bevat alleen PBI-A taken → OK', async () => {
|
||||||
|
mockFindFirstPbi.mockResolvedValue({ id: 'pbi-1', pr_url: 'https://github.com/org/repo/pull/42' })
|
||||||
|
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1', false, 'pbi-1'), makeBatchTask('pbi1-t2', false, 'pbi-1')])
|
||||||
|
mockTransaction.mockResolvedValue([
|
||||||
|
{ id: 'job-a', task_id: 'pbi1-t1' },
|
||||||
|
{ id: 'job-b', task_id: 'pbi1-t2' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi1-t2'])
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, count: 2 })
|
||||||
|
expect(mockTransaction).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('open PR op PBI-A, input bevat PBI-B taken → error met PR-URL', async () => {
|
||||||
|
const PR_URL = 'https://github.com/org/repo/pull/42'
|
||||||
|
mockFindFirstPbi.mockResolvedValue({ id: 'pbi-1', pr_url: PR_URL })
|
||||||
|
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi2-t1', false, 'pbi-2')])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi2-t1'])
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: expect.stringContaining(PR_URL), open_pr_url: PR_URL, open_pbi_id: 'pbi-1' })
|
||||||
|
expect(mockTransaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PBI-A pr_merged_at gezet → gate retourneert null, PBI-B mag', async () => {
|
||||||
|
// pr_merged_at != null → findFirst returnt null (where-clause filtert het uit)
|
||||||
|
mockFindFirstPbi.mockResolvedValue(null)
|
||||||
|
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi2-t1', false, 'pbi-2')])
|
||||||
|
mockTransaction.mockResolvedValue([{ id: 'job-a', task_id: 'pbi2-t1' }])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi2-t1'])
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, count: 1 })
|
||||||
|
expect(mockTransaction).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ vi.mock('@/lib/prisma', () => ({
|
||||||
task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask },
|
task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask },
|
||||||
product: { findFirst: mockFindFirstProduct },
|
product: { findFirst: mockFindFirstProduct },
|
||||||
sprint: { findFirst: mockFindFirstSprint },
|
sprint: { findFirst: mockFindFirstSprint },
|
||||||
|
pbi: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
claudeJob: {
|
claudeJob: {
|
||||||
findFirst: mockFindFirstJob,
|
findFirst: mockFindFirstJob,
|
||||||
create: mockCreateJob,
|
create: mockCreateJob,
|
||||||
|
|
@ -298,6 +299,7 @@ describe('previewEnqueueAllAction', () => {
|
||||||
|
|
||||||
const makeBatchTask = (id: string, hasActiveJob = false) => ({
|
const makeBatchTask = (id: string, hasActiveJob = false) => ({
|
||||||
id,
|
id,
|
||||||
|
story: { pbi_id: 'pbi-1' },
|
||||||
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
|
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ type EnqueueResult =
|
||||||
|
|
||||||
type EnqueueAllResult =
|
type EnqueueAllResult =
|
||||||
| { success: true; count: number }
|
| { success: true; count: number }
|
||||||
| { error: string }
|
| { error: string; open_pr_url?: string; open_pbi_id?: string }
|
||||||
|
|
||||||
type CancelResult = { success: true } | { error: string }
|
type CancelResult = { success: true } | { error: string }
|
||||||
|
|
||||||
|
|
@ -249,6 +249,7 @@ export async function enqueueClaudeJobsBatchAction(
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
story: { select: { pbi_id: true } },
|
||||||
claude_jobs: {
|
claude_jobs: {
|
||||||
where: { status: { in: ACTIVE_JOB_STATUSES } },
|
where: { status: { in: ACTIVE_JOB_STATUSES } },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
|
@ -260,6 +261,23 @@ export async function enqueueClaudeJobsBatchAction(
|
||||||
return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' }
|
return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gate: blokkeer taken van een nieuwe PBI zolang een andere PBI een open PR heeft
|
||||||
|
const openPrPbi = await prisma.pbi.findFirst({
|
||||||
|
where: { product_id: productId, pr_url: { not: null }, pr_merged_at: null },
|
||||||
|
select: { id: true, pr_url: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (openPrPbi) {
|
||||||
|
const hasTaskFromOtherPbi = authorizedTasks.some(t => t.story.pbi_id !== openPrPbi.id)
|
||||||
|
if (hasTaskFromOtherPbi) {
|
||||||
|
return {
|
||||||
|
error: `Vorige PBI heeft een open PR. Merge eerst PR ${openPrPbi.pr_url} voordat je een nieuwe PBI start.`,
|
||||||
|
open_pr_url: openPrPbi.pr_url!,
|
||||||
|
open_pbi_id: openPrPbi.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0)
|
const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0)
|
||||||
if (queueable.length === 0) return { success: true, count: 0 }
|
if (queueable.length === 0) return { success: true, count: 0 }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,14 @@ Wanneer de NAS-agent (`/opt/agent/`) een batch jobs uitvoert:
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Start run | `git checkout -b feat/<batch-slug>` lokaal | `gh pr create` |
|
| Start run | `git checkout -b feat/<batch-slug>` lokaal | `gh pr create` |
|
||||||
| Na elke taak | `git add -A && git commit -m "<type>(ST-XXX): <title>"` | `git push` |
|
| Na elke taak | `git add -A && git commit -m "<type>(ST-XXX): <title>"` | `git push` |
|
||||||
| Queue leeg | `git push -u origin <branch>` + `gh pr create` | — |
|
| Queue leeg | `git push -u origin <branch>` + `gh pr create` | `gh pr merge` |
|
||||||
|
| Na PR-create | `mcp__scrum4me__set_pbi_pr(pbi_id, pr_url)` → **STOP** | idem |
|
||||||
|
|
||||||
- Alle commits accumuleren op dezelfde branch — lopende state blijft op disk tot de run klaar is.
|
- Alle commits accumuleren op dezelfde branch — lopende state blijft op disk tot de run klaar is.
|
||||||
- Één PR per batch → één Vercel preview-deployment.
|
- Één PR per batch → één Vercel preview-deployment.
|
||||||
- Single-task batch (1 job in queue): dezelfde flow — 1 commit → push + PR.
|
- Single-task batch (1 job in queue): dezelfde flow — 1 commit → push + PR.
|
||||||
|
- **De agent mergt nooit.** Na PR-create: `set_pbi_pr` aanroepen en stoppen. Wacht op handmatige merge door de PB-owner.
|
||||||
|
- Als een volgende batch wordt geblokkeerd door de PBI-gate (open PR): toon de foutmelding (`open_pr_url`) aan de gebruiker. Niet opnieuw proberen.
|
||||||
|
|
||||||
#### End-to-end verificatie: 1 batch = 1 Vercel-deploy
|
#### End-to-end verificatie: 1 batch = 1 Vercel-deploy
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "pbis" ADD COLUMN "pr_merged_at" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "pr_url" TEXT;
|
||||||
|
|
@ -165,10 +165,12 @@ model Pbi {
|
||||||
description String?
|
description String?
|
||||||
priority Int
|
priority Int
|
||||||
sort_order Float
|
sort_order Float
|
||||||
status PbiStatus @default(READY)
|
status PbiStatus @default(READY)
|
||||||
created_at DateTime @default(now())
|
pr_url String?
|
||||||
updated_at DateTime @updatedAt
|
pr_merged_at DateTime?
|
||||||
stories Story[]
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
stories Story[]
|
||||||
|
|
||||||
@@unique([product_id, code])
|
@@unique([product_id, code])
|
||||||
@@index([product_id, priority, sort_order])
|
@@index([product_id, priority, sort_order])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue