Skip to content

Commit ff313a8

Browse files
abrichrclaude
andauthored
feat: add monorepo detection and implement PR merge/close actions (#18)
- Add detectMonorepo() to test-runner.ts: detects Turborepo (turbo.json) and pnpm workspaces, uses appropriate test commands - Implement actual GitHub API calls in bot for PR approve (merge) and reject (close) via inline keyboard callbacks - Add 5 tests for monorepo detection Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7ef67d8 commit ff313a8

File tree

3 files changed

+170
-22
lines changed

3 files changed

+170
-22
lines changed

apps/bot/src/index.ts

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -185,15 +185,63 @@ function extractJobIdFromMessage(text: string): string | null {
185185
}
186186

187187
/**
188-
* Parse a GitHub PR URL into its components.
189-
* Returns null if the URL is not a valid PR URL.
188+
* Parse a GitHub PR URL into its owner, repo, and PR number components.
189+
*
190+
* Accepts URLs like: https://github.com/owner/repo/pull/123
190191
*/
191-
function parsePrUrl(url: string): { repoUrl: string; prNumber: number } | null {
192-
const match = url.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/)
192+
function parsePrUrl(prUrl: string): { owner: string; repo: string; number: number } | null {
193+
const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/)
193194
if (!match) return null
194-
return {
195-
repoUrl: `https://github.com/${match[1]}`,
196-
prNumber: parseInt(match[2], 10),
195+
return { owner: match[1], repo: match[2], number: parseInt(match[3], 10) }
196+
}
197+
198+
/**
199+
* Merge a pull request via the GitHub REST API.
200+
*/
201+
async function mergePullRequest(owner: string, repo: string, prNumber: number): Promise<void> {
202+
const res = await fetch(
203+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/merge`,
204+
{
205+
method: 'PUT',
206+
headers: {
207+
Authorization: `Bearer ${GITHUB_TOKEN}`,
208+
Accept: 'application/vnd.github+json',
209+
'X-GitHub-Api-Version': '2022-11-28',
210+
},
211+
body: JSON.stringify({
212+
merge_method: 'squash',
213+
}),
214+
},
215+
)
216+
217+
if (!res.ok) {
218+
const body = await res.text()
219+
throw new Error(`GitHub merge failed (${res.status}): ${body}`)
220+
}
221+
}
222+
223+
/**
224+
* Close a pull request via the GitHub REST API (without merging).
225+
*/
226+
async function closePullRequest(owner: string, repo: string, prNumber: number): Promise<void> {
227+
const res = await fetch(
228+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
229+
{
230+
method: 'PATCH',
231+
headers: {
232+
Authorization: `Bearer ${GITHUB_TOKEN}`,
233+
Accept: 'application/vnd.github+json',
234+
'X-GitHub-Api-Version': '2022-11-28',
235+
},
236+
body: JSON.stringify({
237+
state: 'closed',
238+
}),
239+
},
240+
)
241+
242+
if (!res.ok) {
243+
const body = await res.text()
244+
throw new Error(`GitHub close failed (${res.status}): ${body}`)
197245
}
198246
}
199247

@@ -352,7 +400,7 @@ bot.command('task', async (ctx: Context) => {
352400
}
353401

354402
const ack = await ctx.reply(
355-
`\u{1F504} Detected PR #${prInfo.prNumber}. Queuing revision...`,
403+
`\u{1F504} Detected PR #${prInfo.number}. Queuing revision...`,
356404
{ parse_mode: 'HTML' },
357405
)
358406

@@ -364,10 +412,10 @@ bot.command('task', async (ctx: Context) => {
364412
}
365413
if (GITHUB_TOKEN) ghEnv.GH_TOKEN = GITHUB_TOKEN
366414

367-
const nwo = prInfo.repoUrl.replace('https://github.com/', '')
415+
const nwo = `${prInfo.owner}/${prInfo.repo}`
368416
const headRef = execFileSync(
369417
'gh',
370-
['api', `repos/${nwo}/pulls/${prInfo.prNumber}`, '--jq', '.head.ref'],
418+
['api', `repos/${nwo}/pulls/${prInfo.number}`, '--jq', '.head.ref'],
371419
{ encoding: 'utf-8', env: ghEnv },
372420
).trim()
373421

@@ -384,7 +432,7 @@ bot.command('task', async (ctx: Context) => {
384432
const parentJobId = parentJobs?.[0]?.id
385433

386434
const job = await insertJob({
387-
repoUrl: prInfo.repoUrl,
435+
repoUrl: `https://github.com/${prInfo.owner}/${prInfo.repo}`,
388436
task: description,
389437
chatId: ctx.chat!.id,
390438
messageId: ack.message_id,
@@ -398,7 +446,7 @@ bot.command('task', async (ctx: Context) => {
398446
'\u{1F504} <b>PR revision queued!</b>',
399447
'',
400448
`<b>Job ID:</b> <code>${job.id}</code>`,
401-
`<b>PR:</b> #${prInfo.prNumber}`,
449+
`<b>PR:</b> #${prInfo.number}`,
402450
`<b>Branch:</b> <code>${headRef}</code>`,
403451
`<b>Feedback:</b> ${escapeHtml(description)}`,
404452
'',
@@ -652,17 +700,24 @@ bot.callbackQuery(/^approve:(.+)$/, async (ctx) => {
652700
return
653701
}
654702

655-
// In a full implementation this would call the GitHub API to merge.
656-
// For now, acknowledge the action and provide the PR link.
657-
await ctx.answerCallbackQuery({ text: 'Approval noted!' })
703+
const parsed = parsePrUrl(job.pr_url)
704+
if (!parsed) {
705+
await ctx.answerCallbackQuery({ text: 'Could not parse PR URL.' })
706+
return
707+
}
708+
709+
await ctx.answerCallbackQuery({ text: 'Merging PR...' })
710+
711+
await mergePullRequest(parsed.owner, parsed.repo, parsed.number)
712+
658713
await ctx.editMessageText(
659714
[
660-
`\u{2705} <b>PR approved for merge</b>`,
715+
`\u{2705} <b>PR merged successfully</b>`,
661716
'',
662717
`<b>Job:</b> <code>${job.id.slice(0, 8)}</code>`,
663718
`<b>PR:</b> ${job.pr_url}`,
664719
'',
665-
'The PR merge has been initiated.',
720+
`Merged <code>${parsed.owner}/${parsed.repo}#${parsed.number}</code> via squash merge.`,
666721
].join('\n'),
667722
{ parse_mode: 'HTML' },
668723
)
@@ -685,16 +740,24 @@ bot.callbackQuery(/^reject:(.+)$/, async (ctx) => {
685740
return
686741
}
687742

688-
// In a full implementation this would call the GitHub API to close the PR.
689-
await ctx.answerCallbackQuery({ text: 'PR rejected.' })
743+
const parsed = parsePrUrl(job.pr_url)
744+
if (!parsed) {
745+
await ctx.answerCallbackQuery({ text: 'Could not parse PR URL.' })
746+
return
747+
}
748+
749+
await ctx.answerCallbackQuery({ text: 'Closing PR...' })
750+
751+
await closePullRequest(parsed.owner, parsed.repo, parsed.number)
752+
690753
await ctx.editMessageText(
691754
[
692755
`\u{274C} <b>PR rejected and closed</b>`,
693756
'',
694757
`<b>Job:</b> <code>${job.id.slice(0, 8)}</code>`,
695758
`<b>PR:</b> ${job.pr_url}`,
696759
'',
697-
'The PR has been closed without merging.',
760+
`Closed <code>${parsed.owner}/${parsed.repo}#${parsed.number}</code> without merging.`,
698761
].join('\n'),
699762
{ parse_mode: 'HTML' },
700763
)

apps/worker/src/__tests__/test-runner.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
22
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
33
import { join } from 'path'
44
import { tmpdir } from 'os'
5-
import { detectTestRunner, detectPackageManager, runTests } from '../test-runner.js'
5+
import { detectTestRunner, detectPackageManager, detectMonorepo, runTests } from '../test-runner.js'
66

77
// Helper: create a temp directory with specific files
88
function createTempDir(): string {
@@ -207,6 +207,43 @@ describe('detectPackageManager', () => {
207207
})
208208
})
209209

210+
describe('detectMonorepo', () => {
211+
let tempDir: string
212+
213+
beforeEach(() => {
214+
tempDir = createTempDir()
215+
})
216+
217+
afterEach(() => {
218+
rmSync(tempDir, { recursive: true, force: true })
219+
})
220+
221+
it('detects turborepo from turbo.json', () => {
222+
touchFile(tempDir, 'turbo.json', '{"pipeline":{}}')
223+
expect(detectMonorepo(tempDir)).toBe('turborepo')
224+
})
225+
226+
it('detects pnpm-workspace from pnpm-workspace.yaml', () => {
227+
touchFile(tempDir, 'pnpm-workspace.yaml', 'packages:\n - "apps/*"')
228+
expect(detectMonorepo(tempDir)).toBe('pnpm-workspace')
229+
})
230+
231+
it('returns none for a plain repo', () => {
232+
touchFile(tempDir, 'package.json', '{}')
233+
expect(detectMonorepo(tempDir)).toBe('none')
234+
})
235+
236+
it('returns none for an empty directory', () => {
237+
expect(detectMonorepo(tempDir)).toBe('none')
238+
})
239+
240+
it('prioritizes turborepo when both turbo.json and pnpm-workspace.yaml exist', () => {
241+
touchFile(tempDir, 'turbo.json', '{"pipeline":{}}')
242+
touchFile(tempDir, 'pnpm-workspace.yaml', 'packages:\n - "apps/*"')
243+
expect(detectMonorepo(tempDir)).toBe('turborepo')
244+
})
245+
})
246+
210247
describe('runTests with real commands', () => {
211248
let tempDir: string
212249

apps/worker/src/test-runner.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,46 @@ function getSafeEnv(): Record<string, string> {
2525
return env
2626
}
2727

28+
/**
29+
* Monorepo layout type, if detected.
30+
*/
31+
export type MonorepoType = 'turborepo' | 'pnpm-workspace' | 'none'
32+
33+
/**
34+
* Auto-detect whether the repo is a monorepo and what kind.
35+
*
36+
* Detection order:
37+
* 1. turbo.json exists -> Turborepo (uses `pnpm turbo test` or `npx turbo test`)
38+
* 2. pnpm-workspace.yaml exists (without turbo) -> pnpm workspace (`pnpm -r test`)
39+
* 3. Neither -> single repo
40+
*/
41+
export function detectMonorepo(workDir: string): MonorepoType {
42+
if (existsSync(join(workDir, 'turbo.json'))) {
43+
return 'turborepo'
44+
}
45+
if (existsSync(join(workDir, 'pnpm-workspace.yaml'))) {
46+
return 'pnpm-workspace'
47+
}
48+
return 'none'
49+
}
50+
51+
/**
52+
* Build the test command for a monorepo layout.
53+
* Returns null if the repo is not a monorepo (caller should fall back to
54+
* the per-runner command).
55+
*/
56+
function getMonorepoTestCommand(monorepo: MonorepoType, pm: PackageManager): string | null {
57+
switch (monorepo) {
58+
case 'turborepo':
59+
// Prefer pnpm turbo when pnpm is the package manager; otherwise npx
60+
return pm === 'pnpm' ? 'pnpm turbo test' : 'npx turbo test'
61+
case 'pnpm-workspace':
62+
return 'pnpm -r test'
63+
default:
64+
return null
65+
}
66+
}
67+
2868
/**
2969
* Auto-detect the test runner from repo files.
3070
*/
@@ -150,14 +190,22 @@ function getTestCommand(runner: TestRunner, pm: PackageManager): string {
150190

151191
/**
152192
* Run the test suite and return structured results.
193+
*
194+
* When `monorepo` is provided (or auto-detected), the function uses the
195+
* appropriate monorepo test orchestration command instead of the single-
196+
* runner command.
153197
*/
154198
export function runTests(
155199
workDir: string,
156200
runner: TestRunner,
157201
pm: PackageManager,
158202
timeoutSeconds: number,
203+
monorepo?: MonorepoType,
159204
): TestResults {
160-
const command = getTestCommand(runner, pm)
205+
// Auto-detect monorepo if not explicitly provided
206+
const mono = monorepo ?? detectMonorepo(workDir)
207+
const monorepoCommand = getMonorepoTestCommand(mono, pm)
208+
const command = monorepoCommand ?? getTestCommand(runner, pm)
161209
const startTime = Date.now()
162210

163211
console.log(`[test-runner] Running: ${command}`)

0 commit comments

Comments
 (0)