aiwg
Version:
Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.
473 lines (405 loc) • 14.2 kB
JavaScript
/**
* Create Pull Request Tool
*
* Creates well-formed pull requests for AIWG contributions with:
* - Quality validation (>= 80% required)
* - Interactive PR metadata collection
* - Automated PR description generation
* - GitHub label assignment
* - Workspace metadata persistence
*
* Usage:
* node tools/contrib/create-pr.mjs <feature-name> [--draft]
* aiwg -contribute-pr <feature-name> [--draft]
*/
import { execSync } from 'child_process';
import * as readline from 'readline';
import * as fs from 'fs';
import * as path from 'path';
import { runAllGates, generateReport } from './lib/quality-validator.mjs';
import { createPR } from './lib/github-client.mjs';
import {
loadWorkspaceData,
savePRMetadata,
updateWorkspaceStatus,
workspaceExists
} from './lib/workspace-manager.mjs';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
/**
* Prompt user for input
* @param {string} question - Question to ask
* @param {string} defaultValue - Default value if user presses enter
* @returns {Promise<string>} User input
*/
function prompt(question, defaultValue = '') {
return new Promise((resolve) => {
const displayDefault = defaultValue ? ` [${defaultValue}]` : '';
rl.question(`${question}${displayDefault}: `, (answer) => {
resolve(answer.trim() || defaultValue);
});
});
}
/**
* Prompt user to choose from options
* @param {string} question - Question to ask
* @param {Array<{value: string, label: string}>} options - Options to choose from
* @returns {Promise<string>} Selected option value
*/
async function promptChoice(question, options) {
console.log(`\n${question}`);
options.forEach((opt, idx) => {
console.log(`[${idx + 1}] ${opt.label}`);
});
while (true) {
const answer = await prompt('\nChoice', '1');
const choice = parseInt(answer, 10);
if (choice >= 1 && choice <= options.length) {
return options[choice - 1].value;
}
console.log(`Invalid choice. Please enter 1-${options.length}`);
}
}
/**
* Prompt yes/no question
* @param {string} question - Question to ask
* @param {boolean} defaultValue - Default value
* @returns {Promise<boolean>} User response
*/
async function promptYesNo(question, defaultValue = false) {
const defaultStr = defaultValue ? 'Y/n' : 'y/N';
const answer = await prompt(`${question} [${defaultStr}]`);
if (!answer) return defaultValue;
return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
}
/**
* Execute git command
* @param {string} command - Git command
* @returns {string} Command output
*/
function execGit(command) {
try {
return execSync(command, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
} catch (err) {
throw new Error(`Git command failed: ${err.message}`);
}
}
/**
* Check git status for uncommitted changes
* @returns {boolean} True if working directory is clean
*/
function isGitClean() {
const status = execGit('git status --porcelain');
return status === '';
}
/**
* Check if branch is pushed to origin
* @param {string} branch - Branch name
* @returns {boolean} True if branch exists on origin
*/
function isBranchPushed(branch) {
try {
execGit(`git rev-parse origin/${branch}`);
return true;
} catch {
return false;
}
}
/**
* Push branch to origin
* @param {string} branch - Branch name
*/
function pushBranch(branch) {
console.log(`\nPushing branch to origin...`);
execGit(`git push -u origin ${branch}`);
console.log('✓ Branch pushed to origin');
}
/**
* Get current branch name
* @returns {string} Branch name
*/
function getCurrentBranch() {
return execGit('git rev-parse --abbrev-ref HEAD');
}
/**
* Get list of changed files
* @returns {Array<string>} File paths
*/
function getChangedFiles() {
const diff = execGit('git diff --name-only main...HEAD');
return diff.split('\n').filter(f => f.trim());
}
/**
* Capitalize first letter of each word
* @param {string} str - String to capitalize
* @returns {string} Capitalized string
*/
function capitalize(str) {
return str
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Generate PR title from feature name and type
* @param {string} feature - Feature name
* @param {string} type - PR type (feature, bugfix, docs, refactor)
* @returns {string} PR title
*/
function generatePRTitle(feature, type) {
const typePrefix = {
feature: 'Add',
bugfix: 'Fix',
docs: 'Docs',
refactor: 'Refactor'
}[type] || 'Add';
const featureName = capitalize(feature);
return `${typePrefix} ${featureName}`;
}
/**
* Generate PR description
* @param {string} feature - Feature name
* @param {Object} qualityResults - Quality validation results
* @param {Object} metadata - Additional metadata (type, breaking, migrationGuide)
* @returns {string} PR description markdown
*/
function generatePRDescription(feature, qualityResults, metadata) {
const changedFiles = getChangedFiles();
// Group files by type
const tools = changedFiles.filter(f => f.startsWith('tools/'));
const docs = changedFiles.filter(f => f.startsWith('docs/'));
const readme = changedFiles.filter(f => f === 'README.md');
const config = changedFiles.filter(f => f.includes('install.sh') || f.includes('package.json'));
const tests = changedFiles.filter(f => f.includes('test') || f.includes('spec'));
const other = changedFiles.filter(f =>
!tools.includes(f) && !docs.includes(f) && !readme.includes(f) &&
!config.includes(f) && !tests.includes(f)
);
// Build changes section
const changes = [];
if (tools.length > 0) {
changes.push(`- Created/modified tools: ${tools.slice(0, 3).map(f => `\`${f}\``).join(', ')}${tools.length > 3 ? ` (+${tools.length - 3} more)` : ''}`);
}
if (docs.length > 0) {
changes.push(`- Documentation: ${docs.slice(0, 3).map(f => `\`${f}\``).join(', ')}${docs.length > 3 ? ` (+${docs.length - 3} more)` : ''}`);
}
if (readme.length > 0) {
changes.push(`- Updated \`README.md\``);
}
if (config.length > 0) {
changes.push(`- Updated configuration: ${config.map(f => `\`${f}\``).join(', ')}`);
}
if (tests.length > 0) {
changes.push(`- Added/updated tests: ${tests.length} file(s)`);
}
if (other.length > 0 && other.length < 5) {
other.forEach(f => changes.push(`- Modified \`${f}\``));
}
// Build testing section
const testing = [];
testing.push(`✓ Markdown lint: ${qualityResults.markdownLint.passed ? 'PASSED' : 'FAILED'}`);
testing.push(`✓ Manifest sync: ${qualityResults.manifestSync.synced ? 'PASSED' : 'NEEDS UPDATE'}`);
testing.push(`✓ Documentation: ${qualityResults.documentation.complete ? 'COMPLETE' : 'INCOMPLETE'}`);
if (qualityResults.tests.hasTests) {
testing.push(`✓ Tests: ${qualityResults.tests.testFiles.length} test file(s)`);
}
testing.push(`✓ Quality score: ${qualityResults.score}/100`);
// Build checklist
const checklist = [];
checklist.push(`- [${qualityResults.documentation.complete ? 'x' : ' '}] Documentation updated`);
checklist.push(`- [${qualityResults.tests.hasTests ? 'x' : ' '}] Tests passing`);
checklist.push(`- [${qualityResults.score >= 80 ? 'x' : ' '}] Quality gates passed (>= 80%)`);
if (metadata.breaking) {
checklist.push(`- [${metadata.migrationGuide ? 'x' : ' '}] Breaking changes documented`);
} else {
checklist.push(`- [x] No breaking changes`);
}
// Build description
let description = `## Summary\n\n`;
description += `Adds ${feature} feature.\n\n`;
description += `## Changes\n\n`;
description += changes.join('\n') + '\n\n';
if (metadata.breaking && metadata.migrationGuide) {
description += `## Breaking Changes\n\n`;
description += `⚠️ This PR introduces breaking changes.\n\n`;
description += `### Migration Guide\n\n`;
description += metadata.migrationGuide + '\n\n';
}
description += `## Testing\n\n`;
description += testing.join('\n') + '\n\n';
description += `## Checklist\n\n`;
description += checklist.join('\n') + '\n\n';
description += `---\n\n`;
description += `🤖 Generated using AIWG contributor workflow`;
return description;
}
/**
* Main entry point
*/
async function main() {
const args = process.argv.slice(2);
// Parse arguments
const featureName = args.find(arg => !arg.startsWith('--'));
const isDraft = args.includes('--draft');
if (!featureName) {
console.error('Error: Feature name is required');
console.error('Usage: aiwg -contribute-pr <feature-name> [--draft]');
process.exit(1);
}
console.log(`Creating pull request for: ${featureName}\n`);
// Check workspace exists
if (!workspaceExists(featureName)) {
console.error(`Error: Workspace not found for feature: ${featureName}`);
console.error(`Run: aiwg -contribute-start ${featureName}`);
process.exit(1);
}
// Load workspace data
const workspaceResult = loadWorkspaceData(featureName);
if (!workspaceResult.success) {
console.error(`Error: ${workspaceResult.error}`);
process.exit(1);
}
const workspace = workspaceResult.data;
// Step 1: Prerequisites Check
console.log('Step 1: Checking prerequisites...\n');
// Check quality validation
console.log('Running quality validation...');
const qualityResults = runAllGates(featureName);
if (!qualityResults.passed) {
console.error('\n❌ Quality validation failed\n');
console.error(generateReport(qualityResults));
console.error(`\nMinimum quality score: 80/100 (current: ${qualityResults.score}/100)`);
console.error(`\nFix issues and re-run: aiwg -contribute-test ${featureName}`);
process.exit(1);
}
console.log(`✓ Quality validation passed (${qualityResults.score}/100)`);
// Check git status
if (!isGitClean()) {
console.error('\n❌ Uncommitted changes detected');
console.error('Please commit all changes before creating PR:');
console.error(' git add .');
console.error(' git commit -m "Your commit message"');
process.exit(1);
}
console.log('✓ Working directory clean');
// Get current branch
const currentBranch = getCurrentBranch();
console.log(`✓ Current branch: ${currentBranch}`);
// Check if branch is pushed
if (!isBranchPushed(currentBranch)) {
const shouldPush = await promptYesNo('\nBranch not pushed to origin. Push now?', true);
if (!shouldPush) {
console.log('\nCancelled. Push branch manually:');
console.log(` git push -u origin ${currentBranch}`);
rl.close();
process.exit(0);
}
pushBranch(currentBranch);
} else {
console.log('✓ Branch pushed to origin');
}
// Step 2: Interactive PR Creation
console.log('\n\nStep 2: PR Metadata\n');
// PR Title
const defaultTitle = generatePRTitle(featureName, 'feature');
const prTitle = await prompt('PR Title', defaultTitle);
// PR Type
const prType = await promptChoice('PR Type', [
{ value: 'feature', label: 'feature (new functionality)' },
{ value: 'bugfix', label: 'bugfix (fix existing issue)' },
{ value: 'docs', label: 'docs (documentation only)' },
{ value: 'refactor', label: 'refactor (code improvement, no behavior change)' }
]);
// Breaking Changes
const hasBreaking = await promptYesNo('\nBreaking Changes?', false);
let migrationGuide = '';
if (hasBreaking) {
console.log('\nBreaking changes detected. Please provide migration guide:');
console.log('(Enter multi-line text. Press Ctrl+D when done)\n');
// Read multiline input
migrationGuide = await new Promise((resolve) => {
let lines = [];
const stdin = process.stdin;
stdin.setRawMode(false);
stdin.resume();
stdin.setEncoding('utf8');
stdin.on('data', (chunk) => {
lines.push(chunk);
});
stdin.on('end', () => {
resolve(lines.join(''));
});
});
}
// Step 3: Generate PR Description
console.log('\n\nStep 3: Generating PR description...\n');
const prDescription = generatePRDescription(featureName, qualityResults, {
type: prType,
breaking: hasBreaking,
migrationGuide
});
console.log('--- PR Description Preview ---');
console.log(prDescription);
console.log('--- End Preview ---\n');
const confirmCreate = await promptYesNo('Create PR with this description?', true);
if (!confirmCreate) {
console.log('\nCancelled.');
rl.close();
process.exit(0);
}
// Step 4: Create PR via GitHub API
console.log('\n\nStep 4: Creating pull request...\n');
const labels = ['contribution', prType];
if (hasBreaking) {
labels.push('breaking');
}
const prResult = createPR(prTitle, prDescription, labels, isDraft);
if (!prResult.success) {
console.error(`\n❌ Failed to create PR: ${prResult.error}`);
rl.close();
process.exit(1);
}
console.log(`✓ PR created: ${prResult.url}`);
console.log(`✓ PR number: #${prResult.number}`);
if (isDraft) {
console.log('✓ PR marked as draft');
}
// Step 5: Save PR Metadata
console.log('\n\nStep 5: Saving PR metadata...\n');
const prMetadata = {
number: prResult.number,
url: prResult.url,
title: prTitle,
type: prType,
breaking: hasBreaking,
draft: isDraft,
created_at: new Date().toISOString(),
quality_score: qualityResults.score
};
const saveResult = savePRMetadata(featureName, prMetadata);
if (!saveResult.success) {
console.warn(`⚠ Warning: Failed to save PR metadata: ${saveResult.error}`);
} else {
console.log(`✓ PR metadata saved to workspace`);
}
// Step 6: Output Next Steps
console.log('\n\n✅ PR created successfully!\n');
console.log(`PR #${prResult.number}: ${prTitle}`);
console.log(`URL: ${prResult.url}\n`);
console.log('Next steps:');
console.log(`- Monitor PR: aiwg -contribute-monitor ${featureName}`);
console.log(`- Respond to reviews: aiwg -contribute-respond ${featureName}`);
console.log(`- View online: gh pr view ${prResult.number} --web`);
rl.close();
}
// Run main
main().catch(err => {
console.error(`\nError: ${err.message}`);
process.exit(1);
});