diff --git a/src/adapters/github.ts b/src/adapters/github.ts index c31a8f7..d17682e 100644 --- a/src/adapters/github.ts +++ b/src/adapters/github.ts @@ -1,6 +1,8 @@ /** * GitHub platform adapter using Octokit REST API and Webhooks * Handles issue and PR comments with @mention detection + * + * ENHANCED: Added worktree isolation for concurrent issue handling */ import { Octokit } from '@octokit/rest'; import { createHmac } from 'crypto'; @@ -98,45 +100,16 @@ export class GitHubAdapter implements IPlatformAdapter { } /** - * Start the adapter (no-op for webhook-based adapter) - */ - async start(): Promise { - console.log('[GitHub] Webhook adapter ready'); - } - - /** - * Stop the adapter (no-op for webhook-based adapter) - */ - stop(): void { - console.log('[GitHub] Adapter stopped'); - } - - /** - * Verify webhook signature using HMAC SHA-256 + * Verify GitHub webhook signature */ private verifySignature(payload: string, signature: string): boolean { - try { - const hmac = createHmac('sha256', this.webhookSecret); - const digest = 'sha256=' + hmac.update(payload).digest('hex'); - const isValid = digest === signature; - - if (!isValid) { - console.error('[GitHub] Signature mismatch:', { - received: signature.substring(0, 15) + '...', - computed: digest.substring(0, 15) + '...', - secretLength: this.webhookSecret.length, - }); - } - - return isValid; - } catch (error) { - console.error('[GitHub] Signature verification error:', error); - return false; - } + const hmac = createHmac('sha256', this.webhookSecret); + const digest = 'sha256=' + hmac.update(payload).digest('hex'); + return signature === digest; } /** - * Parse webhook event and extract relevant data + * Parse event to extract relevant data */ private parseEvent(event: WebhookEvent): { owner: string; @@ -196,7 +169,7 @@ export class GitHubAdapter implements IPlatformAdapter { * Check if text contains @remote-agent mention */ private hasMention(text: string): boolean { - return /@remote-agent[\s,:;]/.test(text) || text.trim() === '@remote-agent'; + return /@remote-agent\b/i.test(text); } /** @@ -292,13 +265,103 @@ export class GitHubAdapter implements IPlatformAdapter { } } + /** + * Get or create a git worktree for a specific issue (provides isolation) + * This allows concurrent work on different issues without interference + */ + private async getOrCreateWorktree(baseRepoPath: string, issueNumber: number): Promise { + const worktreePath = `${baseRepoPath}-issue-${issueNumber}`; + const maxRetries = 3; + + try { + await access(worktreePath); + // Worktree exists - reset it to latest main + console.log(`[GitHub] [Issue #${issueNumber}] Using existing worktree: ${worktreePath}`); + await execAsync(`git -C ${worktreePath} fetch origin main 2>/dev/null || true`); + await execAsync(`git -C ${worktreePath} checkout . 2>/dev/null || true`); + await execAsync(`git -C ${worktreePath} clean -fd 2>/dev/null || true`); + await execAsync(`git -C ${worktreePath} reset --hard origin/main 2>/dev/null || true`); + return worktreePath; + } catch { + // Create new worktree from main with retry logic + console.log(`[GitHub] [Issue #${issueNumber}] Creating new worktree: ${worktreePath}`); + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + // Ensure base repo exists and is ready + await access(baseRepoPath); + await execAsync(`git -C ${baseRepoPath} fetch origin main`); + + // Remove any stale worktree reference + await execAsync(`git -C ${baseRepoPath} worktree prune 2>/dev/null || true`); + await execAsync(`rm -rf ${worktreePath} 2>/dev/null || true`); + + // Create worktree + await execAsync(`git -C ${baseRepoPath} worktree add -f ${worktreePath} origin/main`); + console.log(`[GitHub] [Issue #${issueNumber}] Worktree created successfully`); + return worktreePath; + } catch (e) { + console.warn(`[GitHub] [Issue #${issueNumber}] Worktree creation attempt ${attempt}/${maxRetries} failed: ${e}`); + if (attempt < maxRetries) { + // Wait before retry (exponential backoff) + await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); + } + } + } + + console.error(`[GitHub] [Issue #${issueNumber}] Failed to create worktree after ${maxRetries} attempts, using base repo`); + return baseRepoPath; + } + } + + /** + * Cleanup a worktree for a specific issue + */ + private async cleanupWorktree(baseRepoPath: string, issueNumber: number): Promise { + const worktreePath = `${baseRepoPath}-issue-${issueNumber}`; + try { + await execAsync(`git -C ${baseRepoPath} worktree remove ${worktreePath} --force 2>/dev/null || true`); + await execAsync(`rm -rf ${worktreePath} 2>/dev/null || true`); + console.log(`[GitHub] [Issue #${issueNumber}] Cleaned up worktree: ${worktreePath}`); + } catch (e) { + console.warn(`[GitHub] Could not cleanup worktree: ${e}`); + } + } + + /** + * Cleanup all stale worktrees (older than 7 days) + */ + private async cleanupStaleWorktrees(baseRepoPath: string): Promise { + try { + const { stdout } = await execAsync(`find ${baseRepoPath}-issue-* -maxdepth 0 -mtime +7 2>/dev/null || true`); + const stalePaths = stdout.trim().split('\n').filter(p => p); + + for (const path of stalePaths) { + const match = path.match(/issue-(\d+)$/); + if (match) { + const issueNum = parseInt(match[1]); + await this.cleanupWorktree(baseRepoPath, issueNum); + } + } + + if (stalePaths.length > 0) { + console.log(`[GitHub] Cleaned up ${stalePaths.length} stale worktrees`); + } + } catch { + // Ignore errors during stale cleanup + } + } + /** * Get or create codebase for repository * Returns: codebase record, path to use, and whether it's new + * + * ENHANCED: Supports issue-specific worktrees for isolation */ private async getOrCreateCodebaseForRepo( owner: string, - repo: string + repo: string, + issueNumber?: number ): Promise<{ codebase: { id: string; name: string }; repoPath: string; isNew: boolean }> { // Try both with and without .git suffix to match existing clones const repoUrlNoGit = `https://github.com/${owner}/${repo}`; @@ -311,19 +374,46 @@ export class GitHubAdapter implements IPlatformAdapter { if (existing) { console.log(`[GitHub] Using existing codebase: ${existing.name} at ${existing.default_cwd}`); - return { codebase: existing, repoPath: existing.default_cwd, isNew: false }; + + // Check if base repo actually exists on disk (may be lost after container restart) + let baseRepoExists = false; + try { + await access(existing.default_cwd); + await execAsync(`git -C ${existing.default_cwd} status`); + baseRepoExists = true; + } catch { + console.log(`[GitHub] Base repo not found at ${existing.default_cwd}, will clone first`); + } + + if (baseRepoExists) { + // Update base repo + try { + await execAsync(`git -C ${existing.default_cwd} fetch origin main 2>/dev/null || true`); + } catch (e) { + console.warn(`[GitHub] Could not fetch latest: ${e}`); + } + + // Use worktree for issue isolation if issue number provided + if (issueNumber) { + const worktreePath = await this.getOrCreateWorktree(existing.default_cwd, issueNumber); + return { codebase: existing, repoPath: worktreePath, isNew: false }; + } + + return { codebase: existing, repoPath: existing.default_cwd, isNew: false }; + } + // baseRepoExists is false - fall through to clone new repo } // Use just the repo name (not owner-repo) to match /clone behavior const repoPath = `/workspace/${repo}`; - const codebase = await codebaseDb.createCodebase({ + const codebase = existing || await codebaseDb.createCodebase({ name: repo, repository_url: repoUrlNoGit, // Store without .git for consistency default_cwd: repoPath, }); console.log(`[GitHub] Created new codebase: ${codebase.name} at ${repoPath}`); - return { codebase, repoPath, isNew: true }; + return { codebase, repoPath, isNew: !existing }; } /** @@ -392,6 +482,20 @@ ${userComment}`; const { owner, repo, number, comment, eventType, issue, pullRequest } = parsed; + // Handle PR/issue close events - cleanup worktree + if (event.action === 'closed') { + const closeNumber = event.pull_request?.number || event.issue?.number; + const closeRepo = event.repository?.name; + if (closeNumber && closeRepo) { + const baseRepoPath = `/workspace/${closeRepo}`; + this.cleanupWorktree(baseRepoPath, closeNumber).catch(e => + console.warn(`[GitHub] Cleanup failed: ${e}`) + ); + this.cleanupStaleWorktrees(baseRepoPath).catch(() => {}); + } + return; // Don't process close events further + } + // 3. Check @mention if (!this.hasMention(comment)) return; @@ -404,10 +508,11 @@ ${userComment}`; const existingConv = await db.getOrCreateConversation('github', conversationId); const isNewConversation = !existingConv.codebase_id; - // 6. Get/create codebase (checks for existing first!) + // 6. Get/create codebase with worktree isolation const { codebase, repoPath, isNew: isNewCodebase } = await this.getOrCreateCodebaseForRepo( owner, - repo + repo, + number // Pass issue/PR number for worktree isolation ); // 7. Get default branch @@ -422,13 +527,11 @@ ${userComment}`; await this.autoDetectAndLoadCommands(repoPath, codebase.id); } - // 10. Update conversation - if (isNewConversation) { - await db.updateConversation(existingConv.id, { - codebase_id: codebase.id, - cwd: repoPath, - }); - } + // 10. Update conversation - ALWAYS update cwd to ensure correct worktree is used + await db.updateConversation(existingConv.id, { + codebase_id: codebase.id, + cwd: repoPath, // Always set to current worktree path + }); // 11. Build message with context const strippedComment = this.stripMention(comment);