UNPKG

@posthog/agent

Version:

TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog

455 lines (449 loc) 17.3 kB
import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import { Logger } from './utils/logger.js'; const execAsync = promisify(exec); class GitManager { repositoryPath; authorName; authorEmail; logger; constructor(config) { this.repositoryPath = config.repositoryPath; this.authorName = config.authorName; this.authorEmail = config.authorEmail; this.logger = config.logger || new Logger({ debug: false, prefix: "[GitManager]" }); } escapeShellArg(str) { return str .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/`/g, "\\`") .replace(/\$/g, "\\$"); } async runGitCommand(command) { try { const { stdout } = await execAsync(`cd "${this.repositoryPath}" && git ${command}`); return stdout.trim(); } catch (error) { throw new Error(`Git command failed: ${command}\n${error}`); } } async runCommand(command) { try { const { stdout } = await execAsync(`cd "${this.repositoryPath}" && ${command}`); return stdout.trim(); } catch (error) { throw new Error(`Command failed: ${command}\n${error}`); } } async isGitRepository() { try { await this.runGitCommand("rev-parse --git-dir"); return true; } catch { return false; } } async getCurrentBranch() { return await this.runGitCommand("branch --show-current"); } async getDefaultBranch() { try { // Try to get the default branch from remote const remoteBranch = await this.runGitCommand("symbolic-ref refs/remotes/origin/HEAD"); return remoteBranch.replace("refs/remotes/origin/", ""); } catch { // Fallback: check if main exists, otherwise use master if (await this.branchExists("main")) { return "main"; } else if (await this.branchExists("master")) { return "master"; } else { throw new Error("Cannot determine default branch. No main or master branch found."); } } } async branchExists(branchName) { try { await this.runGitCommand(`rev-parse --verify ${branchName}`); return true; } catch { return false; } } async createBranch(branchName, baseBranch) { const base = baseBranch || (await this.getCurrentBranch()); await this.runGitCommand(`checkout -b ${branchName} ${base}`); } async switchToBranch(branchName) { await this.runGitCommand(`checkout ${branchName}`); } async resetToDefaultBranchIfNeeded() { const currentBranch = await this.getCurrentBranch(); const defaultBranch = await this.getDefaultBranch(); if (currentBranch === defaultBranch) { this.logger.debug("Already on default branch", { branch: defaultBranch }); return true; } if (await this.hasChanges()) { this.logger.warn("Skipping branch reset - uncommitted changes present", { currentBranch, defaultBranch, }); return false; } await this.switchToBranch(defaultBranch); this.logger.info("Reset to default branch", { from: currentBranch, to: defaultBranch, }); return true; } async createOrSwitchToBranch(branchName, baseBranch) { await this.ensureCleanWorkingDirectory("switching branches"); const exists = await this.branchExists(branchName); if (exists) { await this.switchToBranch(branchName); } else { await this.createBranch(branchName, baseBranch); } } async addFiles(paths) { const pathList = paths.map((p) => `"${this.escapeShellArg(p)}"`).join(" "); await this.runGitCommand(`add ${pathList}`); } async addAllPostHogFiles() { try { // Use -A flag to add all changes (including new files) and ignore errors if directory is empty await this.runGitCommand("add -A .posthog/"); } catch (error) { // If the directory doesn't exist or has no files, that's fine - just log and continue this.logger.debug("No PostHog files to add", { error }); } } async commitChanges(message, options) { const command = this.buildCommitCommand(message, options); return await this.runGitCommand(command); } async hasChanges() { try { const status = await this.runGitCommand("status --porcelain"); if (!status || status.trim().length === 0) { return false; } const lines = status.split("\n").filter((line) => { const trimmed = line.trim(); return trimmed.length > 0 && !trimmed.includes(".posthog/"); }); return lines.length > 0; } catch { return false; } } async hasStagedChanges() { try { const status = await this.runGitCommand("diff --cached --name-only"); return status.length > 0; } catch { return false; } } // Helper: Centralized safety check for uncommitted changes async ensureCleanWorkingDirectory(operation) { if (await this.hasChanges()) { throw new Error(`Uncommitted changes detected. Please commit or stash changes before ${operation}.`); } } async generateUniqueBranchName(baseName) { if (!(await this.branchExists(baseName))) { return baseName; } let counter = 1; let uniqueName = `${baseName}-${counter}`; while (await this.branchExists(uniqueName)) { counter++; uniqueName = `${baseName}-${counter}`; } return uniqueName; } async ensureOnDefaultBranch() { const defaultBranch = await this.getDefaultBranch(); const currentBranch = await this.getCurrentBranch(); if (currentBranch !== defaultBranch) { await this.ensureCleanWorkingDirectory("switching to default branch"); await this.switchToBranch(defaultBranch); } return defaultBranch; } buildCommitCommand(message, options) { let command = `commit -m "${this.escapeShellArg(message)}"`; if (options?.allowEmpty) { command += " --allow-empty"; } const authorName = options?.authorName || this.authorName; const authorEmail = options?.authorEmail || this.authorEmail; if (authorName && authorEmail) { command += ` --author="${authorName} <${authorEmail}>"`; } return command; } async getRemoteUrl() { try { return await this.runGitCommand("remote get-url origin"); } catch { return null; } } async pushBranch(branchName, force = false) { const forceFlag = force ? "--force" : ""; await this.runGitCommand(`push ${forceFlag} -u origin ${branchName}`); } /** * Tracks whether commits were made during an operation by comparing HEAD SHA * before and after. Returns an object with methods to finalize the operation. * * Usage: * const tracker = await gitManager.trackCommitsDuring(); * // ... do work that might create commits ... * const result = await tracker.finalize({ commitMessage: 'fallback message', push: true }); */ async trackCommitsDuring() { const initialSha = await this.getCommitSha("HEAD"); return { finalize: async (options) => { const currentSha = await this.getCommitSha("HEAD"); const externalCommitsCreated = initialSha !== currentSha; const hasUncommittedChanges = await this.hasChanges(); // If no commits and no changes, nothing to do if (!externalCommitsCreated && !hasUncommittedChanges) { return { commitCreated: false, pushedBranch: false }; } let commitCreated = externalCommitsCreated; // Commit any remaining uncommitted changes if (hasUncommittedChanges) { await this.runGitCommand("add ."); const hasStagedChanges = await this.hasStagedChanges(); if (hasStagedChanges) { await this.commitChanges(options.commitMessage); commitCreated = true; } } // Push if requested and commits were made let pushedBranch = false; if (options.push && commitCreated) { const currentBranch = await this.getCurrentBranch(); await this.pushBranch(currentBranch); pushedBranch = true; this.logger.info("Pushed branch after operation", { branch: currentBranch, }); } return { commitCreated, pushedBranch }; }, }; } async createTaskBranch(taskSlug) { const branchName = `posthog/task-${taskSlug}`; // Ensure we're on default branch before creating task branch const defaultBranch = await this.ensureOnDefaultBranch(); this.logger.info("Creating task branch from default branch", { branchName, taskSlug, baseBranch: defaultBranch, }); await this.createOrSwitchToBranch(branchName, defaultBranch); return branchName; } async createTaskPlanningBranch(taskId, baseBranch) { const baseName = `posthog/task-${taskId}-planning`; const branchName = await this.generateUniqueBranchName(baseName); this.logger.debug("Creating unique planning branch", { branchName, taskId, }); const base = baseBranch || (await this.ensureOnDefaultBranch()); await this.createBranch(branchName, base); return branchName; } async createTaskImplementationBranch(taskId, planningBranchName) { const baseName = `posthog/task-${taskId}-implementation`; const branchName = await this.generateUniqueBranchName(baseName); this.logger.debug("Creating unique implementation branch", { branchName, taskId, currentBranch: await this.getCurrentBranch(), }); // Determine base branch: explicit param > current planning branch > default let baseBranch = planningBranchName; if (!baseBranch) { const currentBranch = await this.getCurrentBranch(); if (currentBranch.includes("-planning")) { baseBranch = currentBranch; this.logger.debug("Using current planning branch", { baseBranch }); } else { baseBranch = await this.ensureOnDefaultBranch(); this.logger.debug("Using default branch", { baseBranch }); } } this.logger.debug("Creating implementation branch from base", { baseBranch, branchName, }); await this.createBranch(branchName, baseBranch); this.logger.info("Implementation branch created", { branchName, currentBranch: await this.getCurrentBranch(), }); return branchName; } async commitPlan(taskId, taskTitle) { const currentBranch = await this.getCurrentBranch(); this.logger.debug("Committing plan", { taskId, currentBranch }); await this.addAllPostHogFiles(); const hasChanges = await this.hasStagedChanges(); this.logger.debug("Checking for staged changes", { hasChanges }); if (!hasChanges) { this.logger.info("No plan changes to commit", { taskId }); return "No changes to commit"; } const message = `📋 Add plan for task: ${taskTitle} Task ID: ${taskId} Generated by PostHog Agent This commit contains the implementation plan and supporting documentation for the task. Review the plan before proceeding with implementation.`; const result = await this.commitChanges(message); this.logger.info("Plan committed", { taskId, taskTitle }); return result; } async commitImplementation(taskId, taskTitle, planSummary) { await this.runGitCommand("add ."); const hasChanges = await this.hasStagedChanges(); if (!hasChanges) { this.logger.warn("No implementation changes to commit", { taskId }); return "No changes to commit"; } let message = `✨ Implement task: ${taskTitle} Task ID: ${taskId} Generated by PostHog Agent`; if (planSummary) { message += `\n\nPlan Summary:\n${planSummary}`; } message += `\n\nThis commit implements the changes described in the task plan.`; const result = await this.commitChanges(message); this.logger.info("Implementation committed", { taskId, taskTitle }); return result; } async deleteBranch(branchName, force = false) { const forceFlag = force ? "-D" : "-d"; await this.runGitCommand(`branch ${forceFlag} ${branchName}`); } async deleteRemoteBranch(branchName) { await this.runGitCommand(`push origin --delete ${branchName}`); } async getBranchInfo(branchName) { const exists = await this.branchExists(branchName); const currentBranch = await this.getCurrentBranch(); return { name: branchName, exists, isCurrentBranch: branchName === currentBranch, }; } async getCommitSha(ref = "HEAD") { return await this.runGitCommand(`rev-parse ${ref}`); } async getCommitMessage(ref = "HEAD") { return await this.runGitCommand(`log -1 --pretty=%B ${ref}`); } async createPullRequest(branchName, title, body, baseBranch) { const currentBranch = await this.getCurrentBranch(); if (currentBranch !== branchName) { await this.ensureCleanWorkingDirectory("creating PR"); await this.switchToBranch(branchName); } await this.pushBranch(branchName); let command = `gh pr create --title "${this.escapeShellArg(title)}" --body "${this.escapeShellArg(body)}"`; if (baseBranch) { command += ` --base ${baseBranch}`; } try { const prUrl = await this.runCommand(command); return prUrl.trim(); } catch (error) { throw new Error(`Failed to create PR: ${error}`); } } async getTaskBranch(taskSlug) { try { // Get all branches matching the task slug pattern const branches = await this.runGitCommand("branch --list --all"); const branchPattern = `posthog/task-${taskSlug}`; // Look for exact match or with counter suffix const lines = branches .split("\n") .map((l) => l.trim().replace(/^\*\s+/, "")); for (const line of lines) { const cleanBranch = line.replace("remotes/origin/", ""); if (cleanBranch.startsWith(branchPattern)) { return cleanBranch; } } return null; } catch (error) { this.logger.debug("Failed to get task branch", { taskSlug, error }); return null; } } async commitAndPush(message, options) { const hasChanges = await this.hasStagedChanges(); if (!hasChanges && !options?.allowEmpty) { this.logger.debug("No changes to commit, skipping"); return; } const command = this.buildCommitCommand(message, options); await this.runGitCommand(command); // Push to origin const currentBranch = await this.getCurrentBranch(); await this.pushBranch(currentBranch); this.logger.info("Committed and pushed changes", { branch: currentBranch, message, }); } async isWorktree() { try { // In a worktree, .git is a file pointing to the main repo's .git/worktrees/{name} // In a normal repo, .git is a directory const result = await this.runGitCommand("rev-parse --git-common-dir --git-dir"); const lines = result.split("\n"); if (lines.length >= 2) { const commonDir = lines[0].trim(); const gitDir = lines[1].trim(); // If they're different, we're in a worktree return commonDir !== gitDir; } return false; } catch { return false; } } } export { GitManager }; //# sourceMappingURL=git-manager.js.map