Skip to content
Open
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
199 changes: 151 additions & 48 deletions src/adapters/github.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -98,45 +100,16 @@ export class GitHubAdapter implements IPlatformAdapter {
}

/**
* Start the adapter (no-op for webhook-based adapter)
*/
async start(): Promise<void> {
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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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<string> {
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<void> {
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<void> {
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}`;
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -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);
Expand Down