first draft contributions
This commit is contained in:
613
src/utils/gitContributions.ts
Normal file
613
src/utils/gitContributions.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
// src/utils/gitContributions.ts
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import { load, dump } from 'js-yaml';
|
||||
import path from 'path';
|
||||
|
||||
interface ContributionData {
|
||||
type: 'add' | 'edit';
|
||||
tool: {
|
||||
name: string;
|
||||
icon?: string;
|
||||
type: 'software' | 'method' | 'concept';
|
||||
description: string;
|
||||
domains: string[];
|
||||
phases: string[];
|
||||
platforms: string[];
|
||||
skillLevel: string;
|
||||
accessType?: string;
|
||||
url: string;
|
||||
projectUrl?: string;
|
||||
license?: string;
|
||||
knowledgebase?: boolean;
|
||||
'domain-agnostic-software'?: string[];
|
||||
related_concepts?: string[];
|
||||
tags: string[];
|
||||
statusUrl?: string;
|
||||
};
|
||||
metadata: {
|
||||
submitter: string;
|
||||
reason?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GitOperationResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
prUrl?: string;
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
interface GitConfig {
|
||||
localRepoPath: string;
|
||||
provider: 'gitea' | 'github' | 'gitlab';
|
||||
apiEndpoint: string;
|
||||
apiToken: string;
|
||||
repoUrl: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
class GitContributionManager {
|
||||
private config: GitConfig;
|
||||
private activeBranches = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
const repoUrl = process.env.GIT_REPO_URL || '';
|
||||
const { owner, name } = this.parseRepoUrl(repoUrl);
|
||||
|
||||
this.config = {
|
||||
localRepoPath: process.env.LOCAL_REPO_PATH || '/var/git/cc24-hub',
|
||||
provider: (process.env.GIT_PROVIDER as any) || 'gitea',
|
||||
apiEndpoint: process.env.GIT_API_ENDPOINT || '',
|
||||
apiToken: process.env.GIT_API_TOKEN || '',
|
||||
repoUrl,
|
||||
repoOwner: owner,
|
||||
repoName: name
|
||||
};
|
||||
|
||||
if (!this.config.apiEndpoint || !this.config.apiToken || !this.config.repoUrl) {
|
||||
throw new Error('Missing required git configuration');
|
||||
}
|
||||
}
|
||||
|
||||
private parseRepoUrl(url: string): { owner: string; name: string } {
|
||||
try {
|
||||
// Parse URLs like: https://git.cc24.dev/mstoeck3/cc24-hub.git
|
||||
const match = url.match(/\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
|
||||
if (!match) {
|
||||
throw new Error('Invalid repository URL format');
|
||||
}
|
||||
|
||||
return {
|
||||
owner: match[1],
|
||||
name: match[2]
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse repository URL: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
async submitContribution(data: ContributionData): Promise<GitOperationResult> {
|
||||
const branchName = this.generateBranchName(data);
|
||||
|
||||
// Check if branch is already being processed
|
||||
if (this.activeBranches.has(branchName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'A contribution with similar details is already being processed'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
this.activeBranches.add(branchName);
|
||||
|
||||
// Ensure repository is in clean state
|
||||
await this.ensureCleanRepo();
|
||||
|
||||
// Create and checkout new branch
|
||||
await this.createBranch(branchName);
|
||||
|
||||
// Modify tools.yaml
|
||||
await this.modifyToolsYaml(data);
|
||||
|
||||
// Commit changes
|
||||
await this.commitChanges(data, branchName);
|
||||
|
||||
// Push branch
|
||||
await this.pushBranch(branchName);
|
||||
|
||||
// Create pull request
|
||||
const prUrl = await this.createPullRequest(data, branchName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Contribution submitted successfully',
|
||||
prUrl,
|
||||
branchName
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Git contribution failed:', error);
|
||||
|
||||
// Attempt cleanup
|
||||
await this.cleanup(branchName);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
} finally {
|
||||
this.activeBranches.delete(branchName);
|
||||
}
|
||||
}
|
||||
|
||||
private generateBranchName(data: ContributionData): string {
|
||||
const timestamp = Date.now();
|
||||
const toolSlug = data.tool.name.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
return `contrib-${data.type}-${toolSlug}-${timestamp}`;
|
||||
}
|
||||
|
||||
private async executeGitCommand(command: string, options: { cwd?: string; timeout?: number } = {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { cwd = this.config.localRepoPath, timeout = 30000 } = options;
|
||||
|
||||
const child = spawn('sh', ['-c', command], {
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`Git command timed out: ${command}`));
|
||||
}, timeout);
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
reject(new Error(`Git command failed (${code}): ${command}\n${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`Failed to execute git command: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureCleanRepo(): Promise<void> {
|
||||
try {
|
||||
// Fetch latest changes
|
||||
await this.executeGitCommand('git fetch origin');
|
||||
|
||||
// Reset to main branch
|
||||
await this.executeGitCommand('git checkout main');
|
||||
await this.executeGitCommand('git reset --hard origin/main');
|
||||
|
||||
// Clean untracked files
|
||||
await this.executeGitCommand('git clean -fd');
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to clean repository: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async createBranch(branchName: string): Promise<void> {
|
||||
try {
|
||||
await this.executeGitCommand(`git checkout -b ${branchName}`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async modifyToolsYaml(data: ContributionData): Promise<void> {
|
||||
try {
|
||||
const yamlPath = path.join(this.config.localRepoPath, 'src/data/tools.yaml');
|
||||
const originalContent = await fs.readFile(yamlPath, 'utf8');
|
||||
|
||||
if (data.type === 'add') {
|
||||
// For adding, append to the tools section
|
||||
const newToolYaml = this.generateToolYaml(data.tool);
|
||||
const updatedContent = this.insertNewTool(originalContent, newToolYaml);
|
||||
await fs.writeFile(yamlPath, updatedContent, 'utf8');
|
||||
} else {
|
||||
// For editing, we still need to parse and regenerate (unfortunately)
|
||||
// But let's at least preserve the overall structure
|
||||
const yamlData = load(originalContent) as any;
|
||||
|
||||
const existingIndex = yamlData.tools.findIndex((tool: any) => tool.name === data.tool.name);
|
||||
if (existingIndex === -1) {
|
||||
throw new Error(`Tool "${data.tool.name}" not found for editing`);
|
||||
}
|
||||
|
||||
yamlData.tools[existingIndex] = this.normalizeToolObject(data.tool);
|
||||
|
||||
// Use consistent YAML formatting
|
||||
const newYamlContent = dump(yamlData, {
|
||||
lineWidth: 120,
|
||||
noRefs: true,
|
||||
sortKeys: false,
|
||||
forceQuotes: false,
|
||||
flowLevel: -1,
|
||||
styles: {
|
||||
'!!null': 'canonical'
|
||||
}
|
||||
});
|
||||
|
||||
await fs.writeFile(yamlPath, newYamlContent, 'utf8');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to modify tools.yaml: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeToolObject(tool: any): any {
|
||||
const normalized = { ...tool };
|
||||
|
||||
// Convert empty strings and undefined to null
|
||||
Object.keys(normalized).forEach(key => {
|
||||
if (normalized[key] === '' || normalized[key] === undefined) {
|
||||
normalized[key] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure arrays are preserved as arrays (even if empty)
|
||||
['domains', 'phases', 'platforms', 'tags', 'related_concepts'].forEach(key => {
|
||||
if (!Array.isArray(normalized[key])) {
|
||||
normalized[key] = [];
|
||||
}
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private generateToolYaml(tool: any): string {
|
||||
const normalized = this.normalizeToolObject(tool);
|
||||
|
||||
let yaml = ` - name: ${normalized.name}\n`;
|
||||
|
||||
if (normalized.icon) yaml += ` icon: ${normalized.icon}\n`;
|
||||
yaml += ` type: ${normalized.type}\n`;
|
||||
|
||||
// Handle description with proper formatting for long text
|
||||
if (normalized.description) {
|
||||
if (normalized.description.length > 80) {
|
||||
yaml += ` description: >-\n`;
|
||||
const words = normalized.description.split(' ');
|
||||
let line = ' ';
|
||||
for (const word of words) {
|
||||
if ((line + word).length > 80 && line.length > 6) {
|
||||
yaml += line.trimEnd() + '\n';
|
||||
line = ' ' + word + ' ';
|
||||
} else {
|
||||
line += word + ' ';
|
||||
}
|
||||
}
|
||||
yaml += line.trimEnd() + '\n';
|
||||
} else {
|
||||
yaml += ` description: ${normalized.description}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Arrays
|
||||
['domains', 'phases', 'platforms'].forEach(key => {
|
||||
if (normalized[key] && normalized[key].length > 0) {
|
||||
yaml += ` ${key}:\n`;
|
||||
normalized[key].forEach((item: string) => {
|
||||
yaml += ` - ${item}\n`;
|
||||
});
|
||||
} else {
|
||||
yaml += ` ${key}: []\n`;
|
||||
}
|
||||
});
|
||||
|
||||
// Add other fields
|
||||
if (normalized['domain-agnostic-software']) {
|
||||
yaml += ` domain-agnostic-software: ${JSON.stringify(normalized['domain-agnostic-software'])}\n`;
|
||||
} else {
|
||||
yaml += ` domain-agnostic-software: null\n`;
|
||||
}
|
||||
|
||||
yaml += ` skillLevel: ${normalized.skillLevel}\n`;
|
||||
yaml += ` accessType: ${normalized.accessType || 'null'}\n`;
|
||||
|
||||
// Handle URL with proper formatting for long URLs
|
||||
if (normalized.url) {
|
||||
if (normalized.url.length > 80) {
|
||||
yaml += ` url: >-\n ${normalized.url}\n`;
|
||||
} else {
|
||||
yaml += ` url: ${normalized.url}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
yaml += ` projectUrl: ${normalized.projectUrl || 'null'}\n`;
|
||||
yaml += ` license: ${normalized.license || 'null'}\n`;
|
||||
yaml += ` knowledgebase: ${normalized.knowledgebase || 'null'}\n`;
|
||||
|
||||
// Related concepts
|
||||
if (normalized.related_concepts && normalized.related_concepts.length > 0) {
|
||||
yaml += ` related_concepts:\n`;
|
||||
normalized.related_concepts.forEach((concept: string) => {
|
||||
yaml += ` - ${concept}\n`;
|
||||
});
|
||||
} else {
|
||||
yaml += ` related_concepts: null\n`;
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (normalized.tags && normalized.tags.length > 0) {
|
||||
yaml += ` tags:\n`;
|
||||
normalized.tags.forEach((tag: string) => {
|
||||
yaml += ` - ${tag}\n`;
|
||||
});
|
||||
} else {
|
||||
yaml += ` tags: []\n`;
|
||||
}
|
||||
|
||||
if (normalized.statusUrl) {
|
||||
yaml += ` statusUrl: ${normalized.statusUrl}\n`;
|
||||
}
|
||||
|
||||
return yaml;
|
||||
}
|
||||
|
||||
private insertNewTool(originalContent: string, newToolYaml: string): string {
|
||||
// Find the end of the tools section (before domains:)
|
||||
const domainsIndex = originalContent.indexOf('\ndomains:');
|
||||
if (domainsIndex === -1) {
|
||||
// If no domains section, just append to end with proper spacing
|
||||
return originalContent.trimEnd() + '\n\n' + newToolYaml.trimEnd() + '\n';
|
||||
}
|
||||
|
||||
// Insert before the domains section with proper newline spacing
|
||||
const beforeDomains = originalContent.slice(0, domainsIndex).trimEnd();
|
||||
const afterDomains = originalContent.slice(domainsIndex);
|
||||
|
||||
return beforeDomains + '\n\n' + newToolYaml.trimEnd() + afterDomains;
|
||||
}
|
||||
|
||||
private async commitChanges(data: ContributionData, branchName: string): Promise<void> {
|
||||
try {
|
||||
// Configure git user for this commit
|
||||
await this.executeGitCommand('git config user.name "CC24-Hub Contributors"');
|
||||
await this.executeGitCommand('git config user.email "contributors@cc24.dev"');
|
||||
|
||||
// Stage changes
|
||||
await this.executeGitCommand('git add src/data/tools.yaml');
|
||||
|
||||
// Create commit message
|
||||
const action = data.type === 'add' ? 'Add' : 'Update';
|
||||
const commitMessage = `${action} ${data.tool.type}: ${data.tool.name}
|
||||
|
||||
Submitted by: ${data.metadata.submitter}
|
||||
${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}
|
||||
|
||||
Branch: ${branchName}`;
|
||||
|
||||
await this.executeGitCommand(`git commit -m "${commitMessage}"`);
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to commit changes: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async pushBranch(branchName: string): Promise<void> {
|
||||
try {
|
||||
await this.executeGitCommand(`git push origin ${branchName}`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to push branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async createPullRequest(data: ContributionData, branchName: string): Promise<string> {
|
||||
const action = data.type === 'add' ? 'Add' : 'Update';
|
||||
const title = `${action} ${data.tool.type}: ${data.tool.name}`;
|
||||
|
||||
const body = `## Contribution Details
|
||||
|
||||
**Type**: ${data.tool.type}
|
||||
**Action**: ${action}
|
||||
**Submitted by**: ${data.metadata.submitter}
|
||||
|
||||
### Tool Information
|
||||
- **Name**: ${data.tool.name}
|
||||
- **Description**: ${data.tool.description}
|
||||
- **Domains**: ${data.tool.domains.join(', ')}
|
||||
- **Phases**: ${data.tool.phases.join(', ')}
|
||||
- **Skill Level**: ${data.tool.skillLevel}
|
||||
- **License**: ${data.tool.license || 'N/A'}
|
||||
- **URL**: ${data.tool.url}
|
||||
|
||||
${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}` : ''}
|
||||
|
||||
### Review Checklist
|
||||
- [ ] Tool information is accurate and complete
|
||||
- [ ] Description is clear and informative
|
||||
- [ ] Domains and phases are correctly assigned
|
||||
- [ ] Tags are relevant and consistent
|
||||
- [ ] License information is correct
|
||||
- [ ] URLs are valid and accessible
|
||||
|
||||
---
|
||||
*This contribution was submitted via the CC24-Hub web interface.*`;
|
||||
|
||||
try {
|
||||
let apiUrl: string;
|
||||
let requestBody: any;
|
||||
|
||||
switch (this.config.provider) {
|
||||
case 'gitea':
|
||||
apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/pulls`;
|
||||
requestBody = {
|
||||
title,
|
||||
body,
|
||||
head: branchName,
|
||||
base: 'main'
|
||||
};
|
||||
break;
|
||||
|
||||
case 'github':
|
||||
apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/pulls`;
|
||||
requestBody = {
|
||||
title,
|
||||
body,
|
||||
head: branchName,
|
||||
base: 'main'
|
||||
};
|
||||
break;
|
||||
|
||||
case 'gitlab':
|
||||
apiUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}/merge_requests`;
|
||||
requestBody = {
|
||||
title,
|
||||
description: body,
|
||||
source_branch: branchName,
|
||||
target_branch: 'main'
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported git provider: ${this.config.provider}`);
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.config.apiToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`PR creation failed (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const prData = await response.json();
|
||||
|
||||
// Extract PR URL based on provider
|
||||
let prUrl: string;
|
||||
switch (this.config.provider) {
|
||||
case 'gitea':
|
||||
case 'github':
|
||||
prUrl = prData.html_url || prData.url;
|
||||
break;
|
||||
case 'gitlab':
|
||||
prUrl = prData.web_url;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown provider response format');
|
||||
}
|
||||
|
||||
return prUrl;
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create pull request: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanup(branchName: string): Promise<void> {
|
||||
try {
|
||||
// Switch back to main and delete the failed branch
|
||||
await this.executeGitCommand('git checkout main', { timeout: 10000 });
|
||||
await this.executeGitCommand(`git branch -D ${branchName}`, { timeout: 10000 });
|
||||
|
||||
// Try to delete remote branch if it exists
|
||||
try {
|
||||
await this.executeGitCommand(`git push origin --delete ${branchName}`, { timeout: 10000 });
|
||||
} catch (error) {
|
||||
// Ignore errors when deleting remote branch (might not exist)
|
||||
console.warn(`Could not delete remote branch ${branchName}:`, error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Cleanup failed for branch ${branchName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{ healthy: boolean; issues?: string[] }> {
|
||||
const issues: string[] = [];
|
||||
|
||||
try {
|
||||
// Check if local repo exists and is a git repository
|
||||
const repoExists = await fs.access(this.config.localRepoPath).then(() => true).catch(() => false);
|
||||
if (!repoExists) {
|
||||
issues.push(`Local repository path does not exist: ${this.config.localRepoPath}`);
|
||||
return { healthy: false, issues };
|
||||
}
|
||||
|
||||
const gitDirExists = await fs.access(path.join(this.config.localRepoPath, '.git')).then(() => true).catch(() => false);
|
||||
if (!gitDirExists) {
|
||||
issues.push('Local path is not a git repository');
|
||||
return { healthy: false, issues };
|
||||
}
|
||||
|
||||
// Check git status
|
||||
try {
|
||||
await this.executeGitCommand('git status --porcelain', { timeout: 5000 });
|
||||
} catch (error) {
|
||||
issues.push(`Git status check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Check remote connectivity
|
||||
try {
|
||||
await this.executeGitCommand('git ls-remote origin HEAD', { timeout: 10000 });
|
||||
} catch (error) {
|
||||
issues.push(`Remote connectivity check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Check API connectivity
|
||||
try {
|
||||
const response = await fetch(this.config.apiEndpoint, {
|
||||
headers: { 'Authorization': `Bearer ${this.config.apiToken}` },
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 404) { // 404 is expected for base API endpoint
|
||||
issues.push(`API connectivity check failed: HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
issues.push(`API connectivity check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Check write permissions
|
||||
try {
|
||||
const testFile = path.join(this.config.localRepoPath, '.write-test');
|
||||
await fs.writeFile(testFile, 'test');
|
||||
await fs.unlink(testFile);
|
||||
} catch (error) {
|
||||
issues.push(`Write permission check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return { healthy: issues.length === 0, issues: issues.length > 0 ? issues : undefined };
|
||||
|
||||
} catch (error) {
|
||||
issues.push(`Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return { healthy: false, issues };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { GitContributionManager, type ContributionData, type GitOperationResult };
|
||||
Reference in New Issue
Block a user