Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 83 additions & 20 deletions apps/bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,15 +185,63 @@ function extractJobIdFromMessage(text: string): string | null {
}

/**
* Parse a GitHub PR URL into its components.
* Returns null if the URL is not a valid PR URL.
* Parse a GitHub PR URL into its owner, repo, and PR number components.
*
* Accepts URLs like: https://github.com/owner/repo/pull/123
*/
function parsePrUrl(url: string): { repoUrl: string; prNumber: number } | null {
const match = url.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/)
function parsePrUrl(prUrl: string): { owner: string; repo: string; number: number } | null {
const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/)
if (!match) return null
return {
repoUrl: `https://github.com/${match[1]}`,
prNumber: parseInt(match[2], 10),
return { owner: match[1], repo: match[2], number: parseInt(match[3], 10) }
}

/**
* Merge a pull request via the GitHub REST API.
*/
async function mergePullRequest(owner: string, repo: string, prNumber: number): Promise<void> {
const res = await fetch(
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/merge`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${GITHUB_TOKEN}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
body: JSON.stringify({
merge_method: 'squash',
}),
},
)

if (!res.ok) {
const body = await res.text()
throw new Error(`GitHub merge failed (${res.status}): ${body}`)
}
}

/**
* Close a pull request via the GitHub REST API (without merging).
*/
async function closePullRequest(owner: string, repo: string, prNumber: number): Promise<void> {
const res = await fetch(
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${GITHUB_TOKEN}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
body: JSON.stringify({
state: 'closed',
}),
},
)

if (!res.ok) {
const body = await res.text()
throw new Error(`GitHub close failed (${res.status}): ${body}`)
}
}

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

const ack = await ctx.reply(
`\u{1F504} Detected PR #${prInfo.prNumber}. Queuing revision...`,
`\u{1F504} Detected PR #${prInfo.number}. Queuing revision...`,
{ parse_mode: 'HTML' },
)

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

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

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

const job = await insertJob({
repoUrl: prInfo.repoUrl,
repoUrl: `https://github.com/${prInfo.owner}/${prInfo.repo}`,
task: description,
chatId: ctx.chat!.id,
messageId: ack.message_id,
Expand All @@ -398,7 +446,7 @@ bot.command('task', async (ctx: Context) => {
'\u{1F504} <b>PR revision queued!</b>',
'',
`<b>Job ID:</b> <code>${job.id}</code>`,
`<b>PR:</b> #${prInfo.prNumber}`,
`<b>PR:</b> #${prInfo.number}`,
`<b>Branch:</b> <code>${headRef}</code>`,
`<b>Feedback:</b> ${escapeHtml(description)}`,
'',
Expand Down Expand Up @@ -652,17 +700,24 @@ bot.callbackQuery(/^approve:(.+)$/, async (ctx) => {
return
}

// In a full implementation this would call the GitHub API to merge.
// For now, acknowledge the action and provide the PR link.
await ctx.answerCallbackQuery({ text: 'Approval noted!' })
const parsed = parsePrUrl(job.pr_url)
if (!parsed) {
await ctx.answerCallbackQuery({ text: 'Could not parse PR URL.' })
return
}

await ctx.answerCallbackQuery({ text: 'Merging PR...' })

await mergePullRequest(parsed.owner, parsed.repo, parsed.number)

await ctx.editMessageText(
[
`\u{2705} <b>PR approved for merge</b>`,
`\u{2705} <b>PR merged successfully</b>`,
'',
`<b>Job:</b> <code>${job.id.slice(0, 8)}</code>`,
`<b>PR:</b> ${job.pr_url}`,
'',
'The PR merge has been initiated.',
`Merged <code>${parsed.owner}/${parsed.repo}#${parsed.number}</code> via squash merge.`,
].join('\n'),
{ parse_mode: 'HTML' },
)
Expand All @@ -685,16 +740,24 @@ bot.callbackQuery(/^reject:(.+)$/, async (ctx) => {
return
}

// In a full implementation this would call the GitHub API to close the PR.
await ctx.answerCallbackQuery({ text: 'PR rejected.' })
const parsed = parsePrUrl(job.pr_url)
if (!parsed) {
await ctx.answerCallbackQuery({ text: 'Could not parse PR URL.' })
return
}

await ctx.answerCallbackQuery({ text: 'Closing PR...' })

await closePullRequest(parsed.owner, parsed.repo, parsed.number)

await ctx.editMessageText(
[
`\u{274C} <b>PR rejected and closed</b>`,
'',
`<b>Job:</b> <code>${job.id.slice(0, 8)}</code>`,
`<b>PR:</b> ${job.pr_url}`,
'',
'The PR has been closed without merging.',
`Closed <code>${parsed.owner}/${parsed.repo}#${parsed.number}</code> without merging.`,
].join('\n'),
{ parse_mode: 'HTML' },
)
Expand Down
39 changes: 38 additions & 1 deletion apps/worker/src/__tests__/test-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { detectTestRunner, detectPackageManager, runTests } from '../test-runner.js'
import { detectTestRunner, detectPackageManager, detectMonorepo, runTests } from '../test-runner.js'

// Helper: create a temp directory with specific files
function createTempDir(): string {
Expand Down Expand Up @@ -207,6 +207,43 @@ describe('detectPackageManager', () => {
})
})

describe('detectMonorepo', () => {
let tempDir: string

beforeEach(() => {
tempDir = createTempDir()
})

afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
})

it('detects turborepo from turbo.json', () => {
touchFile(tempDir, 'turbo.json', '{"pipeline":{}}')
expect(detectMonorepo(tempDir)).toBe('turborepo')
})

it('detects pnpm-workspace from pnpm-workspace.yaml', () => {
touchFile(tempDir, 'pnpm-workspace.yaml', 'packages:\n - "apps/*"')
expect(detectMonorepo(tempDir)).toBe('pnpm-workspace')
})

it('returns none for a plain repo', () => {
touchFile(tempDir, 'package.json', '{}')
expect(detectMonorepo(tempDir)).toBe('none')
})

it('returns none for an empty directory', () => {
expect(detectMonorepo(tempDir)).toBe('none')
})

it('prioritizes turborepo when both turbo.json and pnpm-workspace.yaml exist', () => {
touchFile(tempDir, 'turbo.json', '{"pipeline":{}}')
touchFile(tempDir, 'pnpm-workspace.yaml', 'packages:\n - "apps/*"')
expect(detectMonorepo(tempDir)).toBe('turborepo')
})
})

describe('runTests with real commands', () => {
let tempDir: string

Expand Down
50 changes: 49 additions & 1 deletion apps/worker/src/test-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,46 @@ function getSafeEnv(): Record<string, string> {
return env
}

/**
* Monorepo layout type, if detected.
*/
export type MonorepoType = 'turborepo' | 'pnpm-workspace' | 'none'

/**
* Auto-detect whether the repo is a monorepo and what kind.
*
* Detection order:
* 1. turbo.json exists -> Turborepo (uses `pnpm turbo test` or `npx turbo test`)
* 2. pnpm-workspace.yaml exists (without turbo) -> pnpm workspace (`pnpm -r test`)
* 3. Neither -> single repo
*/
export function detectMonorepo(workDir: string): MonorepoType {
if (existsSync(join(workDir, 'turbo.json'))) {
return 'turborepo'
}
if (existsSync(join(workDir, 'pnpm-workspace.yaml'))) {
return 'pnpm-workspace'
}
return 'none'
}

/**
* Build the test command for a monorepo layout.
* Returns null if the repo is not a monorepo (caller should fall back to
* the per-runner command).
*/
function getMonorepoTestCommand(monorepo: MonorepoType, pm: PackageManager): string | null {
switch (monorepo) {
case 'turborepo':
// Prefer pnpm turbo when pnpm is the package manager; otherwise npx
return pm === 'pnpm' ? 'pnpm turbo test' : 'npx turbo test'
case 'pnpm-workspace':
return 'pnpm -r test'
default:
return null
}
}

/**
* Auto-detect the test runner from repo files.
*/
Expand Down Expand Up @@ -150,14 +190,22 @@ function getTestCommand(runner: TestRunner, pm: PackageManager): string {

/**
* Run the test suite and return structured results.
*
* When `monorepo` is provided (or auto-detected), the function uses the
* appropriate monorepo test orchestration command instead of the single-
* runner command.
*/
export function runTests(
workDir: string,
runner: TestRunner,
pm: PackageManager,
timeoutSeconds: number,
monorepo?: MonorepoType,
): TestResults {
const command = getTestCommand(runner, pm)
// Auto-detect monorepo if not explicitly provided
const mono = monorepo ?? detectMonorepo(workDir)
const monorepoCommand = getMonorepoTestCommand(mono, pm)
const command = monorepoCommand ?? getTestCommand(runner, pm)
const startTime = Date.now()

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