575 lines
18 KiB
TypeScript
575 lines
18 KiB
TypeScript
// src/utils/gitContributions.ts - Enhanced for Phase 3 with YAML preservation
|
|
import { execSync, spawn } from 'child_process';
|
|
import { promises as fs } from 'fs';
|
|
import { load, dump } from 'js-yaml';
|
|
import path from 'path';
|
|
|
|
export 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;
|
|
};
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export class GitContributionManager {
|
|
protected 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}`);
|
|
}
|
|
}
|
|
|
|
// Enhanced git operations for Phase 3
|
|
|
|
/**
|
|
* Create a new branch
|
|
*/
|
|
protected async createBranch(branchName: string): Promise<void> {
|
|
try {
|
|
// Ensure we're on main and up to date
|
|
execSync('git checkout main', { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
execSync('git pull origin main', { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
|
|
// Create and checkout new branch
|
|
execSync(`git checkout -b "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
|
|
this.activeBranches.add(branchName);
|
|
|
|
} catch (error) {
|
|
throw new Error(`Failed to create branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write file to repository
|
|
*/
|
|
protected async writeFile(filePath: string, content: string): Promise<void> {
|
|
try {
|
|
const fullPath = path.join(this.config.localRepoPath, filePath);
|
|
const dirPath = path.dirname(fullPath);
|
|
|
|
// Ensure directory exists
|
|
await fs.mkdir(dirPath, { recursive: true });
|
|
|
|
// Write file
|
|
await fs.writeFile(fullPath, content, 'utf8');
|
|
|
|
} catch (error) {
|
|
throw new Error(`Failed to write file ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read file from repository
|
|
*/
|
|
protected async readFile(filePath: string): Promise<string> {
|
|
try {
|
|
const fullPath = path.join(this.config.localRepoPath, filePath);
|
|
return await fs.readFile(fullPath, 'utf8');
|
|
} catch (error) {
|
|
throw new Error(`Failed to read file ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Commit changes with message
|
|
*/
|
|
protected async commitChanges(message: string): Promise<void> {
|
|
try {
|
|
// Add all changes
|
|
execSync('git add .', { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
|
|
// Check if there are any changes to commit
|
|
try {
|
|
execSync('git diff --cached --exit-code', { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
// If we get here, there are no changes
|
|
throw new Error('No changes to commit');
|
|
} catch (error) {
|
|
// This is expected - it means there are changes to commit
|
|
}
|
|
|
|
// Set git config if not already set
|
|
try {
|
|
execSync('git config user.email "contributions@cc24-hub.local"', { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
execSync('git config user.name "CC24-Hub Contributions"', { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
} catch {
|
|
// Config might already be set
|
|
}
|
|
|
|
// Commit changes
|
|
execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
|
|
} catch (error) {
|
|
throw new Error(`Failed to commit changes: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Push branch to remote
|
|
*/
|
|
protected async pushBranch(branchName: string): Promise<void> {
|
|
try {
|
|
execSync(`git push -u origin "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
} catch (error) {
|
|
throw new Error(`Failed to push branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete branch (cleanup)
|
|
*/
|
|
protected async deleteBranch(branchName: string): Promise<void> {
|
|
try {
|
|
// Switch to main first
|
|
execSync('git checkout main', { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
|
|
// Delete local branch
|
|
execSync(`git branch -D "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
|
|
// Delete remote branch if it exists
|
|
try {
|
|
execSync(`git push origin --delete "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
} catch {
|
|
// Branch might not exist on remote yet
|
|
}
|
|
|
|
this.activeBranches.delete(branchName);
|
|
|
|
} catch (error) {
|
|
console.warn(`Failed to cleanup branch ${branchName}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create pull request
|
|
*/
|
|
protected async createPullRequest(branchName: string, title: string, body: string): Promise<string> {
|
|
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'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CRITICAL FIX: Preserve YAML formatting while updating tools
|
|
* This prevents the complete rewrite that destroys multiline descriptions
|
|
*/
|
|
private async preserveYamlFormat(toolsPath: string, newTool: any, isEdit: boolean): Promise<string> {
|
|
const originalContent = await this.readFile(toolsPath);
|
|
const yamlData: any = load(originalContent);
|
|
|
|
if (!yamlData.tools || !Array.isArray(yamlData.tools)) {
|
|
throw new Error('Invalid tools.yaml format');
|
|
}
|
|
|
|
if (isEdit) {
|
|
// Find and replace existing tool
|
|
const toolIndex = yamlData.tools.findIndex((t: any) =>
|
|
(t.name || '').toLowerCase() === newTool.name.toLowerCase()
|
|
);
|
|
|
|
if (toolIndex >= 0) {
|
|
yamlData.tools[toolIndex] = newTool;
|
|
} else {
|
|
throw new Error('Tool to edit not found');
|
|
}
|
|
} else {
|
|
// Add new tool - insert alphabetically
|
|
const insertIndex = yamlData.tools.findIndex((t: any) =>
|
|
(t.name || '').toLowerCase() > newTool.name.toLowerCase()
|
|
);
|
|
|
|
if (insertIndex >= 0) {
|
|
yamlData.tools.splice(insertIndex, 0, newTool);
|
|
} else {
|
|
yamlData.tools.push(newTool);
|
|
}
|
|
}
|
|
|
|
// Split original content into sections to preserve formatting
|
|
const lines = originalContent.split('\n');
|
|
const toolsStartIndex = lines.findIndex(line => line.trim() === 'tools:');
|
|
|
|
if (toolsStartIndex === -1) {
|
|
// Fallback to full rewrite if structure is unexpected
|
|
console.warn('Could not find tools section, falling back to full YAML rewrite');
|
|
return dump(yamlData, {
|
|
lineWidth: -1,
|
|
noRefs: true,
|
|
quotingType: '"',
|
|
forceQuotes: false,
|
|
indent: 2
|
|
});
|
|
}
|
|
|
|
// Preserve header (everything before tools:)
|
|
const header = lines.slice(0, toolsStartIndex + 1).join('\n');
|
|
|
|
// Find footer (domains, phases, etc.)
|
|
const domainsStartIndex = lines.findIndex(line => line.trim() === 'domains:');
|
|
const footer = domainsStartIndex >= 0 ? '\n' + lines.slice(domainsStartIndex).join('\n') : '';
|
|
|
|
// Generate only the tools section with proper formatting
|
|
const toolsYaml = yamlData.tools.map((tool: any) => {
|
|
return this.formatToolYaml(tool);
|
|
}).join('');
|
|
|
|
return header + '\n' + toolsYaml + footer;
|
|
}
|
|
|
|
/**
|
|
* Format a single tool entry preserving multiline descriptions
|
|
*/
|
|
private formatToolYaml(tool: any): string {
|
|
let toolEntry = ` - name: "${tool.name}"\n`;
|
|
|
|
if (tool.icon) toolEntry += ` icon: "${tool.icon}"\n`;
|
|
toolEntry += ` type: ${tool.type}\n`;
|
|
|
|
// PRESERVE multiline description format for longer descriptions
|
|
if (tool.description && tool.description.length > 80) {
|
|
toolEntry += ` description: >-\n`;
|
|
const words: string[] = tool.description.split(' ');
|
|
const lines: string[] = [];
|
|
let currentLine: string = '';
|
|
|
|
words.forEach((word: string) => {
|
|
if ((currentLine + ' ' + word).length > 80) {
|
|
if (currentLine) lines.push(currentLine);
|
|
currentLine = word;
|
|
} else {
|
|
currentLine = currentLine ? currentLine + ' ' + word : word;
|
|
}
|
|
});
|
|
if (currentLine) lines.push(currentLine);
|
|
|
|
lines.forEach((line: string) => {
|
|
toolEntry += ` ${line}\n`;
|
|
});
|
|
} else {
|
|
toolEntry += ` description: "${tool.description}"\n`;
|
|
}
|
|
|
|
// Add array fields
|
|
if (tool.domains && tool.domains.length > 0) {
|
|
toolEntry += ` domains:\n`;
|
|
tool.domains.forEach((domain: string) => {
|
|
toolEntry += ` - ${domain}\n`;
|
|
});
|
|
}
|
|
|
|
if (tool.phases && tool.phases.length > 0) {
|
|
toolEntry += ` phases:\n`;
|
|
tool.phases.forEach((phase: string) => {
|
|
toolEntry += ` - ${phase}\n`;
|
|
});
|
|
}
|
|
|
|
if (tool.platforms && tool.platforms.length > 0) {
|
|
toolEntry += ` platforms:\n`;
|
|
tool.platforms.forEach((platform: string) => {
|
|
toolEntry += ` - ${platform}\n`;
|
|
});
|
|
}
|
|
|
|
if (tool.related_concepts && tool.related_concepts.length > 0) {
|
|
toolEntry += ` related_concepts:\n`;
|
|
tool.related_concepts.forEach((concept: string) => {
|
|
toolEntry += ` - ${concept}\n`;
|
|
});
|
|
}
|
|
|
|
if (tool['domain-agnostic-software'] && tool['domain-agnostic-software'].length > 0) {
|
|
toolEntry += ` domain-agnostic-software:\n`;
|
|
tool['domain-agnostic-software'].forEach((item: string) => {
|
|
toolEntry += ` - ${item}\n`;
|
|
});
|
|
}
|
|
|
|
// Add scalar fields
|
|
toolEntry += ` skillLevel: ${tool.skillLevel}\n`;
|
|
if (tool.accessType) toolEntry += ` accessType: ${tool.accessType}\n`;
|
|
toolEntry += ` url: ${tool.url}\n`;
|
|
if (tool.projectUrl) toolEntry += ` projectUrl: ${tool.projectUrl}\n`;
|
|
if (tool.license) toolEntry += ` license: ${tool.license}\n`;
|
|
if (tool.knowledgebase) toolEntry += ` knowledgebase: ${tool.knowledgebase}\n`;
|
|
|
|
if (tool.tags && tool.tags.length > 0) {
|
|
toolEntry += ` tags:\n`;
|
|
tool.tags.forEach((tag: string) => {
|
|
toolEntry += ` - ${tag}\n`;
|
|
});
|
|
}
|
|
|
|
return toolEntry;
|
|
}
|
|
|
|
private generateToolYAML(tool: any): string {
|
|
// Clean up the tool object - remove null/undefined values
|
|
const cleanTool: any = {
|
|
name: tool.name,
|
|
type: tool.type,
|
|
description: tool.description,
|
|
domains: tool.domains || [],
|
|
phases: tool.phases || [],
|
|
skillLevel: tool.skillLevel,
|
|
url: tool.url
|
|
};
|
|
|
|
// Add optional fields only if they have values
|
|
if (tool.icon) cleanTool.icon = tool.icon;
|
|
if (tool.platforms && tool.platforms.length > 0) cleanTool.platforms = tool.platforms;
|
|
if (tool.license) cleanTool.license = tool.license;
|
|
if (tool.accessType) cleanTool.accessType = tool.accessType;
|
|
if (tool.projectUrl) cleanTool.projectUrl = tool.projectUrl;
|
|
if (tool.knowledgebase) cleanTool.knowledgebase = tool.knowledgebase;
|
|
if (tool.related_concepts && tool.related_concepts.length > 0) cleanTool.related_concepts = tool.related_concepts;
|
|
if (tool.tags && tool.tags.length > 0) cleanTool.tags = tool.tags;
|
|
|
|
// Generate clean YAML
|
|
return dump(cleanTool, {
|
|
lineWidth: -1,
|
|
noRefs: true,
|
|
quotingType: '"',
|
|
forceQuotes: false,
|
|
indent: 2
|
|
});
|
|
}
|
|
|
|
async submitContribution(data: ContributionData): Promise<GitOperationResult> {
|
|
const branchName = `tool-${data.type}-${Date.now()}`;
|
|
|
|
try {
|
|
await this.createBranch(branchName);
|
|
|
|
const toolsPath = 'src/data/tools.yaml';
|
|
|
|
// CRITICAL FIX: Use format-preserving method instead of dump()
|
|
const newYaml = await this.preserveYamlFormat(toolsPath, data.tool, data.type === 'edit');
|
|
|
|
await this.writeFile(toolsPath, newYaml);
|
|
|
|
const commitMessage = `${data.type === 'add' ? 'Add' : 'Update'} tool: ${data.tool.name}
|
|
|
|
Submitted by: ${data.metadata.submitter}
|
|
${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`;
|
|
|
|
await this.commitChanges(commitMessage);
|
|
|
|
await this.pushBranch(branchName);
|
|
|
|
// Generate tool YAML for PR description
|
|
const toolYaml = this.generateToolYAML(data.tool);
|
|
|
|
const prUrl = await this.createPullRequest(
|
|
branchName,
|
|
`${data.type === 'add' ? 'Add' : 'Update'} tool: ${data.tool.name}`,
|
|
this.generateEnhancedPRDescription(data, toolYaml)
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
message: `Tool contribution submitted successfully`,
|
|
prUrl,
|
|
branchName
|
|
};
|
|
|
|
} catch (error) {
|
|
// Cleanup on failure
|
|
try {
|
|
await this.deleteBranch(branchName);
|
|
} catch (cleanupError) {
|
|
console.error('Failed to cleanup branch:', cleanupError);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private generateEnhancedPRDescription(data: ContributionData, toolYaml: string): string {
|
|
return `## Tool ${data.type === 'add' ? 'Addition' : 'Update'}: ${data.tool.name}
|
|
|
|
**Type:** ${data.tool.type}
|
|
**Submitted by:** ${data.metadata.submitter}
|
|
**Action:** ${data.type === 'add' ? 'Add new tool' : 'Update existing tool'}
|
|
|
|
### Tool Details
|
|
- **Name:** ${data.tool.name}
|
|
- **Description:** ${data.tool.description}
|
|
- **URL:** ${data.tool.url}
|
|
- **Skill Level:** ${data.tool.skillLevel}
|
|
${data.tool.platforms && data.tool.platforms.length > 0 ? `- **Platforms:** ${data.tool.platforms.join(', ')}` : ''}
|
|
${data.tool.license ? `- **License:** ${data.tool.license}` : ''}
|
|
${data.tool.accessType ? `- **Access Type:** ${data.tool.accessType}` : ''}
|
|
${data.tool.projectUrl ? `- **Project URL:** ${data.tool.projectUrl}` : ''}
|
|
- **Domains:** ${data.tool.domains.join(', ')}
|
|
- **Phases:** ${data.tool.phases.join(', ')}
|
|
${data.tool.tags && data.tool.tags.length > 0 ? `- **Tags:** ${data.tool.tags.join(', ')}` : ''}
|
|
${data.tool.related_concepts && data.tool.related_concepts.length > 0 ? `- **Related Concepts:** ${data.tool.related_concepts.join(', ')}` : ''}
|
|
|
|
${data.metadata.reason ? `### Reason for Contribution
|
|
${data.metadata.reason}
|
|
|
|
` : ''}### Raw Tool Data (Copy & Paste Ready)
|
|
|
|
\`\`\`yaml
|
|
${toolYaml}\`\`\`
|
|
|
|
### For Maintainers
|
|
|
|
**To add this tool to tools.yaml:**
|
|
1. Copy the YAML data above
|
|
2. ${data.type === 'add' ? 'Add it to the tools array in the appropriate alphabetical position' : 'Replace the existing tool entry with this updated data'}
|
|
3. Verify all fields are correct
|
|
4. Test that the tool displays properly
|
|
5. Close this PR
|
|
|
|
### Review Checklist
|
|
- [ ] Tool information is accurate and complete
|
|
- [ ] Description is clear and informative
|
|
- [ ] Domains and phases are correctly assigned
|
|
- [ ] Tags are relevant and consistent with existing tools
|
|
- [ ] License information is correct (for software)
|
|
- [ ] URLs are valid and accessible
|
|
- [ ] No duplicate tool entries
|
|
- [ ] YAML syntax is valid
|
|
|
|
---
|
|
*This contribution was submitted via the CC24-Hub web interface and contains only the raw tool data for manual integration.*`;
|
|
}
|
|
} |