first draft contributions

This commit is contained in:
overcuriousity
2025-07-22 21:56:01 +02:00
parent 9798837806
commit 043a2d32ac
4 changed files with 1742 additions and 0 deletions

View 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 };