@versatil/sdlc-framework
Version:
🚀 AI-Native SDLC framework with 11-MCP ecosystem, RAG memory, OPERA orchestration, and 6 specialized agents achieving ZERO CONTEXT LOSS. Features complete CI/CD pipeline with 7 GitHub workflows (MCP testing, security scanning, performance benchmarking),
603 lines (497 loc) • 16 kB
text/typescript
/**
* VERSATIL SDLC Framework - GitHub Sync Orchestrator
* Handles full GitHub integration with context-aware PRs and synchronization
*/
import { EventEmitter } from 'events';
import { VERSATILLogger } from '../utils/logger.js';
import { IsolatedPaths } from './isolated-versatil-orchestrator.js';
import * as path from 'path';
export interface GitHubConfig {
owner: string;
repo: string;
token?: string;
defaultBranch: string;
autoSync: boolean;
prTemplate?: string;
}
export interface PullRequest {
id: number;
title: string;
body: string;
branch: string;
baseBranch: string;
labels: string[];
reviewers: string[];
files: string[];
context: any;
}
export interface GitHubIssue {
id: number;
title: string;
body: string;
labels: string[];
assignees: string[];
milestone?: string;
linkedPR?: number;
}
export class GitHubSyncOrchestrator extends EventEmitter {
private logger: VERSATILLogger;
private paths: IsolatedPaths;
private config: GitHubConfig;
private syncInterval?: NodeJS.Timeout;
// Track synchronization state
private syncState = {
lastSync: null as Date | null,
pendingChanges: [] as string[],
activePRs: new Map<string, PullRequest>(),
activeIssues: new Map<number, GitHubIssue>()
};
constructor(paths: IsolatedPaths) {
super();
this.logger = new VERSATILLogger('GitHubSync');
this.paths = paths;
// Default configuration
this.config = {
owner: '',
repo: '',
defaultBranch: 'main',
autoSync: false
};
}
public async initialize(): Promise<void> {
// Load GitHub configuration
await this.loadGitHubConfig();
// Initialize git repository state
await this.initializeRepoState();
// Set up auto-sync if enabled
if (this.config.autoSync) {
this.startAutoSync();
}
this.logger.info('GitHub sync orchestrator initialized', {
owner: this.config.owner,
repo: this.config.repo,
autoSync: this.config.autoSync
});
}
/**
* Load GitHub configuration from project
*/
private async loadGitHubConfig(): Promise<void> {
const { exec } = require('child_process').promises;
try {
// Get remote origin URL
const remoteUrl = await exec('git remote get-url origin', {
cwd: this.paths.project.root
});
// Parse GitHub owner and repo from URL
const match = remoteUrl.stdout.trim().match(/github\.com[:/]([^/]+)\/(.+?)(\.git)?$/);
if (match) {
this.config.owner = match[1];
this.config.repo = match[2];
}
// Get default branch
const defaultBranch = await exec('git symbolic-ref refs/remotes/origin/HEAD', {
cwd: this.paths.project.root
}).catch(() => ({ stdout: 'refs/remotes/origin/main' }));
this.config.defaultBranch = defaultBranch.stdout.trim().split('/').pop() || 'main';
// Load token from environment
this.config.token = process.env.GITHUB_TOKEN;
} catch (error) {
this.logger.warn('Failed to load GitHub configuration', { error });
}
}
/**
* Initialize repository state
*/
private async initializeRepoState(): Promise<void> {
const { exec } = require('child_process').promises;
try {
// Ensure we're on a branch
await exec('git rev-parse --abbrev-ref HEAD', {
cwd: this.paths.project.root
});
// Fetch latest from origin
await exec('git fetch origin', {
cwd: this.paths.project.root
});
// Get current status
const status = await exec('git status --porcelain', {
cwd: this.paths.project.root
});
this.syncState.pendingChanges = status.stdout
.trim()
.split('\n')
.filter(Boolean)
.map((line: string) => line.substring(3));
} catch (error) {
this.logger.warn('Failed to initialize repo state', { error });
}
}
/**
* Create a context-aware pull request
*/
public async createContextualPR(options: {
title: string;
description: string;
branch?: string;
context: any;
files?: string[];
labels?: string[];
reviewers?: string[];
}): Promise<PullRequest> {
const { exec } = require('child_process').promises;
// Create branch if not specified
const branchName = options.branch || this.generateBranchName(options.title);
try {
// Create and checkout new branch
await exec(`git checkout -b ${branchName}`, {
cwd: this.paths.project.root
});
// Stage specified files or all changes
if (options.files && options.files.length > 0) {
for (const file of options.files) {
await exec(`git add ${file}`, {
cwd: this.paths.project.root
});
}
} else {
await exec('git add -A', {
cwd: this.paths.project.root
});
}
// Create context-aware commit message
const commitMessage = this.generateCommitMessage(options);
await exec(`git commit -m "${commitMessage}"`, {
cwd: this.paths.project.root
});
// Push branch
await exec(`git push -u origin ${branchName}`, {
cwd: this.paths.project.root
});
// Create PR body with full context
const prBody = this.generatePRBody(options);
// Create PR via GitHub API (would use Octokit in production)
const pr: PullRequest = {
id: Date.now(), // Temporary ID
title: options.title,
body: prBody,
branch: branchName,
baseBranch: this.config.defaultBranch,
labels: options.labels || this.inferLabels(options),
reviewers: options.reviewers || this.selectReviewers(options),
files: options.files || this.syncState.pendingChanges,
context: options.context
};
// Store PR reference
this.syncState.activePRs.set(branchName, pr);
// Emit PR created event
this.emit('pr:created', pr);
this.logger.info('Created context-aware PR', {
branch: branchName,
title: options.title
});
return pr;
} catch (error) {
// Rollback on error
await exec(`git checkout ${this.config.defaultBranch}`, {
cwd: this.paths.project.root
}).catch(() => {});
throw error;
}
}
/**
* Generate branch name from title
*/
private generateBranchName(title: string): string {
const base = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.substring(0, 50);
const timestamp = Date.now().toString(36).substring(-4);
return `versatil/${base}-${timestamp}`;
}
/**
* Generate context-aware commit message
*/
private generateCommitMessage(options: any): string {
const type = this.inferCommitType(options);
const scope = this.inferScope(options);
let message = `${type}`;
if (scope) message += `(${scope})`;
message += `: ${options.title}`;
// Add context information
if (options.context?.plan) {
message += `\n\nPlan ID: ${options.context.plan.id}`;
}
if (options.context?.agents) {
message += `\n\nAgents involved: ${options.context.agents.join(', ')}`;
}
return message;
}
/**
* Generate comprehensive PR body
*/
private generatePRBody(options: any): string {
let body = `## ${options.description}\n\n`;
// Add context section
body += `### Context\n\n`;
if (options.context?.goal) {
body += `**Goal:** ${options.context.goal}\n\n`;
}
if (options.context?.plan) {
body += `**Plan:** ${options.context.plan.metadata?.id || 'N/A'}\n`;
body += `**Estimated Time:** ${options.context.plan.metadata?.estimatedTime || 'N/A'}ms\n`;
body += `**Risk Level:** ${options.context.plan.metadata?.risk || 'N/A'}\n\n`;
}
// Add changes section
body += `### Changes\n\n`;
if (options.files && options.files.length > 0) {
body += `Modified files:\n`;
options.files.forEach((file: string) => {
body += `- ${file}\n`;
});
body += '\n';
}
// Add testing section
body += `### Testing\n\n`;
if (options.context?.tests) {
body += `- [ ] Unit tests added/updated\n`;
body += `- [ ] E2E tests added/updated\n`;
body += `- [ ] Visual regression tests passed\n`;
} else {
body += `- [ ] Tests to be added\n`;
}
// Add checklist
body += `\n### Checklist\n\n`;
body += `- [ ] Code follows project conventions\n`;
body += `- [ ] Self-review completed\n`;
body += `- [ ] Documentation updated\n`;
body += `- [ ] No breaking changes\n`;
// Add VERSATIL metadata
body += `\n### VERSATIL Metadata\n\n`;
body += `\`\`\`json\n${JSON.stringify({
framework: 'VERSATIL',
version: '1.3.0',
agents: options.context?.agents || [],
timestamp: Date.now()
}, null, 2)}\n\`\`\`\n`;
// Add PR template if configured
if (this.config.prTemplate) {
body += `\n${this.config.prTemplate}\n`;
}
return body;
}
/**
* Infer commit type from context
*/
private inferCommitType(options: any): string {
const title = options.title.toLowerCase();
if (title.includes('fix')) return 'fix';
if (title.includes('feat') || title.includes('add')) return 'feat';
if (title.includes('docs')) return 'docs';
if (title.includes('style')) return 'style';
if (title.includes('refactor')) return 'refactor';
if (title.includes('test')) return 'test';
if (title.includes('chore')) return 'chore';
return 'feat';
}
/**
* Infer scope from files
*/
private inferScope(options: any): string {
if (!options.files || options.files.length === 0) return '';
// Find common directory
const dirs = options.files.map((f: string) => path.dirname(f));
const commonDir = this.findCommonDirectory(dirs);
if (commonDir && commonDir !== '.') {
return commonDir.split('/').pop() || '';
}
return '';
}
/**
* Infer labels from context
*/
private inferLabels(options: any): string[] {
const labels = [];
// Add type labels
const type = this.inferCommitType(options);
labels.push(type);
// Add risk labels
if (options.context?.plan?.metadata?.risk) {
labels.push(`risk:${options.context.plan.metadata.risk}`);
}
// Add stack labels
if (options.context?.stack) {
Object.keys(options.context.stack).forEach((tech: string) => {
labels.push(`stack:${tech}`);
});
}
// Add agent labels
if (options.context?.agents) {
labels.push('versatil:automated');
}
return labels;
}
/**
* Select reviewers based on context
*/
private selectReviewers(options: any): string[] {
// In production, would use CODEOWNERS or team assignments
const reviewers = [];
// Add reviewers based on files
if (options.files) {
// Would check CODEOWNERS file
}
return reviewers;
}
/**
* Find common directory from paths
*/
private findCommonDirectory(paths: string[]): string {
if (paths.length === 0) return '';
if (paths.length === 1) return path.dirname(paths[0]);
const parts = paths[0].split('/');
let common = '';
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (paths.every(p => p.split('/')[i] === part)) {
common = common ? `${common}/${part}` : part;
} else {
break;
}
}
return common;
}
/**
* Sync local changes with GitHub
*/
public async syncWithGitHub(): Promise<void> {
const { exec } = require('child_process').promises;
try {
// Fetch latest
await exec('git fetch origin', {
cwd: this.paths.project.root
});
// Get current branch
const branch = await exec('git rev-parse --abbrev-ref HEAD', {
cwd: this.paths.project.root
});
const currentBranch = branch.stdout.trim();
// Pull latest changes
await exec(`git pull origin ${currentBranch}`, {
cwd: this.paths.project.root
});
// Update sync state
this.syncState.lastSync = new Date();
this.logger.info('Synced with GitHub', { branch: currentBranch });
} catch (error) {
this.logger.error('Failed to sync with GitHub', { error });
throw error;
}
}
/**
* Start automatic synchronization
*/
private startAutoSync(): void {
// Sync every 5 minutes
this.syncInterval = setInterval(() => {
this.syncWithGitHub().catch(error => {
this.logger.error('Auto-sync failed', { error });
});
}, 5 * 60 * 1000);
this.logger.info('Started auto-sync with GitHub');
}
/**
* Get repository status
*/
public async getRepoStatus(): Promise<any> {
const { exec } = require('child_process').promises;
try {
const [branch, status, ahead] = await Promise.all([
exec('git rev-parse --abbrev-ref HEAD', { cwd: this.paths.project.root }),
exec('git status --porcelain', { cwd: this.paths.project.root }),
exec('git rev-list --count HEAD..origin/HEAD', { cwd: this.paths.project.root })
.catch(() => ({ stdout: '0' }))
]);
return {
currentBranch: branch.stdout.trim(),
hasUncommittedChanges: status.stdout.trim().length > 0,
unpushedCommits: parseInt(ahead.stdout.trim()),
activePRs: this.syncState.activePRs.size,
activeIssues: this.syncState.activeIssues.size,
lastSync: this.syncState.lastSync
};
} catch (error) {
this.logger.error('Failed to get repo status', { error });
return null;
}
}
/**
* Create GitHub issue with context
*/
public async createIssue(options: {
title: string;
body: string;
labels?: string[];
assignees?: string[];
milestone?: string;
}): Promise<GitHubIssue> {
const issue: GitHubIssue = {
id: Date.now(),
title: options.title,
body: this.enhanceIssueBody(options.body),
labels: options.labels || [],
assignees: options.assignees || [],
milestone: options.milestone
};
// Store issue reference
this.syncState.activeIssues.set(issue.id, issue);
// Emit issue created event
this.emit('issue:created', issue);
return issue;
}
/**
* Enhance issue body with VERSATIL context
*/
private enhanceIssueBody(body: string): string {
return `${body}\n\n---\n_Created by VERSATIL SDLC Framework v1.3.0_`;
}
/**
* Link PR to issue
*/
public async linkPRToIssue(prId: string, issueId: number): Promise<void> {
const pr = Array.from(this.syncState.activePRs.values())
.find(p => p.branch === prId || p.id.toString() === prId);
const issue = this.syncState.activeIssues.get(issueId);
if (pr && issue) {
issue.linkedPR = pr.id;
// Update PR body to reference issue
pr.body += `\n\nCloses #${issueId}`;
this.emit('pr:linked', { pr, issue });
}
}
/**
* Get active PRs
*/
public getActivePRs(): PullRequest[] {
return Array.from(this.syncState.activePRs.values());
}
/**
* Get active issues
*/
public getActiveIssues(): GitHubIssue[] {
return Array.from(this.syncState.activeIssues.values());
}
/**
* Cleanup
*/
public async shutdown(): Promise<void> {
if (this.syncInterval) {
clearInterval(this.syncInterval);
}
// Final sync
if (this.config.autoSync) {
await this.syncWithGitHub().catch(() => {});
}
}
}