Compare commits
1 commit
main
...
fix/cross-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70cb4ad2b0 |
5 changed files with 171 additions and 15 deletions
|
|
@ -113,6 +113,71 @@ describe('createWorktreeForJob', () => {
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow('Worktree path already exists')
|
).rejects.toThrow('Worktree path already exists')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reuseBranch: reuses an existing local branch', async () => {
|
||||||
|
const { repoDir, originDir } = await setupRepo()
|
||||||
|
tmpDirs.push(repoDir, originDir)
|
||||||
|
await makeWorktreeParent()
|
||||||
|
|
||||||
|
// Sibling already created the branch locally.
|
||||||
|
await git(['branch', 'feat/sprint-abc', 'origin/main'], repoDir)
|
||||||
|
|
||||||
|
const result = await createWorktreeForJob({
|
||||||
|
repoRoot: repoDir,
|
||||||
|
jobId: 'job-reuse-local',
|
||||||
|
branchName: 'feat/sprint-abc',
|
||||||
|
baseRef: 'origin/main',
|
||||||
|
reuseBranch: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
||||||
|
expect(stdout.trim()).toBe('feat/sprint-abc')
|
||||||
|
expect(result.branchName).toBe('feat/sprint-abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuseBranch: recreates a local branch from origin when only the remote has it', async () => {
|
||||||
|
const { repoDir, originDir } = await setupRepo()
|
||||||
|
tmpDirs.push(repoDir, originDir)
|
||||||
|
await makeWorktreeParent()
|
||||||
|
|
||||||
|
// Branch exists on origin (a sibling pushed it, or the container was
|
||||||
|
// recreated and the local clone is fresh) but not as a local branch.
|
||||||
|
await git(['branch', 'feat/sprint-xyz', 'origin/main'], repoDir)
|
||||||
|
await git(['push', 'origin', 'feat/sprint-xyz'], repoDir)
|
||||||
|
await git(['branch', '-D', 'feat/sprint-xyz'], repoDir)
|
||||||
|
|
||||||
|
const result = await createWorktreeForJob({
|
||||||
|
repoRoot: repoDir,
|
||||||
|
jobId: 'job-reuse-origin',
|
||||||
|
branchName: 'feat/sprint-xyz',
|
||||||
|
baseRef: 'origin/main',
|
||||||
|
reuseBranch: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
||||||
|
expect(stdout.trim()).toBe('feat/sprint-xyz')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuseBranch: falls back to a fresh branch when it exists nowhere (cross-repo sprint)', async () => {
|
||||||
|
const { repoDir, originDir } = await setupRepo()
|
||||||
|
tmpDirs.push(repoDir, originDir)
|
||||||
|
await makeWorktreeParent()
|
||||||
|
|
||||||
|
// reuseBranch is decided sprint-wide; for the first job targeting THIS
|
||||||
|
// repo the branch exists neither locally nor on origin. Must not throw
|
||||||
|
// "invalid reference" — should create it fresh from baseRef.
|
||||||
|
const result = await createWorktreeForJob({
|
||||||
|
repoRoot: repoDir,
|
||||||
|
jobId: 'job-reuse-fresh',
|
||||||
|
branchName: 'feat/sprint-newrepo',
|
||||||
|
baseRef: 'origin/main',
|
||||||
|
reuseBranch: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
||||||
|
expect(stdout.trim()).toBe('feat/sprint-newrepo')
|
||||||
|
expect(result.branchName).toBe('feat/sprint-newrepo')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('removeWorktreeForJob', () => {
|
describe('removeWorktreeForJob', () => {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ vi.mock('../src/prisma.js', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
product: { findUnique: vi.fn() },
|
product: { findUnique: vi.fn() },
|
||||||
task: { findUnique: vi.fn() },
|
task: { findUnique: vi.fn() },
|
||||||
claudeJob: { findFirst: vi.fn(), findUnique: vi.fn() },
|
claudeJob: { findFirst: vi.fn(), findMany: vi.fn(), findUnique: vi.fn() },
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -22,6 +22,7 @@ const mockPrisma = prisma as unknown as {
|
||||||
task: { findUnique: ReturnType<typeof vi.fn> }
|
task: { findUnique: ReturnType<typeof vi.fn> }
|
||||||
claudeJob: {
|
claudeJob: {
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
findFirst: ReturnType<typeof vi.fn>
|
||||||
|
findMany: ReturnType<typeof vi.fn>
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
findUnique: ReturnType<typeof vi.fn>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -41,9 +42,10 @@ beforeEach(() => {
|
||||||
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true })
|
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true })
|
||||||
mockPrisma.task.findUnique.mockResolvedValue({
|
mockPrisma.task.findUnique.mockResolvedValue({
|
||||||
title: 'Add feature',
|
title: 'Add feature',
|
||||||
|
repo_url: null,
|
||||||
story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' },
|
story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' },
|
||||||
})
|
})
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue(null) // no sibling PR by default
|
mockPrisma.claudeJob.findMany.mockResolvedValue([]) // no sibling PRs by default
|
||||||
// Default: legacy job zonder sprint_run (STORY-mode pad).
|
// Default: legacy job zonder sprint_run (STORY-mode pad).
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null })
|
mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null })
|
||||||
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
|
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
|
||||||
|
|
@ -62,12 +64,27 @@ describe('maybeCreateAutoPr', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reuses sibling pr_url when another job in same story already opened a PR', async () => {
|
it('reuses sibling pr_url when another job in same story already opened a PR', async () => {
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/77' })
|
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||||
|
{ pr_url: 'https://github.com/org/repo/pull/77', task: { repo_url: null } },
|
||||||
|
])
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
expect(url).toBe('https://github.com/org/repo/pull/77')
|
expect(url).toBe('https://github.com/org/repo/pull/77')
|
||||||
expect(mockCreatePr).not.toHaveBeenCalled()
|
expect(mockCreatePr).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does NOT reuse a sibling PR from a different repo (cross-repo story)', async () => {
|
||||||
|
// Sibling targeted another repo via task.repo_url — its PR must not leak in.
|
||||||
|
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pr_url: 'https://github.com/org/other-repo/pull/12',
|
||||||
|
task: { repo_url: 'https://github.com/org/other-repo' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
expect(url).toBe('https://github.com/org/repo/pull/99') // fresh PR, not the sibling's
|
||||||
|
expect(mockCreatePr).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
it('returns null when auto_pr=false', async () => {
|
it('returns null when auto_pr=false', async () => {
|
||||||
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false })
|
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false })
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
@ -78,6 +95,7 @@ describe('maybeCreateAutoPr', () => {
|
||||||
it('uses story title without code prefix when story has no code', async () => {
|
it('uses story title without code prefix when story has no code', async () => {
|
||||||
mockPrisma.task.findUnique.mockResolvedValue({
|
mockPrisma.task.findUnique.mockResolvedValue({
|
||||||
title: 'Add feature',
|
title: 'Add feature',
|
||||||
|
repo_url: null,
|
||||||
story: { id: 'story-1', code: null, title: 'Story title' },
|
story: { id: 'story-1', code: null, title: 'Story title' },
|
||||||
})
|
})
|
||||||
await maybeCreateAutoPr(BASE_OPTS)
|
await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
@ -113,7 +131,9 @@ describe('maybeCreateAutoPr', () => {
|
||||||
sprint_run_id: 'run-1',
|
sprint_run_id: 'run-1',
|
||||||
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
|
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
|
||||||
})
|
})
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/55' })
|
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||||
|
{ pr_url: 'https://github.com/org/repo/pull/55', task: { repo_url: null } },
|
||||||
|
])
|
||||||
|
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
||||||
|
|
@ -121,6 +141,29 @@ describe('maybeCreateAutoPr', () => {
|
||||||
expect(mockCreatePr).not.toHaveBeenCalled()
|
expect(mockCreatePr).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('SPRINT-mode: cross-repo — sibling-PR van ander repo wordt niet hergebruikt', async () => {
|
||||||
|
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
||||||
|
sprint_run_id: 'run-1',
|
||||||
|
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
|
||||||
|
})
|
||||||
|
// Deze job target een ander repo via task.repo_url.
|
||||||
|
mockPrisma.task.findUnique.mockResolvedValue({
|
||||||
|
title: 'MCP-taak',
|
||||||
|
repo_url: 'https://github.com/org/scrum4me-mcp',
|
||||||
|
story: { id: 'story-1', code: 'SCRUM-9', title: 'Story title' },
|
||||||
|
})
|
||||||
|
// Sibling met pr_url hoort bij het product-repo (repo_url null) → andere bucket.
|
||||||
|
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||||
|
{ pr_url: 'https://github.com/org/repo/pull/201', task: { repo_url: null } },
|
||||||
|
])
|
||||||
|
|
||||||
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
||||||
|
// Geen hergebruik van de product-repo PR → eigen draft-PR voor het mcp-repo.
|
||||||
|
expect(url).toBe('https://github.com/org/repo/pull/99')
|
||||||
|
expect(mockCreatePr).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
it('returns null and does not throw when gh fails', async () => {
|
it('returns null and does not throw when gh fails', async () => {
|
||||||
mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' })
|
mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' })
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "scrum4me-mcp",
|
"name": "scrum4me-mcp",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "scrum4me-mcp",
|
"name": "scrum4me-mcp",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,19 @@ async function branchExists(repoRoot: string, name: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remoteBranchExists(repoRoot: string, name: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await exec(
|
||||||
|
'git',
|
||||||
|
['show-ref', '--verify', '--quiet', `refs/remotes/origin/${name}`],
|
||||||
|
{ cwd: repoRoot },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function findWorktreeForBranch(
|
async function findWorktreeForBranch(
|
||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
branchName: string,
|
branchName: string,
|
||||||
|
|
@ -75,7 +88,27 @@ export async function createWorktreeForJob(opts: {
|
||||||
if (occupant) {
|
if (occupant) {
|
||||||
await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot })
|
await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot })
|
||||||
}
|
}
|
||||||
|
// reuseBranch is decided sprint-wide, but git branches are per-repo. For a
|
||||||
|
// cross-repo sprint the first job targeting THIS repo gets reuseBranch=true
|
||||||
|
// even though the branch was never created here; a container recreate also
|
||||||
|
// wipes the local clone. Fall back gracefully instead of failing with
|
||||||
|
// "invalid reference":
|
||||||
|
// - local branch exists → reuse it
|
||||||
|
// - exists on origin only → recreate the local branch tracking origin
|
||||||
|
// - nowhere → create it fresh from baseRef
|
||||||
|
if (await branchExists(repoRoot, branchName)) {
|
||||||
await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot })
|
await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot })
|
||||||
|
} else if (await remoteBranchExists(repoRoot, branchName)) {
|
||||||
|
await exec(
|
||||||
|
'git',
|
||||||
|
['worktree', 'add', '-b', branchName, worktreePath, `origin/${branchName}`],
|
||||||
|
{ cwd: repoRoot },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
})
|
||||||
|
}
|
||||||
return { worktreePath, branchName }
|
return { worktreePath, branchName }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -420,24 +420,35 @@ export async function maybeCreateAutoPr(opts: {
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
select: {
|
select: {
|
||||||
title: true,
|
title: true,
|
||||||
|
repo_url: true,
|
||||||
story: { select: { id: true, code: true, title: true } },
|
story: { select: { id: true, code: true, title: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!task) return null
|
if (!task) return null
|
||||||
|
|
||||||
// PBI-46 SPRINT-mode: hergebruik 1 draft-PR voor de hele SprintRun.
|
// Cross-repo sprints: een sprint kan taken hebben die via task.repo_url een
|
||||||
|
// ander repo targeten. PRs en branches zijn per-repo, dus een sibling-PR mag
|
||||||
|
// alleen hergebruikt worden als die sibling hetzelfde repo targette. null/leeg
|
||||||
|
// repo_url = het product-repo; twee taken zitten in dezelfde repo-bucket als
|
||||||
|
// hun (repo_url ?? null) gelijk is.
|
||||||
|
const thisRepoKey = task.repo_url ?? null
|
||||||
|
|
||||||
|
// PBI-46 SPRINT-mode: hergebruik 1 draft-PR voor de hele SprintRun (per repo).
|
||||||
// Mens zet 'm ready-for-review zodra de SprintRun DONE is.
|
// Mens zet 'm ready-for-review zodra de SprintRun DONE is.
|
||||||
if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') {
|
if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') {
|
||||||
const sprintSibling = await prisma.claudeJob.findFirst({
|
const sprintSiblings = await prisma.claudeJob.findMany({
|
||||||
where: {
|
where: {
|
||||||
sprint_run_id: job.sprint_run_id,
|
sprint_run_id: job.sprint_run_id,
|
||||||
pr_url: { not: null },
|
pr_url: { not: null },
|
||||||
id: { not: jobId },
|
id: { not: jobId },
|
||||||
},
|
},
|
||||||
select: { pr_url: true },
|
select: { pr_url: true, task: { select: { repo_url: true } } },
|
||||||
orderBy: { created_at: 'asc' },
|
orderBy: { created_at: 'asc' },
|
||||||
})
|
})
|
||||||
if (sprintSibling?.pr_url) return sprintSibling.pr_url
|
const sameRepoSibling = sprintSiblings.find(
|
||||||
|
(s) => (s.task?.repo_url ?? null) === thisRepoKey,
|
||||||
|
)
|
||||||
|
if (sameRepoSibling?.pr_url) return sameRepoSibling.pr_url
|
||||||
|
|
||||||
// Eerste DONE in deze SprintRun → maak draft-PR aan, geen auto-merge.
|
// Eerste DONE in deze SprintRun → maak draft-PR aan, geen auto-merge.
|
||||||
const goal = job.sprint_run.sprint.sprint_goal
|
const goal = job.sprint_run.sprint.sprint_goal
|
||||||
|
|
@ -459,17 +470,21 @@ export async function maybeCreateAutoPr(opts: {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// STORY-mode (default of legacy): branch-per-story, sibling-tasks delen PR.
|
// STORY-mode (default of legacy): branch-per-story, sibling-tasks delen PR
|
||||||
const sibling = await prisma.claudeJob.findFirst({
|
// — maar alleen siblings die hetzelfde repo targeten (zie thisRepoKey).
|
||||||
|
const storySiblings = await prisma.claudeJob.findMany({
|
||||||
where: {
|
where: {
|
||||||
task: { story_id: task.story.id },
|
task: { story_id: task.story.id },
|
||||||
pr_url: { not: null },
|
pr_url: { not: null },
|
||||||
id: { not: jobId },
|
id: { not: jobId },
|
||||||
},
|
},
|
||||||
select: { pr_url: true },
|
select: { pr_url: true, task: { select: { repo_url: true } } },
|
||||||
orderBy: { created_at: 'asc' },
|
orderBy: { created_at: 'asc' },
|
||||||
})
|
})
|
||||||
if (sibling?.pr_url) return sibling.pr_url
|
const sameRepoStorySibling = storySiblings.find(
|
||||||
|
(s) => (s.task?.repo_url ?? null) === thisRepoKey,
|
||||||
|
)
|
||||||
|
if (sameRepoStorySibling?.pr_url) return sameRepoStorySibling.pr_url
|
||||||
|
|
||||||
const storyTitle = task.story.code ? `${task.story.code}: ${task.story.title}` : task.story.title
|
const storyTitle = task.story.code ? `${task.story.code}: ${task.story.title}` : task.story.title
|
||||||
const body = summary
|
const body = summary
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue