simplify tool contribution

This commit is contained in:
overcuriousity 2025-07-25 22:52:21 +02:00
parent d80a4d85eb
commit 6892aaf7de
4 changed files with 875 additions and 1302 deletions

View File

@ -115,17 +115,6 @@ async function validateToolData(tool: any, action: string): Promise<{ valid: boo
} }
} }
// Validate related concepts exist
if (tool.related_concepts && tool.related_concepts.length > 0) {
const existingConcepts = new Set(
existingData.tools.filter((t: any) => t.type === 'concept').map((t: any) => t.name)
);
const invalidConcepts = tool.related_concepts.filter((c: string) => !existingConcepts.has(c));
if (invalidConcepts.length > 0) {
errors.push(`Referenced concepts not found: ${invalidConcepts.join(', ')}`);
}
}
return { valid: errors.length === 0, errors }; return { valid: errors.length === 0, errors };
} catch (error) { } catch (error) {
@ -199,23 +188,21 @@ export const POST: APIRoute = async ({ request }) => {
// CRITICAL FIX: Enhanced error handling for Git operations // CRITICAL FIX: Enhanced error handling for Git operations
try { try {
// Submit contribution via Git (now creates issue instead of PR)
const gitManager = new GitContributionManager(); const gitManager = new GitContributionManager();
const result = await gitManager.submitContribution(contributionData); const result = await gitManager.submitContribution(contributionData);
if (result.success) { if (result.success) {
// Log successful contribution console.log(`[CONTRIBUTION] Issue created for "${validatedData.tool.name}" by ${userEmail} - Issue: ${result.issueUrl}`);
console.log(`[CONTRIBUTION SUCCESS] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail} - PR: ${result.prUrl}`);
// ENSURE proper success response
return apiResponse.created({ return apiResponse.created({
success: true, success: true,
message: result.message, message: result.message,
prUrl: result.prUrl, issueUrl: result.issueUrl,
branchName: result.branchName issueNumber: result.issueNumber
}); });
} else { } else {
// Log failed contribution console.error(`[CONTRIBUTION FAILED] "${validatedData.tool.name}" by ${userEmail}: ${result.message}`);
console.error(`[CONTRIBUTION FAILED] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail}: ${result.message}`);
return apiServerError.internal(`Contribution failed: ${result.message}`); return apiServerError.internal(`Contribution failed: ${result.message}`);
} }

View File

@ -30,8 +30,8 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
<form id="kb-form" style="padding: 2rem;"> <form id="kb-form" style="padding: 2rem;">
<!-- Tool Selection --> <!-- Tool Selection -->
<div class="form-group"> <div class="form-group">
<label for="tool-name" class="form-label required">Related Tool</label> <label for="tool-name" class="form-label">Related Tool</label>
<select id="tool-name" name="toolName" required class="form-input"> <select id="tool-name" name="toolName" class="form-input">
<option value="">Select a tool</option> <option value="">Select a tool</option>
{sortedTools.map(tool => ( {sortedTools.map(tool => (
<option value={tool.name}>{tool.name} ({tool.type})</option> <option value={tool.name}>{tool.name} ({tool.type})</option>
@ -41,12 +41,11 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
<!-- Article Title --> <!-- Article Title -->
<div class="form-group"> <div class="form-group">
<label for="title" class="form-label required">Article Title</label> <label for="title" class="form-label">Article Title</label>
<input <input
type="text" type="text"
id="title" id="title"
name="title" name="title"
required
maxlength="100" maxlength="100"
placeholder="Clear, descriptive title for your article" placeholder="Clear, descriptive title for your article"
class="form-input" class="form-input"
@ -55,11 +54,10 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
<!-- Description --> <!-- Description -->
<div class="form-group"> <div class="form-group">
<label for="description" class="form-label required">Description</label> <label for="description" class="form-label">Description</label>
<textarea <textarea
id="description" id="description"
name="description" name="description"
required
maxlength="300" maxlength="300"
rows="3" rows="3"
placeholder="Brief summary of what this article covers (20-300 characters)" placeholder="Brief summary of what this article covers (20-300 characters)"
@ -117,8 +115,8 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
<!-- Difficulty Level --> <!-- Difficulty Level -->
<div class="form-group"> <div class="form-group">
<label for="difficulty" class="form-label required">Difficulty Level</label> <label for="difficulty" class="form-label">Difficulty Level</label>
<select id="difficulty" name="difficulty" required class="form-input"> <select id="difficulty" name="difficulty" class="form-input">
<option value="">Select difficulty</option> <option value="">Select difficulty</option>
<option value="novice">Novice - No prior experience needed</option> <option value="novice">Novice - No prior experience needed</option>
<option value="beginner">Beginner - Basic familiarity helpful</option> <option value="beginner">Beginner - Basic familiarity helpful</option>
@ -168,7 +166,7 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
<!-- Submit Button --> <!-- Submit Button -->
<div style="display: flex; gap: 1rem; margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--color-border);"> <div style="display: flex; gap: 1rem; margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--color-border);">
<button type="submit" id="submit-btn" class="btn btn-accent" style="flex: 1;" disabled> <button type="submit" id="submit-btn" class="btn btn-accent" style="flex: 1;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/> <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
</svg> </svg>
@ -204,85 +202,116 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
</BaseLayout> </BaseLayout>
<script> <script>
// State management // FIXED: Properly typed interfaces for TypeScript compliance
let uploadedFiles: Array<{id: string, file: File, name: string, uploaded: boolean, url?: string}> = []; interface UploadedFile {
id: string;
// DOM elements file: File;
const form = document.getElementById('kb-form') as HTMLFormElement; name: string;
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement; uploaded: boolean;
const fileInput = document.getElementById('file-input') as HTMLInputElement; url?: string;
const uploadArea = document.getElementById('upload-area') as HTMLElement;
const fileList = document.getElementById('file-list') as HTMLElement;
const filesContainer = document.getElementById('files-container') as HTMLElement;
// Form validation
function validateForm(): boolean {
const toolName = (document.getElementById('tool-name') as HTMLSelectElement).value;
const title = (document.getElementById('title') as HTMLInputElement).value;
const description = (document.getElementById('description') as HTMLTextAreaElement).value;
const content = (document.getElementById('content') as HTMLTextAreaElement).value;
const externalLink = (document.getElementById('external-link') as HTMLInputElement).value;
const difficulty = (document.getElementById('difficulty') as HTMLSelectElement).value;
const hasContent = content.trim().length > 0 || uploadedFiles.length > 0 || externalLink.trim().length > 0;
return Boolean(toolName) && Boolean(title) && Boolean(description) && Boolean(difficulty) && hasContent;
} }
// Update submit button state // Extend Window interface for global functions
function updateSubmitButton() { declare global {
if (submitBtn) { interface Window {
submitBtn.disabled = !validateForm(); removeFile: (fileId: string) => void;
} }
} }
// File upload handling // FIXED: State management with proper typing
function setupFileUpload() { let uploadedFiles: UploadedFile[] = [];
if (!fileInput || !uploadArea) return;
uploadArea.addEventListener('click', () => fileInput.click()); // FIXED: Properly typed element selection with specific HTML element types
const elements = {
uploadArea.addEventListener('dragover', (e) => { form: document.getElementById('kb-form') as HTMLFormElement | null,
e.preventDefault(); submitBtn: document.getElementById('submit-btn') as HTMLButtonElement | null,
uploadArea.style.borderColor = 'var(--color-accent)'; fileInput: document.getElementById('file-input') as HTMLInputElement | null,
uploadArea: document.getElementById('upload-area') as HTMLElement | null,
fileList: document.getElementById('file-list') as HTMLElement | null,
filesContainer: document.getElementById('files-container') as HTMLElement | null
};
// Check for critical elements
const criticalElements: Array<keyof typeof elements> = ['form', 'submitBtn'];
const missingElements = criticalElements.filter(key => !elements[key]);
if (missingElements.length > 0) {
console.error('[KB FORM ERROR] Missing critical elements:', missingElements);
} else {
console.log('[KB FORM DEBUG] All critical elements found, initializing form');
}
function validateForm(): boolean {
return true; // Always return true - no validation
}
// Update submit button state with null checks
function updateSubmitButton(): void {
if (elements.submitBtn) {
const isValid = validateForm();
elements.submitBtn.disabled = !isValid;
console.log('[KB FORM DEBUG] Button state:', isValid ? 'enabled' : 'disabled');
}
}
// File upload handling with proper null checks
function setupFileUpload(): void {
if (!elements.fileInput || !elements.uploadArea) return;
elements.uploadArea.addEventListener('click', () => {
if (elements.fileInput) {
elements.fileInput.click();
}
}); });
uploadArea.addEventListener('dragleave', () => { elements.uploadArea.addEventListener('dragover', (e: DragEvent) => {
uploadArea.style.borderColor = 'var(--color-border)'; e.preventDefault();
if (elements.uploadArea) {
elements.uploadArea.style.borderColor = 'var(--color-accent)';
}
}); });
uploadArea.addEventListener('drop', (e) => { elements.uploadArea.addEventListener('dragleave', () => {
if (elements.uploadArea) {
elements.uploadArea.style.borderColor = 'var(--color-border)';
}
});
elements.uploadArea.addEventListener('drop', (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
uploadArea.style.borderColor = 'var(--color-border)'; if (elements.uploadArea) {
elements.uploadArea.style.borderColor = 'var(--color-border)';
}
if (e.dataTransfer?.files) { if (e.dataTransfer?.files) {
handleFiles(Array.from(e.dataTransfer.files)); handleFiles(Array.from(e.dataTransfer.files));
} }
}); });
fileInput.addEventListener('change', (e) => { elements.fileInput.addEventListener('change', (e: Event) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
if (target.files) { if (target?.files) {
handleFiles(Array.from(target.files)); handleFiles(Array.from(target.files));
} }
}); });
} }
function handleFiles(files: File[]) { function handleFiles(files: File[]): void {
files.forEach(file => { files.forEach(file => {
const fileId = Date.now() + '-' + Math.random().toString(36).substr(2, 9); const fileId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
uploadedFiles.push({ const newFile: UploadedFile = {
id: fileId, id: fileId,
file, file,
name: file.name, name: file.name,
uploaded: false uploaded: false
}); };
uploadedFiles.push(newFile);
uploadFile(fileId); uploadFile(fileId);
}); });
renderFileList(); renderFileList();
updateSubmitButton(); updateSubmitButton();
} }
async function uploadFile(fileId: string) { async function uploadFile(fileId: string): Promise<void> {
const fileItem = uploadedFiles.find(f => f.id === fileId); const fileItem = uploadedFiles.find(f => f.id === fileId);
if (!fileItem) return; if (!fileItem) return;
@ -310,18 +339,18 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
} }
} }
function removeFile(fileId: string) { function removeFile(fileId: string): void {
uploadedFiles = uploadedFiles.filter(f => f.id !== fileId); uploadedFiles = uploadedFiles.filter(f => f.id !== fileId);
renderFileList(); renderFileList();
updateSubmitButton(); updateSubmitButton();
} }
function renderFileList() { function renderFileList(): void {
if (!filesContainer || !fileList) return; if (!elements.filesContainer || !elements.fileList) return;
if (uploadedFiles.length > 0) { if (uploadedFiles.length > 0) {
fileList.style.display = 'block'; elements.fileList.style.display = 'block';
filesContainer.innerHTML = uploadedFiles.map(file => ` elements.filesContainer.innerHTML = uploadedFiles.map(file => `
<div class="file-item" style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: 0.25rem; margin-bottom: 0.5rem;"> <div class="file-item" style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: 0.25rem; margin-bottom: 0.5rem;">
<div style="flex: 1;"> <div style="flex: 1;">
<strong>${file.name}</strong> <strong>${file.name}</strong>
@ -333,29 +362,32 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
} }
</div> </div>
</div> </div>
<button type="button" onclick="removeFile('${file.id}')" class="btn btn-small" style="background: var(--color-danger); color: white;">Remove</button> <button type="button" onclick="window.removeFile('${file.id}')" class="btn btn-small" style="background: var(--color-danger); color: white;">Remove</button>
</div> </div>
`).join(''); `).join('');
} else { } else {
fileList.style.display = 'none'; elements.fileList.style.display = 'none';
} }
} }
// Form submission async function handleSubmit(e: Event): Promise<void> {
async function handleSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
console.log('[KB FORM DEBUG] Form submitted');
if (!submitBtn || !form || submitBtn.disabled) return; if (!elements.submitBtn || !elements.form) {
console.log('[KB FORM DEBUG] Submission blocked - form missing');
return;
}
submitBtn.classList.add('loading'); elements.submitBtn.classList.add('loading');
submitBtn.innerHTML = '⏳ Submitting...'; elements.submitBtn.innerHTML = '⏳ Submitting...';
try { try {
const formData = new FormData(form); const formData = new FormData(elements.form);
// Process categories and tags // Process categories and tags with proper null handling
const categoriesValue = formData.get('categories') as string || ''; const categoriesValue = (formData.get('categories') as string) || '';
const tagsValue = formData.get('tags') as string || ''; const tagsValue = (formData.get('tags') as string) || '';
const categories = categoriesValue.split(',').map(s => s.trim()).filter(s => s); const categories = categoriesValue.split(',').map(s => s.trim()).filter(s => s);
const tags = tagsValue.split(',').map(s => s.trim()).filter(s => s); const tags = tagsValue.split(',').map(s => s.trim()).filter(s => s);
@ -365,12 +397,15 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
// Add uploaded files // Add uploaded files
formData.set('uploadedFiles', JSON.stringify(uploadedFiles.filter(f => f.uploaded))); formData.set('uploadedFiles', JSON.stringify(uploadedFiles.filter(f => f.uploaded)));
console.log('[KB FORM DEBUG] Submitting to API...');
const response = await fetch('/api/contribute/knowledgebase', { const response = await fetch('/api/contribute/knowledgebase', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
console.log('[KB FORM DEBUG] Response status:', response.status);
const result = await response.json(); const result = await response.json();
console.log('[KB FORM DEBUG] Response data:', result);
if (result.success) { if (result.success) {
// Show success modal // Show success modal
@ -389,8 +424,10 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
successModal.style.display = 'flex'; successModal.style.display = 'flex';
} }
// Reset form // Reset form with proper typing
form.reset(); if (elements.form) {
elements.form.reset();
}
uploadedFiles = []; uploadedFiles = [];
renderFileList(); renderFileList();
updateSubmitButton(); updateSubmitButton();
@ -399,14 +436,17 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
} }
} catch (error) { } catch (error) {
console.error('[KB FORM ERROR] Submission error:', error);
showMessage('error', 'An error occurred during submission'); showMessage('error', 'An error occurred during submission');
} finally { } finally {
submitBtn.classList.remove('loading'); if (elements.submitBtn) {
submitBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/></svg> Submit Article'; elements.submitBtn.classList.remove('loading');
elements.submitBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/></svg> Submit Article';
}
} }
} }
function showMessage(type: 'success' | 'error' | 'warning', message: string) { function showMessage(type: 'success' | 'error' | 'warning', message: string): void {
const container = document.getElementById('message-container'); const container = document.getElementById('message-container');
if (!container) return; if (!container) return;
@ -423,57 +463,10 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
setTimeout(() => messageEl.remove(), 5000); setTimeout(() => messageEl.remove(), 5000);
} }
// Event listeners // Make removeFile available globally with proper typing
if (form) { window.removeFile = removeFile;
form.addEventListener('submit', handleSubmit);
form.addEventListener('input', updateSubmitButton);
form.addEventListener('change', updateSubmitButton);
}
// Make removeFile available globally
(window as any).removeFile = removeFile;
// Initialize // Initialize
setupFileUpload(); setupFileUpload();
updateSubmitButton(); console.log('[KB FORM DEBUG] Form initialization complete');
</script> </script>
<style>
.upload-area {
border: 2px dashed var(--color-border);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: var(--transition-fast);
}
.upload-area:hover {
border-color: var(--color-accent);
background-color: var(--color-background-secondary);
}
.upload-placeholder svg {
color: var(--color-text-tertiary);
margin-bottom: 0.5rem;
}
.file-item {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.btn.loading {
opacity: 0.7;
pointer-events: none;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,5 @@
// src/utils/gitContributions.ts - Enhanced for Phase 3 with YAML preservation // src/utils/gitContributions.ts - MINIMAL: Issues only, no filesystem/git operations
import { execSync, spawn } from 'child_process'; import { dump } from 'js-yaml';
import { promises as fs } from 'fs';
import { load, dump } from 'js-yaml';
import path from 'path';
export interface ContributionData { export interface ContributionData {
type: 'add' | 'edit'; type: 'add' | 'edit';
@ -34,413 +31,63 @@ export interface ContributionData {
export interface GitOperationResult { export interface GitOperationResult {
success: boolean; success: boolean;
message: string; message: string;
prUrl?: string; issueUrl?: string;
branchName?: string; issueNumber?: number;
} }
interface GitConfig { interface GitConfig {
localRepoPath: string;
provider: 'gitea' | 'github' | 'gitlab'; provider: 'gitea' | 'github' | 'gitlab';
apiEndpoint: string; apiEndpoint: string;
apiToken: string; apiToken: string;
repoUrl: string;
repoOwner: string; repoOwner: string;
repoName: string; repoName: string;
} }
export class GitContributionManager { export class GitContributionManager {
protected config: GitConfig; private config: GitConfig;
private activeBranches = new Set<string>();
constructor() { constructor() {
const repoUrl = process.env.GIT_REPO_URL || ''; const repoUrl = process.env.GIT_REPO_URL || '';
const { owner, name } = this.parseRepoUrl(repoUrl); const { owner, name } = this.parseRepoUrl(repoUrl);
this.config = { this.config = {
localRepoPath: process.env.LOCAL_REPO_PATH || '/var/git/cc24-hub',
provider: (process.env.GIT_PROVIDER as any) || 'gitea', provider: (process.env.GIT_PROVIDER as any) || 'gitea',
apiEndpoint: process.env.GIT_API_ENDPOINT || '', apiEndpoint: process.env.GIT_API_ENDPOINT || '',
apiToken: process.env.GIT_API_TOKEN || '', apiToken: process.env.GIT_API_TOKEN || '',
repoUrl,
repoOwner: owner, repoOwner: owner,
repoName: name repoName: name
}; };
if (!this.config.apiEndpoint || !this.config.apiToken || !this.config.repoUrl) { if (!this.config.apiEndpoint || !this.config.apiToken) {
throw new Error('Missing required git configuration'); throw new Error('Missing required git configuration');
} }
} }
private parseRepoUrl(url: string): { owner: string; name: string } { private parseRepoUrl(url: string): { owner: string; name: string } {
const match = url.match(/\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
if (!match) {
throw new Error('Invalid repository URL format');
}
return { owner: match[1], name: match[2] };
}
async submitContribution(data: ContributionData): Promise<GitOperationResult> {
try { try {
// Parse URLs like: https://git.cc24.dev/mstoeck3/cc24-hub.git const toolYaml = this.generateYAML(data.tool);
const match = url.match(/\/([^\/]+)\/([^\/]+?)(?:\.git)?$/); const issueUrl = await this.createIssue(data, toolYaml);
if (!match) {
throw new Error('Invalid repository URL format'); return {
} success: true,
return { owner: match[1], name: match[2] }; message: 'Tool contribution submitted as issue',
issueUrl
};
} catch (error) { } catch (error) {
throw new Error(`Failed to parse repository URL: ${url}`); throw new Error(`Issue creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
} }
// Enhanced git operations for Phase 3 private generateYAML(tool: any): string {
// Clean tool object
/**
* 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 = { const cleanTool: any = {
name: tool.name, name: tool.name,
type: tool.type, type: tool.type,
@ -451,125 +98,116 @@ export class GitContributionManager {
url: tool.url url: tool.url
}; };
// Add optional fields only if they have values // Add optional fields
if (tool.icon) cleanTool.icon = tool.icon; if (tool.icon) cleanTool.icon = tool.icon;
if (tool.platforms && tool.platforms.length > 0) cleanTool.platforms = tool.platforms; if (tool.platforms?.length) cleanTool.platforms = tool.platforms;
if (tool.license) cleanTool.license = tool.license; if (tool.license) cleanTool.license = tool.license;
if (tool.accessType) cleanTool.accessType = tool.accessType; if (tool.accessType) cleanTool.accessType = tool.accessType;
if (tool.projectUrl) cleanTool.projectUrl = tool.projectUrl; if (tool.projectUrl) cleanTool.projectUrl = tool.projectUrl;
if (tool.knowledgebase) cleanTool.knowledgebase = tool.knowledgebase; if (tool.knowledgebase) cleanTool.knowledgebase = tool.knowledgebase;
if (tool.related_concepts && tool.related_concepts.length > 0) cleanTool.related_concepts = tool.related_concepts; if (tool.related_concepts?.length) cleanTool.related_concepts = tool.related_concepts;
if (tool.tags && tool.tags.length > 0) cleanTool.tags = tool.tags; if (tool.tags?.length) cleanTool.tags = tool.tags;
if (tool['domain-agnostic-software']?.length) {
cleanTool['domain-agnostic-software'] = tool['domain-agnostic-software'];
}
// Generate clean YAML
return dump(cleanTool, { return dump(cleanTool, {
lineWidth: -1, lineWidth: -1,
noRefs: true, noRefs: true,
quotingType: '"', quotingType: '"',
forceQuotes: false, forceQuotes: false,
indent: 2 indent: 2
}); }).trim();
} }
async submitContribution(data: ContributionData): Promise<GitOperationResult> { private async createIssue(data: ContributionData, toolYaml: string): Promise<string> {
const branchName = `tool-${data.type}-${Date.now()}`; const title = `${data.type === 'add' ? 'Add' : 'Update'} tool: ${data.tool.name}`;
const body = this.generateIssueBody(data, toolYaml);
try { let apiUrl: string;
await this.createBranch(branchName); let requestBody: any;
const toolsPath = 'src/data/tools.yaml'; switch (this.config.provider) {
case 'gitea':
apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
requestBody = { title, body };
break;
// CRITICAL FIX: Use format-preserving method instead of dump() case 'github':
const newYaml = await this.preserveYamlFormat(toolsPath, data.tool, data.type === 'edit'); apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
requestBody = { title, body };
break;
case 'gitlab':
apiUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}/issues`;
requestBody = { title, description: body };
break;
default:
throw new Error(`Unsupported git provider: ${this.config.provider}`);
}
await this.writeFile(toolsPath, newYaml); const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.config.apiToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
const commitMessage = `${data.type === 'add' ? 'Add' : 'Update'} tool: ${data.tool.name} if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
Submitted by: ${data.metadata.submitter} const issueData = await response.json();
${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`;
// Extract issue URL
await this.commitChanges(commitMessage); switch (this.config.provider) {
case 'gitea':
await this.pushBranch(branchName); case 'github':
return issueData.html_url || issueData.url;
// Generate tool YAML for PR description case 'gitlab':
const toolYaml = this.generateToolYAML(data.tool); return issueData.web_url;
default:
const prUrl = await this.createPullRequest( throw new Error('Unknown provider response format');
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 { private generateIssueBody(data: ContributionData, toolYaml: string): string {
return `## Tool ${data.type === 'add' ? 'Addition' : 'Update'}: ${data.tool.name} return `## ${data.type === 'add' ? 'Add' : 'Update'} Tool: ${data.tool.name}
**Type:** ${data.tool.type}
**Submitted by:** ${data.metadata.submitter} **Submitted by:** ${data.metadata.submitter}
**Action:** ${data.type === 'add' ? 'Add new tool' : 'Update existing tool'} **Type:** ${data.tool.type}
**Action:** ${data.type}
### Tool Details ### Tool Information
- **Name:** ${data.tool.name} - **Name:** ${data.tool.name}
- **Description:** ${data.tool.description} - **Description:** ${data.tool.description}
- **URL:** ${data.tool.url} - **URL:** ${data.tool.url}
- **Skill Level:** ${data.tool.skillLevel} - **Skill Level:** ${data.tool.skillLevel}
${data.tool.platforms && data.tool.platforms.length > 0 ? `- **Platforms:** ${data.tool.platforms.join(', ')}` : ''} ${data.tool.platforms?.length ? `- **Platforms:** ${data.tool.platforms.join(', ')}` : ''}
${data.tool.license ? `- **License:** ${data.tool.license}` : ''} ${data.tool.license ? `- **License:** ${data.tool.license}` : ''}
${data.tool.accessType ? `- **Access Type:** ${data.tool.accessType}` : ''} ${data.tool.domains?.length ? `- **Domains:** ${data.tool.domains.join(', ')}` : ''}
${data.tool.projectUrl ? `- **Project URL:** ${data.tool.projectUrl}` : ''} ${data.tool.phases?.length ? `- **Phases:** ${data.tool.phases.join(', ')}` : ''}
- **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 ? `### Reason
${data.metadata.reason} ${data.metadata.reason}
` : ''}### Raw Tool Data (Copy & Paste Ready) ` : ''}### Copy-Paste YAML
\`\`\`yaml \`\`\`yaml
${toolYaml}\`\`\` - ${toolYaml.split('\n').join('\n ')}
\`\`\`
### For Maintainers ### For Maintainers
1. Copy the YAML above
**To add this tool to tools.yaml:** 2. Add to \`src/data/tools.yaml\` in the tools array
1. Copy the YAML data above 3. Maintain alphabetical order
2. ${data.type === 'add' ? 'Add it to the tools array in the appropriate alphabetical position' : 'Replace the existing tool entry with this updated data'} 4. Close this issue when done
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.*`; *Submitted via CC24-Hub contribution form*`;
} }
} }