UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

659 lines 31.5 kB
/** * GitHub MCP Tools for CLI * * Real GitHub integration via `gh` CLI and `git` commands. * Falls back to local state management when CLI tools are unavailable. */ import { getProjectCwd } from './types.js'; import { validateIdentifier, validateText } from './validate-input.js'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { execFileSync, execSync } from 'node:child_process'; // Storage paths const STORAGE_DIR = '.claude-flow'; const GITHUB_DIR = 'github'; const GITHUB_FILE = 'store.json'; function getGitHubDir() { return join(getProjectCwd(), STORAGE_DIR, GITHUB_DIR); } function getGitHubPath() { return join(getGitHubDir(), GITHUB_FILE); } function ensureGitHubDir() { const dir = getGitHubDir(); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } function loadGitHubStore() { try { const path = getGitHubPath(); if (existsSync(path)) { return JSON.parse(readFileSync(path, 'utf-8')); } } catch { // Return empty store } return { repos: {}, prs: {}, issues: {}, version: '3.0.0' }; } function saveGitHubStore(store) { ensureGitHubDir(); writeFileSync(getGitHubPath(), JSON.stringify(store, null, 2), 'utf-8'); } /** * Run a shell command, return stdout or null on failure. * * SECURITY (audit_1776853149979): only call this with a STATIC command * string (no template-string interpolation of user input). For any * caller that needs to pass dynamic / user-supplied values, use * runArgv below — it routes through execFileSync with shell:false so * backticks, $(...), ;, and friends become literal argv bytes. * * The shell-string form is preserved here only because the surviving * callers (`gh issue list ...`, `git rev-list --count HEAD`, …) use * pipes / wc -l and need a shell. Any new caller with user input * MUST use runArgv. */ function run(cmd, cwd) { try { return execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd: cwd || getProjectCwd(), stdio: ['pipe', 'pipe', 'pipe'] }).trim(); } catch { return null; } } /** * Run a program with an argv array (no shell). Use this for any callsite * that mixes user input into the command line — argv elements aren't * interpreted by /bin/sh, so shell metacharacters in user-supplied * strings stay literal. */ function runArgv(file, args, cwd) { try { return execFileSync(file, args, { encoding: 'utf-8', timeout: 15000, cwd: cwd || getProjectCwd(), stdio: ['pipe', 'pipe', 'pipe'], shell: false, }).trim(); } catch { return null; } } /** * Coerce a user-supplied PR / issue / run number to a positive integer. * Returns null if the input can't be safely passed as an argv element to * gh (which would otherwise accept any string). */ function toPositiveInt(value) { const n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0 || n > 2 ** 31) return null; return n; } const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9 _\-./]{0,63}$/; function sanitizeLabels(value) { if (!Array.isArray(value)) return null; const out = []; for (const v of value) { if (typeof v !== 'string' || !LABEL_RE.test(v)) return null; out.push(v); } return out; } /** Check if gh CLI is available */ function hasGhCli() { return run('gh --version') !== null; } export const githubTools = [ { name: 'github_repo_analyze', description: 'Analyze a GitHub repository Use when native Bash / file tools are wrong because this MCP tool exposes Ruflo-specific state or controllers that have no shell equivalent. For tasks that fit a one-line native command, prefer that.', category: 'github', inputSchema: { type: 'object', properties: { owner: { type: 'string', description: 'Repository owner' }, repo: { type: 'string', description: 'Repository name' }, branch: { type: 'string', description: 'Branch to analyze' }, deep: { type: 'boolean', description: 'Deep analysis' }, }, }, handler: async (input) => { if (input.owner) { const v = validateIdentifier(input.owner, 'owner'); if (!v.valid) return { success: false, error: v.error }; } if (input.repo) { const v = validateIdentifier(input.repo, 'repo'); if (!v.valid) return { success: false, error: v.error }; } if (input.branch) { const v = validateIdentifier(input.branch, 'branch'); if (!v.valid) return { success: false, error: v.error }; } const store = loadGitHubStore(); const branch = input.branch || 'main'; const cwd = getProjectCwd(); // Try real git analysis first const commitCount = run('git rev-list --count HEAD', cwd); const branchCount = run('git branch -a --no-color | wc -l', cwd); const contributors = run('git shortlog -sn --no-merges HEAD | wc -l', cwd); const currentBranch = run('git rev-parse --abbrev-ref HEAD', cwd); const remoteUrl = run('git remote get-url origin', cwd); // Parse owner/repo from remote URL let owner = input.owner || ''; let repo = input.repo || ''; if (remoteUrl && (!owner || !repo)) { const m = remoteUrl.match(/[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/); if (m) { owner = owner || m[1]; repo = repo || m[2]; } } const repoKey = `${owner || 'local'}/${repo || 'repo'}`; if (commitCount !== null) { // Real git data available const repoInfo = { owner: owner || 'local', name: repo || 'repo', branch: currentBranch || branch, lastAnalyzed: new Date().toISOString(), metrics: { commits: parseInt(commitCount, 10) || 0, branches: parseInt(branchCount || '0', 10) || 0, contributors: parseInt(contributors || '0', 10) || 0, openIssues: 0, openPRs: 0, }, }; // Try gh CLI for issue/PR counts if (hasGhCli()) { const issueCount = run(`gh issue list --state open --limit 1000 --json number --jq 'length'`); const prCount = run(`gh pr list --state open --limit 1000 --json number --jq 'length'`); if (issueCount !== null) repoInfo.metrics.openIssues = parseInt(issueCount, 10) || 0; if (prCount !== null) repoInfo.metrics.openPRs = parseInt(prCount, 10) || 0; } store.repos[repoKey] = repoInfo; saveGitHubStore(store); return { success: true, _real: true, repository: repoKey, branch: repoInfo.branch, metrics: repoInfo.metrics, remoteUrl: remoteUrl || null, lastAnalyzed: repoInfo.lastAnalyzed, }; } // No git — return local store data return { success: false, error: 'Not a git repository or git not available.', localData: { storedRepos: Object.keys(store.repos) }, }; }, }, { name: 'github_pr_manage', description: 'Manage pull requests Use when native Bash / file tools are wrong because this MCP tool exposes Ruflo-specific state or controllers that have no shell equivalent. For tasks that fit a one-line native command, prefer that.', category: 'github', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'create', 'review', 'merge', 'close'], description: 'Action to perform' }, owner: { type: 'string', description: 'Repository owner' }, repo: { type: 'string', description: 'Repository name' }, prNumber: { type: 'number', description: 'PR number' }, title: { type: 'string', description: 'PR title' }, branch: { type: 'string', description: 'Source branch' }, baseBranch: { type: 'string', description: 'Target branch' }, body: { type: 'string', description: 'PR description' }, }, }, handler: async (input) => { if (input.owner) { const v = validateIdentifier(input.owner, 'owner'); if (!v.valid) return { success: false, error: v.error }; } if (input.repo) { const v = validateIdentifier(input.repo, 'repo'); if (!v.valid) return { success: false, error: v.error }; } if (input.title) { const v = validateText(input.title, 'title'); if (!v.valid) return { success: false, error: v.error }; } if (input.body) { const v = validateText(input.body, 'body'); if (!v.valid) return { success: false, error: v.error }; } if (input.branch) { const v = validateIdentifier(input.branch, 'branch'); if (!v.valid) return { success: false, error: v.error }; } if (input.baseBranch) { const v = validateIdentifier(input.baseBranch, 'baseBranch'); if (!v.valid) return { success: false, error: v.error }; } const store = loadGitHubStore(); const action = input.action || 'list'; const gh = hasGhCli(); if (action === 'list') { if (gh) { const raw = run('gh pr list --state all --limit 20 --json number,title,state,headRefName,createdAt'); if (raw) { try { const prs = JSON.parse(raw); return { success: true, _real: true, source: 'gh-cli', pullRequests: prs, total: prs.length }; } catch { /* fall through */ } } } const prs = Object.values(store.prs); return { success: true, source: 'local-store', pullRequests: prs, total: prs.length, open: prs.filter(pr => pr.status === 'open').length }; } if (action === 'create') { if (gh) { const title = input.title || 'New PR'; const headBranch = input.branch || run('git rev-parse --abbrev-ref HEAD') || 'feature'; const baseBranch = input.baseBranch || 'main'; const body = input.body || ''; // audit_1776853149979: title/body only had length validation, and // the inline .replace(/"/g, '\\"') was a porous escape (no handling // of `, $(...), \). Routes via argv array now — no shell to // interpret metas. const result = runArgv('gh', [ 'pr', 'create', '--title', title, '--base', baseBranch, '--head', headBranch, '--body', body, ]); if (result) { return { success: true, _real: true, action: 'created', url: result }; } } // Fallback: local store const prId = `pr-${Date.now()}`; const pr = { id: prId, title: input.title || 'New PR', status: 'open', branch: input.branch || 'feature', baseBranch: input.baseBranch || 'main', createdAt: new Date().toISOString() }; store.prs[prId] = pr; saveGitHubStore(store); return { success: true, source: 'local-store', action: 'created', pullRequest: pr }; } if (action === 'review') { // audit_1776853149979: prNumber was typed `number` in schema but only // cast at runtime, so a string "1; touch /tmp/x" would interpolate // into the shell. Coerce + validate as positive integer. const prNumber = toPositiveInt(input.prNumber); if (gh && prNumber) { const raw = runArgv('gh', [ 'pr', 'view', String(prNumber), '--json', 'number,title,state,body,additions,deletions,changedFiles,reviews,mergeable,statusCheckRollup', ]); if (raw) { try { return { success: true, _real: true, action: 'review', pullRequest: JSON.parse(raw) }; } catch { /* fall through */ } } } return { success: false, error: prNumber ? 'gh CLI not available or PR not found. Install gh: https://cli.github.com' : 'prNumber is required (positive integer) for review.' }; } if (action === 'merge') { const prNumber = toPositiveInt(input.prNumber); if (gh && prNumber) { const result = runArgv('gh', ['pr', 'merge', String(prNumber), '--merge']); if (result !== null) { return { success: true, _real: true, action: 'merged', prNumber, mergedAt: new Date().toISOString() }; } } // Fallback: local store const prKey = prNumber ? Object.keys(store.prs).find(k => k.includes(String(prNumber))) : undefined; if (prKey && store.prs[prKey]) { store.prs[prKey].status = 'merged'; saveGitHubStore(store); } return { success: true, source: 'local-store', action: 'merged', prNumber, mergedAt: new Date().toISOString() }; } if (action === 'close') { const prNumber = toPositiveInt(input.prNumber); if (gh && prNumber) { const result = runArgv('gh', ['pr', 'close', String(prNumber)]); if (result !== null) { return { success: true, _real: true, action: 'closed', prNumber, closedAt: new Date().toISOString() }; } } const prKey = prNumber ? Object.keys(store.prs).find(k => k.includes(String(prNumber))) : undefined; if (prKey && store.prs[prKey]) { store.prs[prKey].status = 'closed'; saveGitHubStore(store); } return { success: true, source: 'local-store', action: 'closed', prNumber, closedAt: new Date().toISOString() }; } return { success: false, error: 'Unknown action' }; }, }, { name: 'github_issue_track', description: 'Track and manage issues Use when native Bash / file tools are wrong because this MCP tool exposes Ruflo-specific state or controllers that have no shell equivalent. For tasks that fit a one-line native command, prefer that.', category: 'github', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'create', 'update', 'close', 'assign'], description: 'Action to perform' }, owner: { type: 'string', description: 'Repository owner' }, repo: { type: 'string', description: 'Repository name' }, issueNumber: { type: 'number', description: 'Issue number' }, title: { type: 'string', description: 'Issue title' }, body: { type: 'string', description: 'Issue body' }, labels: { type: 'array', items: { type: 'string' }, description: 'Issue labels' }, assignees: { type: 'array', items: { type: 'string' }, description: 'Assignees' }, }, }, handler: async (input) => { if (input.owner) { const v = validateIdentifier(input.owner, 'owner'); if (!v.valid) return { success: false, error: v.error }; } if (input.repo) { const v = validateIdentifier(input.repo, 'repo'); if (!v.valid) return { success: false, error: v.error }; } if (input.title) { const v = validateText(input.title, 'title'); if (!v.valid) return { success: false, error: v.error }; } if (input.body) { const v = validateText(input.body, 'body'); if (!v.valid) return { success: false, error: v.error }; } const store = loadGitHubStore(); const action = input.action || 'list'; const gh = hasGhCli(); if (action === 'list') { if (gh) { const raw = run('gh issue list --state all --limit 20 --json number,title,state,labels,createdAt'); if (raw) { try { const issues = JSON.parse(raw); return { success: true, _real: true, source: 'gh-cli', issues, total: issues.length }; } catch { /* fall through */ } } } const issues = Object.values(store.issues); return { success: true, source: 'local-store', issues, total: issues.length, open: issues.filter(i => i.status === 'open').length }; } if (action === 'create') { const title = input.title || 'New Issue'; const body = input.body || ''; // audit_1776853149979: labels was joined into a shell string with no // validation of the label content. sanitizeLabels rejects anything // outside [A-Za-z0-9 _\-./] and caps each label at 64 chars. const labels = sanitizeLabels(input.labels) ?? []; if (gh) { const argv = ['issue', 'create', '--title', title, '--body', body]; if (labels.length > 0) { argv.push('--label', labels.join(',')); } const result = runArgv('gh', argv); if (result) { return { success: true, _real: true, action: 'created', url: result }; } } const issueId = `issue-${Date.now()}`; const issue = { id: issueId, title, status: 'open', labels, createdAt: new Date().toISOString() }; store.issues[issueId] = issue; saveGitHubStore(store); return { success: true, source: 'local-store', action: 'created', issue }; } if (action === 'update') { const issueNumber = toPositiveInt(input.issueNumber); if (gh && issueNumber) { const argv = ['issue', 'edit', String(issueNumber)]; if (input.title) argv.push('--title', input.title); if (input.labels) { const labels = sanitizeLabels(input.labels); if (labels === null) return { success: false, error: 'labels contains disallowed characters' }; if (labels.length > 0) argv.push('--add-label', labels.join(',')); } if (argv.length > 3) { const result = runArgv('gh', argv); if (result !== null) return { success: true, _real: true, action: 'updated', issueNumber }; } } const issueKey = issueNumber ? Object.keys(store.issues).find(k => k.includes(String(issueNumber))) : undefined; if (issueKey && store.issues[issueKey]) { if (input.title) store.issues[issueKey].title = input.title; if (input.labels) { const labels = sanitizeLabels(input.labels); if (labels !== null) store.issues[issueKey].labels = labels; } saveGitHubStore(store); } return { success: true, source: 'local-store', action: 'updated', issueNumber }; } if (action === 'close') { const issueNumber = toPositiveInt(input.issueNumber); if (gh && issueNumber) { const result = runArgv('gh', ['issue', 'close', String(issueNumber)]); if (result !== null) return { success: true, _real: true, action: 'closed', issueNumber, closedAt: new Date().toISOString() }; } const issueKey = issueNumber ? Object.keys(store.issues).find(k => k.includes(String(issueNumber))) : undefined; if (issueKey && store.issues[issueKey]) { store.issues[issueKey].status = 'closed'; saveGitHubStore(store); } return { success: true, source: 'local-store', action: 'closed', issueNumber, closedAt: new Date().toISOString() }; } return { success: false, error: 'Unknown action' }; }, }, { name: 'github_workflow', description: 'Manage GitHub Actions workflows Use when native Bash / file tools are wrong because this MCP tool exposes Ruflo-specific state or controllers that have no shell equivalent. For tasks that fit a one-line native command, prefer that.', category: 'github', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'trigger', 'status', 'cancel'], description: 'Action to perform' }, owner: { type: 'string', description: 'Repository owner' }, repo: { type: 'string', description: 'Repository name' }, workflowId: { type: 'string', description: 'Workflow ID or name' }, ref: { type: 'string', description: 'Branch or tag ref' }, }, }, handler: async (input) => { if (input.owner) { const v = validateIdentifier(input.owner, 'owner'); if (!v.valid) return { success: false, error: v.error }; } if (input.repo) { const v = validateIdentifier(input.repo, 'repo'); if (!v.valid) return { success: false, error: v.error }; } if (input.workflowId) { const v = validateIdentifier(input.workflowId, 'workflowId'); if (!v.valid) return { success: false, error: v.error }; } if (input.ref) { const v = validateIdentifier(input.ref, 'ref'); if (!v.valid) return { success: false, error: v.error }; } const action = input.action || 'list'; const gh = hasGhCli(); if (!gh) { return { success: false, error: 'gh CLI not available. Install: https://cli.github.com' }; } if (action === 'list') { const raw = run('gh run list --limit 10 --json databaseId,displayTitle,status,conclusion,headBranch,createdAt'); if (raw) { try { return { success: true, _real: true, runs: JSON.parse(raw) }; } catch { /* fall through */ } } const workflows = run('gh workflow list --json id,name,state'); if (workflows) { try { return { success: true, _real: true, workflows: JSON.parse(workflows) }; } catch { /* fall through */ } } } if (action === 'status') { const workflowId = input.workflowId; if (workflowId) { // workflowId is already validated by validateIdentifier above, but // route through argv anyway for consistency / defense-in-depth. const raw = runArgv('gh', [ 'run', 'view', workflowId, '--json', 'databaseId,displayTitle,status,conclusion,jobs', ]); if (raw) { try { return { success: true, _real: true, run: JSON.parse(raw) }; } catch { /* fall through */ } } } // List recent runs as fallback const recent = run('gh run list --limit 5 --json databaseId,displayTitle,status,conclusion'); if (recent) { try { return { success: true, _real: true, recentRuns: JSON.parse(recent) }; } catch { /* fall through */ } } } if (action === 'trigger') { const workflowId = input.workflowId; const ref = input.ref || 'main'; if (workflowId) { const result = runArgv('gh', ['workflow', 'run', workflowId, '--ref', ref]); if (result !== null) return { success: true, _real: true, action: 'triggered', workflowId, ref }; } return { success: false, error: 'workflowId is required to trigger a workflow.' }; } if (action === 'cancel') { const workflowId = input.workflowId; if (workflowId) { const result = runArgv('gh', ['run', 'cancel', workflowId]); if (result !== null) return { success: true, _real: true, action: 'cancelled', runId: workflowId }; } return { success: false, error: 'workflowId (run ID) is required to cancel.' }; } return { success: false, error: `Unknown action: ${action}` }; }, }, { name: 'github_metrics', description: 'Get repository metrics and statistics Use when native Bash / file tools are wrong because this MCP tool exposes Ruflo-specific state or controllers that have no shell equivalent. For tasks that fit a one-line native command, prefer that.', category: 'github', inputSchema: { type: 'object', properties: { owner: { type: 'string', description: 'Repository owner' }, repo: { type: 'string', description: 'Repository name' }, metric: { type: 'string', enum: ['all', 'commits', 'contributors', 'traffic', 'releases'], description: 'Metric type' }, timeRange: { type: 'string', description: 'Time range (e.g., "7d", "30d", "90d")' }, }, }, handler: async (input) => { if (input.owner) { const v = validateIdentifier(input.owner, 'owner'); if (!v.valid) return { success: false, error: v.error }; } if (input.repo) { const v = validateIdentifier(input.repo, 'repo'); if (!v.valid) return { success: false, error: v.error }; } const metric = input.metric || 'all'; const timeRange = input.timeRange || '30d'; const cwd = getProjectCwd(); // Parse time range const days = parseInt(timeRange, 10) || 30; const since = new Date(Date.now() - days * 86400000).toISOString().split('T')[0]; const result = { _real: true, timeRange: `${days}d`, since }; const wantAll = metric === 'all'; if (wantAll || metric === 'commits') { const total = run(`git rev-list --count HEAD`, cwd); const recent = run(`git rev-list --count --since="${since}" HEAD`, cwd); result.commits = { total: parseInt(total || '0', 10), sincePeriod: parseInt(recent || '0', 10), }; } if (wantAll || metric === 'contributors') { const allContrib = run('git shortlog -sn --no-merges HEAD', cwd); if (allContrib) { const lines = allContrib.split('\n').filter(Boolean); result.contributors = { total: lines.length, top: lines.slice(0, 10).map(l => { const m = l.trim().match(/^(\d+)\t(.+)$/); return m ? { commits: parseInt(m[1], 10), name: m[2].trim() } : null; }).filter(Boolean), }; } } if (wantAll || metric === 'releases') { if (hasGhCli()) { const raw = run('gh release list --limit 10 --json tagName,name,publishedAt,isPrerelease'); if (raw) { try { result.releases = JSON.parse(raw); } catch { /* skip */ } } } if (!result.releases) { const tags = run('git tag --sort=-creatordate | head -10', cwd); result.releases = tags ? tags.split('\n').filter(Boolean).map(t => ({ tagName: t })) : []; } } // Always include branch info const branchCount = run('git branch -a --no-color | wc -l', cwd); const currentBranch = run('git rev-parse --abbrev-ref HEAD', cwd); result.branches = { total: parseInt(branchCount || '0', 10), current: currentBranch }; return { success: true, ...result }; }, }, ]; //# sourceMappingURL=github-tools.js.map