UNPKG

@morodomi/ait3

Version:

AIT³ Development Platform - AI + Ticket + Test + Tool driven development methodology

223 lines (222 loc) 8.28 kB
import { promisify } from 'util'; import { exec as execCallback } from 'child_process'; import { Octokit } from '@octokit/rest'; import { validateTicketsDirectory, readConfig, writeConfig, buildTicketNotice } from '../common/config-utils.js'; const defaultExec = promisify(execCallback); /** * Validates GitHub repository identifier (owner or repo name) * GitHub allows: alphanumeric, hyphens, dots, underscores * But NOT: semicolons, pipes, command substitution, spaces, etc. */ function validateGitHubIdentifier(identifier) { // GitHub username/repo rules: // - Can contain alphanumeric characters, hyphens, dots, underscores // - Cannot start or end with hyphens, dots // - Cannot contain spaces or shell metacharacters const validPattern = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/; return validPattern.test(identifier) && identifier.length > 0 && identifier.length <= 39; } /** * Safely gets GitHub auth token from gh CLI */ async function getGitHubAuthToken(exec) { try { const { stdout } = await exec('gh auth token'); return stdout.trim(); } catch { return null; } } export async function setupTicketGitHub(options, services, context, exec = defaultExec) { // Check if .tickets directory exists const dirError = await validateTicketsDirectory(context.cwd); if (dirError) return dirError; // Check if already configured const existingConfig = await readConfig(context.cwd); if (existingConfig.backend === 'github' && !options.force) { const githubConfig = existingConfig.github; let currentRepo = 'unknown repository'; if (githubConfig && typeof githubConfig === 'object' && 'owner' in githubConfig && 'repo' in githubConfig) { const config = githubConfig; currentRepo = `${config.owner}/${config.repo}`; } return { success: true, message: `Already configured for GitHub (${currentRepo})`, data: { details: 'Use --force to reconfigure' } }; } // Check for gh CLI try { await exec('gh --version', { cwd: context.cwd }); } catch { return { success: false, message: 'GitHub CLI (gh) is not installed', data: { details: 'Install gh from: https://cli.github.com' } }; } // Check gh auth status try { await exec('gh auth status', { cwd: context.cwd }); } catch { return { success: false, message: 'Not authenticated with GitHub', data: { details: 'Run: gh auth login' } }; } // Detect repository information let owner = ''; let repo = ''; let remoteName = 'origin'; if (options.repository) { // Use provided repository const parts = options.repository.split('/'); if (parts.length !== 2) { return { success: false, message: 'Invalid repository format', data: { details: 'Use format: owner/repo' } }; } owner = parts[0]; repo = parts[1]; // Validate GitHub identifiers to prevent injection if (!validateGitHubIdentifier(owner) || !validateGitHubIdentifier(repo)) { return { success: false, message: 'Invalid repository format', data: { details: 'Repository owner and name must contain only alphanumeric characters, hyphens, dots, and underscores' } }; } // Validate repository access using Octokit API (secure) try { const authToken = await getGitHubAuthToken(exec); if (!authToken) { return { success: false, message: 'Cannot authenticate with GitHub', data: { details: 'Failed to get auth token from gh CLI' } }; } const octokit = new Octokit({ auth: authToken }); await octokit.rest.repos.get({ owner, repo }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { success: false, message: `Cannot access repository: ${owner}/${repo}`, data: { details: `Check repository name and access permissions. Error: ${errorMessage}` } }; } } else { // Auto-detect from git try { const { stdout } = await exec('git remote -v', { cwd: context.cwd }); const lines = stdout.split('\n').filter(line => line.includes('(fetch)')); if (lines.length === 0) { // No remotes found } else if (lines.length === 1) { // Single remote const match = lines[0].match(/^(\S+)\s+(?:git@github\.com:|https:\/\/github\.com\/)([^/]+)\/([^.\s]+)/); if (match) { remoteName = match[1]; owner = match[2]; repo = match[3].replace(/\.git$/, ''); } } else { // Multiple remotes - check if any are GitHub const gitHubRemotes = lines.filter(line => line.match(/(?:git@github\.com:|https:\/\/github\.com\/)/)); if (gitHubRemotes.length > 1) { const remoteList = gitHubRemotes.map(line => { const match = line.match(/^(\S+)\s+(?:git@github\.com:|https:\/\/github\.com\/)([^/]+)\/([^.\s]+)/); if (match) { return `${match[1]}: ${match[2]}/${match[3].replace(/\.git$/, '')}`; } return null; }).filter(Boolean).join('\n'); return { success: false, message: 'Multiple remotes found', data: { details: `Please specify which repository to use:\n${remoteList}\n\nRun: ait3 setup ticket github owner/repo` } }; } else if (gitHubRemotes.length === 1) { // Only one GitHub remote among multiple remotes const match = gitHubRemotes[0].match(/^(\S+)\s+(?:git@github\.com:|https:\/\/github\.com\/)([^/]+)\/([^.\s]+)/); if (match) { remoteName = match[1]; owner = match[2]; repo = match[3].replace(/\.git$/, ''); } } } } catch { // Not a git repo or no remote, continue with empty values } } // Update configuration const newConfig = { ...existingConfig, backend: 'github', github: { owner, repo, remote: remoteName, useGhCli: true, labels: { todo: 'status:todo', doing: 'status:doing', done: 'status:done', }, }, }; await writeConfig(context.cwd, newConfig); // Check for existing local tickets let ticketNotice = ''; if (existingConfig.backend === 'local' && services.ticketService) { try { const tickets = await services.ticketService.listTickets(); ticketNotice = buildTicketNotice(tickets?.length || 0, 'local'); } catch { // Ignore errors when checking tickets } } return { success: true, message: 'GitHub ticket backend configured successfully', data: { details: owner && repo ? `Repository: ${owner}/${repo}${ticketNotice}` : `Repository information not detected. Update .tickets/config.json manually.${ticketNotice}` } }; }