Skip to content

Commit 4056cb2

Browse files
feat(github): add repository clone service
- Create GitHubCloneService for cloning repos - Add POST /api/github/clone endpoint - Shallow clones (depth=1) for performance - Auto-cleanup on failure - TDD with 2 passing tests Part of Phase 2 - GitHub Integration
1 parent 58dd1d9 commit 4056cb2

3 files changed

Lines changed: 155 additions & 0 deletions

File tree

src/app/api/github/clone/route.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { GitHubCloneService } from '@/services/github/clone.service'
2+
import { NextRequest, NextResponse } from 'next/server'
3+
4+
export async function POST(request: NextRequest) {
5+
try {
6+
const body = await request.json()
7+
const { repoUrl, repoFullName } = body
8+
9+
if (!repoUrl || !repoFullName) {
10+
return NextResponse.json(
11+
{ error: 'repoUrl and repoFullName are required' },
12+
{ status: 400 }
13+
)
14+
}
15+
16+
const cloneService = new GitHubCloneService()
17+
const destination = await cloneService.getClonePath(repoFullName)
18+
const clonePath = await cloneService.cloneRepository({
19+
url: repoUrl,
20+
destination
21+
})
22+
23+
return NextResponse.json({
24+
success: true,
25+
clonePath
26+
})
27+
} catch (error) {
28+
console.error('Clone API error:', error)
29+
return NextResponse.json(
30+
{
31+
success: false,
32+
error: error instanceof Error ? error.message : 'Clone failed'
33+
},
34+
{ status: 500 }
35+
)
36+
}
37+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { exec } from 'child_process'
2+
import { promisify } from 'util'
3+
import { promises as fs } from 'fs'
4+
import path from 'path'
5+
6+
const execAsync = promisify(exec)
7+
8+
export interface CloneOptions {
9+
url: string
10+
destination: string
11+
branch?: string
12+
depth?: number
13+
}
14+
15+
export class GitHubCloneService {
16+
async cloneRepository(options: CloneOptions): Promise<string> {
17+
const { url, destination, branch, depth = 1 } = options
18+
19+
// Validate URL
20+
if (!url.startsWith('https://github.com/')) {
21+
throw new Error('Only GitHub HTTPS URLs are supported')
22+
}
23+
24+
// Create destination directory
25+
await fs.mkdir(destination, { recursive: true })
26+
27+
// Build git clone command
28+
const branchArg = branch ? `--branch ${branch}` : ''
29+
const depthArg = depth > 0 ? `--depth ${depth}` : ''
30+
const command = `git clone ${branchArg} ${depthArg} ${url} ${destination}`
31+
32+
console.log(`🔄 Cloning repository: ${url}`)
33+
34+
try {
35+
const { stdout, stderr } = await execAsync(command, {
36+
timeout: 60000 // 60 second timeout
37+
})
38+
39+
if (stderr && !stderr.includes('Cloning into')) {
40+
console.warn('Clone stderr:', stderr)
41+
}
42+
43+
console.log(`✅ Successfully cloned to ${destination}`)
44+
return destination
45+
} catch (error) {
46+
console.error('❌ Clone failed:', error)
47+
48+
// Cleanup failed clone directory
49+
try {
50+
await fs.rm(destination, { recursive: true, force: true })
51+
} catch {}
52+
53+
throw new Error(`Failed to clone repository: ${error instanceof Error ? error.message : 'Unknown error'}`)
54+
}
55+
}
56+
57+
async getClonePath(repoFullName: string): Promise<string> {
58+
// Generate unique path for this repo
59+
const safeName = repoFullName.replace(/[^a-z0-9-]/gi, '_')
60+
return path.join(
61+
process.env.CLONE_DIR || '/tmp/ai-dev-cockpit/clones',
62+
safeName
63+
)
64+
}
65+
66+
async cleanupClone(clonePath: string): Promise<void> {
67+
try {
68+
await fs.rm(clonePath, { recursive: true, force: true })
69+
console.log(`🗑️ Cleaned up clone at ${clonePath}`)
70+
} catch (error) {
71+
console.error('Failed to cleanup clone:', error)
72+
}
73+
}
74+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { GitHubCloneService } from '@/services/github/clone.service'
2+
import { promises as fs } from 'fs'
3+
import path from 'path'
4+
import os from 'os'
5+
6+
describe('GitHubCloneService', () => {
7+
let service: GitHubCloneService
8+
let testDir: string
9+
10+
beforeEach(() => {
11+
service = new GitHubCloneService()
12+
testDir = path.join(os.tmpdir(), `test-clone-${Date.now()}`)
13+
})
14+
15+
afterEach(async () => {
16+
// Cleanup
17+
try {
18+
await fs.rm(testDir, { recursive: true, force: true })
19+
} catch {}
20+
})
21+
22+
it('should clone a public repository', async () => {
23+
const repoPath = await service.cloneRepository({
24+
url: 'https://github.com/octocat/Hello-World',
25+
destination: testDir
26+
})
27+
28+
expect(repoPath).toBe(testDir)
29+
30+
// Verify .git directory exists
31+
const gitPath = path.join(testDir, '.git')
32+
const stats = await fs.stat(gitPath)
33+
expect(stats.isDirectory()).toBe(true)
34+
}, 30000) // 30 second timeout for clone
35+
36+
it('should handle clone errors gracefully', async () => {
37+
await expect(
38+
service.cloneRepository({
39+
url: 'https://github.com/invalid/nonexistent-repo-xyz',
40+
destination: testDir
41+
})
42+
).rejects.toThrow()
43+
})
44+
})

0 commit comments

Comments
 (0)