UNPKG

@morodomi/ait3

Version:

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

272 lines (271 loc) 9.79 kB
import { Octokit } from '@octokit/rest'; import { execSync } from 'child_process'; import { TicketNotFoundError } from '../../common/errors.js'; export class GitHubTicketService { octokit; config; basePath; constructor(config, basePath = process.cwd()) { this.basePath = basePath; let authToken = config.token || process.env.GITHUB_TOKEN; // Try to get token from gh CLI if not provided if (!authToken && config.useGhCli !== false) { try { authToken = execSync('gh auth token', { encoding: 'utf-8' }).trim(); } catch { // gh CLI not available or not authenticated } } this.octokit = new Octokit({ auth: authToken, }); this.config = { owner: config.owner, repo: config.repo, token: authToken || '', useGhCli: config.useGhCli, labels: { todo: config.labels?.todo || 'status:todo', doing: config.labels?.doing || 'status:doing', done: config.labels?.done || 'status:done', ...config.labels, }, }; } /** * Get GitHub configuration for URL generation */ getConfig() { return { owner: this.config.owner, repo: this.config.repo }; } async createTicket(title, options) { const body = this.formatTicketBody(options); const response = await this.octokit.rest.issues.create({ owner: this.config.owner, repo: this.config.repo, title, body, labels: [ this.config.labels.todo, ...(options?.priority ? [`priority:${options.priority}`] : ['priority:medium']), ...(options?.labels || []), ], }); return this.issueToTicket(response.data); } async listTickets(options) { const labels = []; if (options?.status) { const statusKey = options.status; const statusLabel = this.config.labels[statusKey]; if (statusLabel) labels.push(statusLabel); } if (options?.priority) { labels.push(`priority:${options.priority}`); } const response = await this.octokit.rest.issues.listForRepo({ owner: this.config.owner, repo: this.config.repo, labels: labels.length > 0 ? labels.join(',') : undefined, state: 'all', per_page: 100, }); return response.data.map(issue => this.issueToTicket(issue)); } async getTicket(id) { try { const issueNumber = this.parseTicketId(id); const response = await this.octokit.rest.issues.get({ owner: this.config.owner, repo: this.config.repo, issue_number: issueNumber, }); return this.issueToTicket(response.data); } catch (error) { if (error.status === 404) { return null; } throw error; } } async startTicket(id) { const issueNumber = this.parseTicketId(id); // Remove todo label and add doing label await this.updateLabels(issueNumber, 'todo', 'doing'); // Add a comment to indicate work has started await this.octokit.rest.issues.createComment({ owner: this.config.owner, repo: this.config.repo, issue_number: issueNumber, body: '🚀 Work started on this ticket', }); } async completeTicket(id) { const issueNumber = this.parseTicketId(id); // Remove doing label and add done label await this.updateLabels(issueNumber, 'doing', 'done'); // Close the issue await this.octokit.rest.issues.update({ owner: this.config.owner, repo: this.config.repo, issue_number: issueNumber, state: 'closed', }); } async undoTicket(id) { const issueNumber = this.parseTicketId(id); const issue = await this.getTicket(id); if (!issue) { throw new Error(`Ticket #${id} not found`); } // Determine current status and move to previous status const currentStatus = this.getTicketStatus(issue); if (currentStatus === 'done') { // Reopen the issue and move to doing await this.octokit.rest.issues.update({ owner: this.config.owner, repo: this.config.repo, issue_number: issueNumber, state: 'open', }); await this.updateLabels(issueNumber, 'done', 'doing'); } else if (currentStatus === 'doing') { // Move back to todo await this.updateLabels(issueNumber, 'doing', 'todo'); } } async updateLabels(issueNumber, fromStatus, toStatus) { const fromLabel = this.config.labels[fromStatus]; const toLabel = this.config.labels[toStatus]; if (fromLabel) { try { await this.octokit.rest.issues.removeLabel({ owner: this.config.owner, repo: this.config.repo, issue_number: issueNumber, name: fromLabel, }); } catch (error) { // Ignore if label doesn't exist if (error.status !== 404) throw error; } } if (toLabel) { await this.octokit.rest.issues.addLabels({ owner: this.config.owner, repo: this.config.repo, issue_number: issueNumber, labels: [toLabel], }); } } formatTicketBody(options) { const sections = []; if (options?.description) { sections.push('## Description\n'); sections.push(options.description); } if (options?.acceptanceCriteria && options.acceptanceCriteria.length > 0) { sections.push('\n## Acceptance Criteria\n'); options.acceptanceCriteria.forEach((criterion) => { sections.push(`- [ ] ${criterion}`); }); } if (options?.technicalRequirements && options.technicalRequirements.length > 0) { sections.push('\n## Technical Requirements\n'); options.technicalRequirements.forEach((req) => { sections.push(`- ${req}`); }); } sections.push('\n---\n_Created by AIT³_'); return sections.join('\n'); } issueToTicket(issue) { const status = this.getIssueStatus(issue); const priority = this.getIssuePriority(issue); return { id: `#${issue.number}`, title: issue.title, status, priority, created: issue.created_at, updated: issue.updated_at, assignee: issue.assignee?.login, labels: issue.labels.map((label) => label.name), description: issue.body || '', location: { type: 'github', url: issue.html_url, apiUrl: issue.url, }, }; } getIssueStatus(issue) { const labels = issue.labels.map((label) => label.name); if (labels.includes(this.config.labels.done)) return 'done'; if (labels.includes(this.config.labels.doing)) return 'doing'; if (labels.includes(this.config.labels.todo)) return 'todo'; // Default based on issue state return issue.state === 'closed' ? 'done' : 'todo'; } getIssuePriority(issue) { const labels = issue.labels.map((label) => label.name); for (const label of labels) { if (label.startsWith('priority:')) { return label.replace('priority:', ''); } } return 'medium'; } getTicketStatus(ticket) { return ticket.status; } parseTicketId(id) { // Handle both formats: "123" and "#123" const numericId = id.replace(/^#/, ''); const issueNumber = parseInt(numericId, 10); if (isNaN(issueNumber)) { throw new Error(`Invalid ticket ID: ${id}`); } return issueNumber; } async deleteTicket(id) { try { const issueNumber = this.parseTicketId(id); // GitHub API doesn't actually delete issues, only close them // Use Octokit API directly for security (no shell command injection) await this.octokit.rest.issues.update({ owner: this.config.owner, repo: this.config.repo, issue_number: issueNumber, state: 'closed', state_reason: 'not_planned' }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('Not Found')) { throw new TicketNotFoundError(id); } if (errorMessage.includes('Bad credentials') || errorMessage.includes('authentication')) { throw new Error('GitHub authentication failed. Please run: gh auth login'); } if (errorMessage.includes('permission')) { throw new Error(`Insufficient permissions to delete issue #${id}`); } throw new Error(`Failed to delete GitHub issue: ${errorMessage}`); } } }