aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
393 lines (333 loc) • 10.4 kB
JavaScript
/**
* @fileoverview GitHub Projects v2 Adapter
*
* Integrates with GitHub Projects for story management.
* Uses GitHub CLI (gh) for authentication and GitHub GraphQL API v2.
*
* @see Story 3.20 - PM Tool-Agnostic Integration (TR-3.20.3)
*/
const { execSync } = require('child_process');
const fs = require('fs');
const yaml = require('js-yaml');
const { PMAdapter } = require('../../scripts/pm-adapter');
/**
* GitHub Projects adapter - integrates with GitHub Projects v2
*
* Authenticates via GitHub CLI (gh auth). Requires gh CLI to be installed
* and authenticated before use.
*/
class GitHubProjectsAdapter extends PMAdapter {
/**
* Create GitHub Projects adapter instance
* @param {object} config - GitHub Projects configuration
* @param {string} config.org - GitHub organization or username
* @param {number} config.project_number - Project number in the organization
*/
constructor(config) {
super(config);
if (!config || !config.org || !config.project_number) {
throw new Error('GitHub Projects config requires: org, project_number');
}
this.org = config.org;
this.projectNumber = config.project_number;
this.projectId = null; // Will be fetched lazily
}
/**
* Sync local story to GitHub Projects
*
* Creates or updates a GitHub issue linked to the project.
*
* @param {string} storyPath - Path to story YAML file
* @returns {Promise<{success: boolean, url?: string, error?: string}>}
*/
async syncStory(storyPath) {
try {
// Read story file
if (!fs.existsSync(storyPath)) {
return {
success: false,
error: `Story file not found: ${storyPath}`,
};
}
const storyContent = fs.readFileSync(storyPath, 'utf8');
const story = yaml.load(storyContent);
if (!story || !story.id) {
return {
success: false,
error: 'Invalid story file: missing id field',
};
}
console.log(`📤 Syncing story ${story.id} to GitHub Projects...`);
// Find existing issue by story ID label
const issueNumber = await this._findIssueByStoryId(story.id);
if (issueNumber) {
// Update existing issue
await this._updateIssue(issueNumber, story, storyContent);
const issueUrl = `https://github.com/${this.org}/issues/${issueNumber}`;
console.log(`✅ Story ${story.id} updated in GitHub Projects`);
return {
success: true,
url: issueUrl,
};
} else {
// Create new issue
const newIssueUrl = await this._createIssue(story, storyContent);
console.log(`✅ Story ${story.id} created in GitHub Projects`);
return {
success: true,
url: newIssueUrl,
};
}
} catch (error) {
console.error('❌ Error syncing story to GitHub Projects:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* Pull story updates from GitHub Projects
*
* NOTE: Currently limited - GitHub Projects doesn't store full story content.
* Can only pull status changes.
*
* @param {string} storyId - Story ID (e.g., "3.20")
* @returns {Promise<{success: boolean, updates?: object, error?: string}>}
*/
async pullStory(storyId) {
try {
console.log(`📥 Pulling story ${storyId} from GitHub Projects...`);
const issueNumber = await this._findIssueByStoryId(storyId);
if (!issueNumber) {
return {
success: false,
error: `GitHub issue not found for story ${storyId}`,
};
}
// Get issue status
const issueData = await this._getIssue(issueNumber);
// Map GitHub issue state to AIOS status
const statusMapping = {
'open': 'InProgress',
'closed': 'Done',
};
const mappedStatus = statusMapping[issueData.state] || 'Draft';
console.log(`✅ Story ${storyId} status in GitHub: ${mappedStatus}`);
return {
success: true,
updates: {
status: mappedStatus,
},
};
} catch (error) {
console.error('❌ Error pulling story from GitHub Projects:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* Create new story in GitHub Projects
*
* @param {object} storyData - Story metadata
* @returns {Promise<{success: boolean, url?: string, error?: string}>}
*/
async createStory(storyData) {
try {
console.log(`📝 Creating story ${storyData.id} in GitHub Projects...`);
const issueUrl = await this._createIssue(storyData, '');
console.log(`✅ Story ${storyData.id} created in GitHub Projects`);
return {
success: true,
url: issueUrl,
};
} catch (error) {
console.error('❌ Error creating story in GitHub Projects:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* Update story status in GitHub Projects
*
* Maps AIOS status to GitHub issue state.
*
* @param {string} storyId - Story ID
* @param {string} status - New status (Draft, InProgress, Review, Done)
* @returns {Promise<{success: boolean, error?: string}>}
*/
async updateStatus(storyId, status) {
try {
console.log(`📊 Updating story ${storyId} status to ${status}...`);
const issueNumber = await this._findIssueByStoryId(storyId);
if (!issueNumber) {
return {
success: false,
error: `GitHub issue not found for story ${storyId}`,
};
}
// Map AIOS status to GitHub state
const stateMapping = {
'Draft': 'open',
'InProgress': 'open',
'Review': 'open',
'Done': 'closed',
};
const githubState = stateMapping[status] || 'open';
// Update issue state
this._execGH(['issue', 'edit', issueNumber.toString(), '--state', githubState]);
console.log(`✅ Story ${storyId} status updated to ${status}`);
return { success: true };
} catch (error) {
console.error('❌ Error updating story status:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* Test connection to GitHub
*
* Validates GitHub CLI authentication.
*
* @returns {Promise<{success: boolean, error?: string}>}
*/
async testConnection() {
try {
console.log('🔌 Testing GitHub connection...');
// Check if gh CLI is installed
try {
this._execGH(['--version']);
} catch (_error) {
return {
success: false,
error: 'GitHub CLI (gh) not installed. Install from: https://cli.github.com/',
};
}
// Check if authenticated
try {
const authStatus = this._execGH(['auth', 'status']);
if (!authStatus.includes('Logged in')) {
throw new Error('Not authenticated');
}
} catch (_error) {
return {
success: false,
error: 'GitHub CLI not authenticated. Run: gh auth login',
};
}
// Try to access the organization
try {
this._execGH(['repo', 'list', this.org, '--limit', '1']);
} catch (_error) {
return {
success: false,
error: `Cannot access organization: ${this.org}`,
};
}
console.log('✅ GitHub connection successful');
return { success: true };
} catch (error) {
console.error('❌ GitHub connection failed:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* Execute GitHub CLI command
* @private
* @param {string[]} args - Command arguments
* @returns {string} Command output
*/
_execGH(args) {
const command = `gh ${args.join(' ')}`;
return execSync(command, { encoding: 'utf8' });
}
/**
* Find GitHub issue by story ID
* @private
* @param {string} storyId - Story ID (e.g., "3.20")
* @returns {Promise<number|null>} Issue number or null
*/
async _findIssueByStoryId(storyId) {
try {
const result = this._execGH([
'issue', 'list',
'--label', `story-${storyId}`,
'--json', 'number',
'--limit', '1',
]);
const issues = JSON.parse(result);
if (issues && issues.length > 0) {
return issues[0].number;
}
return null;
} catch (error) {
console.warn(`Warning: Could not search for story ${storyId}:`, error.message);
return null;
}
}
/**
* Get issue data
* @private
* @param {number} issueNumber - Issue number
* @returns {Promise<object>} Issue data
*/
async _getIssue(issueNumber) {
const result = this._execGH([
'issue', 'view', issueNumber.toString(),
'--json', 'state,title,body,labels',
]);
return JSON.parse(result);
}
/**
* Create GitHub issue
* @private
* @param {object} story - Story data
* @param {string} content - Full story content
* @returns {Promise<string>} Issue URL
*/
async _createIssue(story, content) {
const title = `${story.id}: ${story.title}`;
const body = content || story.description || story.context || '';
const labels = [
'story',
`story-${story.id}`,
...(story.epic ? [`epic-${story.epic}`] : []),
...(story.priority ? [story.priority] : []),
];
const result = this._execGH([
'issue', 'create',
'--title', `"${title}"`,
'--body', `"${body.replace(/"/g, '\\"')}"`,
'--label', labels.join(','),
]);
// Extract URL from output
const urlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
return urlMatch ? urlMatch[0] : '';
}
/**
* Update GitHub issue
* @private
* @param {number} issueNumber - Issue number
* @param {object} story - Story data
* @param {string} content - Full story content
*/
async _updateIssue(issueNumber, story, content) {
const title = `${story.id}: ${story.title}`;
const body = content || story.description || '';
this._execGH([
'issue', 'edit', issueNumber.toString(),
'--title', `"${title}"`,
'--body', `"${body.replace(/"/g, '\\"')}"`,
]);
}
}
module.exports = { GitHubProjectsAdapter };