@morodomi/ait3
Version:
AIT³ Development Platform - AI + Ticket + Test + Tool driven development methodology
272 lines (271 loc) • 9.79 kB
JavaScript
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}`);
}
}
}