UNPKG

cyrus-ai

Version:

AI-powered Linear issue automation using Claude

244 lines 9.74 kB
import { execSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import * as readline from "node:readline"; import { DEFAULT_BASE_BRANCH, DEFAULT_CONFIG_FILENAME, migrateEdgeConfig, } from "cyrus-core"; import { getDefaultReposDir } from "../utils/getDefaultReposDir.js"; import { getDefaultWorktreesDir } from "../utils/getDefaultWorktreesDir.js"; import { BaseCommand } from "./ICommand.js"; /** * Detect the default branch of a cloned repository by reading the remote HEAD ref. * Falls back to DEFAULT_BASE_BRANCH ("main") if detection fails. */ export function detectDefaultBranch(repositoryPath) { try { const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", { cwd: repositoryPath, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim(); // ref looks like "refs/remotes/origin/main" — extract the branch name const branch = ref.replace("refs/remotes/origin/", ""); if (branch) { return branch; } } catch { // symbolic-ref not set — try `git remote show origin` as fallback try { const output = execSync("git remote show origin", { cwd: repositoryPath, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }); const match = output.match(/HEAD branch:\s*(.+)/); if (match?.[1]?.trim()) { return match[1].trim(); } } catch { // Both methods failed, fall through to default } } return DEFAULT_BASE_BRANCH; } /** * Self-add-repo command - clones a repo and adds it to config.json * * Usage: * cyrus self-add-repo # prompts for everything * cyrus self-add-repo <url> # prompts for workspace if multiple * cyrus self-add-repo <url> <workspace> # no prompts * cyrus self-add-repo <url> -l <labels> # custom routing labels (comma-separated) * cyrus self-add-repo <url> <workspace> -l <labels> * * Routing labels are used to route Linear issues to this repository. * If not specified, defaults to the repository name. */ export class SelfAddRepoCommand extends BaseCommand { rl = null; getReadline() { if (!this.rl) { this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); } return this.rl; } prompt(question) { return new Promise((resolve) => { this.getReadline().question(question, (answer) => resolve(answer.trim())); }); } cleanup() { if (this.rl) { this.rl.close(); this.rl = null; } } async execute(args) { // Parse flags let customLabels = null; let baseBranchFlag = null; const positionalArgs = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (!arg) continue; if ((arg === "-l" || arg === "--label") && args[i + 1]) { customLabels = args[i + 1].split(",") .map((l) => l.trim()) .filter((l) => l.length > 0); i++; // Skip the label value } else if ((arg === "-b" || arg === "--base-branch") && args[i + 1]) { baseBranchFlag = args[i + 1]; i++; // Skip the branch value } else { positionalArgs.push(arg); } } let url = positionalArgs[0]; const workspaceName = positionalArgs[1]; try { // Load config const configPath = resolve(this.app.cyrusHome, DEFAULT_CONFIG_FILENAME); let config; try { config = migrateEdgeConfig(JSON.parse(readFileSync(configPath, "utf-8"))); } catch { this.logError(`Config file not found: ${configPath}`); process.exit(1); } if (!config.repositories) { config.repositories = []; } // Get URL if not provided if (!url) { url = await this.prompt("Repository URL: "); if (!url) { this.logError("URL is required"); process.exit(1); } } // Extract repo name from URL const repoName = url .split("/") .pop() ?.replace(/\.git$/, ""); if (!repoName) { this.logError("Could not extract repo name from URL"); process.exit(1); } // Check for duplicate if (config.repositories.some((r) => r.name === repoName)) { this.logError(`Repository '${repoName}' already exists in config`); process.exit(1); } // Find workspaces with Linear credentials (from workspace-level config) const workspaces = new Map(); if (config.linearWorkspaces) { for (const [wsId, wsConfig] of Object.entries(config.linearWorkspaces)) { if (wsConfig.linearToken) { workspaces.set(wsId, { id: wsId, name: wsConfig.linearWorkspaceName || wsId, token: wsConfig.linearToken, refreshToken: wsConfig.linearRefreshToken, }); } } } if (workspaces.size === 0) { this.logError("No Linear credentials found. Run 'cyrus self-auth-linear' first."); process.exit(1); } // Get workspace let selectedWorkspace; const workspaceList = Array.from(workspaces.values()); if (workspaceList.length === 1) { // Safe: we checked length === 1 above selectedWorkspace = workspaceList[0]; } else if (workspaceName) { const foundWorkspace = workspaceList.find((w) => w.name === workspaceName); if (!foundWorkspace) { this.logError(`Workspace '${workspaceName}' not found`); process.exit(1); } selectedWorkspace = foundWorkspace; } else { console.log("\nAvailable workspaces:"); workspaceList.forEach((w, i) => { console.log(` ${i + 1}. ${w.name}`); }); const choice = await this.prompt(`Select workspace [1-${workspaceList.length}]: `); const idx = parseInt(choice, 10) - 1; if (idx < 0 || idx >= workspaceList.length) { this.logError("Invalid selection"); process.exit(1); } // Safe: we validated idx is within bounds above selectedWorkspace = workspaceList[idx]; } // Clone the repo const repositoryPath = resolve(getDefaultReposDir(this.app.cyrusHome), repoName); if (existsSync(repositoryPath)) { console.log(`Repository already exists at ${repositoryPath}`); } else { console.log(`Cloning ${url}...`); try { execSync(`git clone ${url} ${repositoryPath}`, { stdio: "inherit" }); } catch { this.logError("Failed to clone repository"); process.exit(1); } } // Generate UUID and add to config const id = randomUUID(); const routingLabels = customLabels ?? [repoName]; // Determine base branch: flag > auto-detect > default const baseBranch = baseBranchFlag ?? detectDefaultBranch(repositoryPath); if (baseBranch !== DEFAULT_BASE_BRANCH) { console.log(`Detected base branch: ${baseBranch}`); } // Detect hosting platform from URL const repoConfig = { id, name: repoName, repositoryPath, baseBranch, workspaceBaseDir: getDefaultWorktreesDir(this.app.cyrusHome), linearWorkspaceId: selectedWorkspace.id, isActive: true, routingLabels, }; if (url.includes("gitlab.com") || url.includes("gitlab.")) { repoConfig.gitlabUrl = url.replace(/\.git$/, ""); } else if (url.includes("github.com")) { repoConfig.githubUrl = url.replace(/\.git$/, ""); } config.repositories.push(repoConfig); writeFileSync(configPath, JSON.stringify(config, null, "\t"), "utf-8"); console.log(`\nAdded: ${repoName}`); console.log(` ID: ${id}`); console.log(` Base branch: ${baseBranch}`); console.log(` Workspace: ${selectedWorkspace.name}`); console.log(` Routing labels: ${routingLabels.join(", ")}`); console.log(`\nTo use different routing labels, edit ${configPath}`); process.exit(0); } finally { this.cleanup(); } } } //# sourceMappingURL=SelfAddRepoCommand.js.map