From 043a2d32acbe35145df5a972c96277f54edfd4d8 Mon Sep 17 00:00:00 2001 From: overcuriousity Date: Tue, 22 Jul 2025 21:56:01 +0200 Subject: [PATCH] first draft contributions --- src/pages/api/contribute/tool.ts | 393 +++++++++++++++++ src/pages/contribute/tool.astro | 732 +++++++++++++++++++++++++++++++ src/styles/global.css | 4 + src/utils/gitContributions.ts | 613 ++++++++++++++++++++++++++ 4 files changed, 1742 insertions(+) create mode 100644 src/pages/api/contribute/tool.ts create mode 100644 src/pages/contribute/tool.astro create mode 100644 src/utils/gitContributions.ts diff --git a/src/pages/api/contribute/tool.ts b/src/pages/api/contribute/tool.ts new file mode 100644 index 0000000..2e2c8f0 --- /dev/null +++ b/src/pages/api/contribute/tool.ts @@ -0,0 +1,393 @@ +// src/pages/api/contribute/tool.ts +import type { APIRoute } from 'astro'; +import { getSessionFromRequest, verifySession } from '../../../utils/auth.js'; +import { GitContributionManager, type ContributionData } from '../../../utils/gitContributions.js'; +import { z } from 'zod'; + +export const prerender = false; + +// Enhanced tool schema for contributions (stricter validation) +const ContributionToolSchema = z.object({ + name: z.string().min(1, 'Tool name is required').max(100, 'Tool name too long'), + icon: z.string().optional().nullable(), + type: z.enum(['software', 'method', 'concept'], { + errorMap: () => ({ message: 'Type must be software, method, or concept' }) + }), + description: z.string().min(10, 'Description must be at least 10 characters').max(1000, 'Description too long'), + domains: z.array(z.string()).default([]), + phases: z.array(z.string()).default([]), + platforms: z.array(z.string()).default([]), + skillLevel: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert'], { + errorMap: () => ({ message: 'Invalid skill level' }) + }), + accessType: z.string().optional().nullable(), + url: z.string().url('Must be a valid URL'), + projectUrl: z.string().url('Must be a valid URL').optional().nullable(), + license: z.string().optional().nullable(), + knowledgebase: z.boolean().optional().nullable(), + 'domain-agnostic-software': z.array(z.string()).optional().nullable(), + related_concepts: z.array(z.string()).optional().nullable(), + tags: z.array(z.string()).default([]), + statusUrl: z.string().url('Must be a valid URL').optional().nullable() +}); + +const ContributionRequestSchema = z.object({ + action: z.enum(['add', 'edit'], { + errorMap: () => ({ message: 'Action must be add or edit' }) + }), + tool: ContributionToolSchema, + metadata: z.object({ + reason: z.string().max(500, 'Reason too long').optional() + }).optional().default({}) +}); + +// Rate limiting storage +const rateLimitStore = new Map(); +const RATE_LIMIT_WINDOW = 10 * 60 * 1000; // 10 minutes +const RATE_LIMIT_MAX = 5; // 5 contributions per 10 minutes per user + +function checkRateLimit(userId: string): boolean { + const now = Date.now(); + const userLimit = rateLimitStore.get(userId); + + if (!userLimit || now > userLimit.resetTime) { + rateLimitStore.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); + return true; + } + + if (userLimit.count >= RATE_LIMIT_MAX) { + return false; + } + + userLimit.count++; + return true; +} + +function cleanupExpiredRateLimits() { + const now = Date.now(); + for (const [userId, limit] of rateLimitStore.entries()) { + if (now > limit.resetTime) { + rateLimitStore.delete(userId); + } + } +} + +// Cleanup every 5 minutes +setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000); + +// Input sanitization +function sanitizeInput(input: any): any { + if (typeof input === 'string') { + return input.trim() + .replace(/[<>]/g, '') // Remove basic HTML tags + .slice(0, 2000); // Limit length + } + + if (Array.isArray(input)) { + return input.map(sanitizeInput).filter(Boolean).slice(0, 50); // Limit array size + } + + if (typeof input === 'object' && input !== null) { + const sanitized: any = {}; + for (const [key, value] of Object.entries(input)) { + if (key.length <= 100) { // Limit key length + sanitized[key] = sanitizeInput(value); + } + } + return sanitized; + } + + return input; +} + +// Validate tool data against existing tools (for duplicates and consistency) +async function validateToolData(tool: any, action: 'add' | 'edit'): Promise<{ valid: boolean; errors: string[] }> { + const errors: string[] = []; + + try { + // Import existing tools data for validation + const { getToolsData } = await import('../../../utils/dataService.js'); + const existingData = await getToolsData(); + + if (action === 'add') { + // Check for duplicate names + const existingTool = existingData.tools.find(t => + t.name.toLowerCase() === tool.name.toLowerCase() + ); + if (existingTool) { + errors.push(`A tool named "${tool.name}" already exists`); + } + } else if (action === 'edit') { + // Check that tool exists for editing + const existingTool = existingData.tools.find(t => t.name === tool.name); + if (!existingTool) { + errors.push(`Tool "${tool.name}" not found for editing`); + } + } + + // Validate domains + const validDomains = new Set(existingData.domains.map(d => d.id)); + const invalidDomains = tool.domains.filter((d: string) => !validDomains.has(d)); + if (invalidDomains.length > 0) { + errors.push(`Invalid domains: ${invalidDomains.join(', ')}`); + } + + // Validate phases + const validPhases = new Set([ + ...existingData.phases.map(p => p.id), + ...(existingData['domain-agnostic-software'] || []).map(s => s.id) + ]); + const invalidPhases = tool.phases.filter((p: string) => !validPhases.has(p)); + if (invalidPhases.length > 0) { + errors.push(`Invalid phases: ${invalidPhases.join(', ')}`); + } + + // Type-specific validations + if (tool.type === 'concept') { + if (tool.platforms && tool.platforms.length > 0) { + errors.push('Concepts should not have platforms'); + } + if (tool.license && tool.license !== null) { + errors.push('Concepts should not have license information'); + } + } else if (tool.type === 'method') { + if (tool.platforms && tool.platforms.length > 0) { + errors.push('Methods should not have platforms'); + } + if (tool.license && tool.license !== null) { + errors.push('Methods should not have license information'); + } + } else if (tool.type === 'software') { + if (!tool.platforms || tool.platforms.length === 0) { + errors.push('Software tools must specify at least one platform'); + } + if (!tool.license) { + errors.push('Software tools must specify a license'); + } + } + + // Validate related concepts exist + if (tool.related_concepts && tool.related_concepts.length > 0) { + const existingConcepts = new Set( + existingData.tools.filter(t => t.type === 'concept').map(t => 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 }; + + } catch (error) { + console.error('Tool validation failed:', error); + errors.push('Validation failed due to system error'); + return { valid: false, errors }; + } +} + +export const POST: APIRoute = async ({ request }) => { + try { + // Check if authentication is required + const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false'; + let userId = 'anonymous'; + let userEmail = 'anonymous@example.com'; + + if (authRequired) { + // Authentication check + const sessionToken = getSessionFromRequest(request); + if (!sessionToken) { + return new Response(JSON.stringify({ + success: false, + error: 'Authentication required' + }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + const session = await verifySession(sessionToken); + if (!session) { + return new Response(JSON.stringify({ + success: false, + error: 'Invalid session' + }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + userId = session.userId; + // In a real implementation, you might want to fetch user email from session or OIDC + userEmail = `${userId}@cc24.dev`; + } + + // Rate limiting + if (!checkRateLimit(userId)) { + return new Response(JSON.stringify({ + success: false, + error: 'Rate limit exceeded. Please wait before submitting another contribution.' + }), { + status: 429, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Parse and sanitize request body + let body; + try { + const rawBody = await request.text(); + if (!rawBody.trim()) { + throw new Error('Empty request body'); + } + body = JSON.parse(rawBody); + } catch (error) { + return new Response(JSON.stringify({ + success: false, + error: 'Invalid JSON in request body' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Sanitize input + const sanitizedBody = sanitizeInput(body); + + // Validate request structure + let validatedData; + try { + validatedData = ContributionRequestSchema.parse(sanitizedBody); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors.map(err => + `${err.path.join('.')}: ${err.message}` + ); + return new Response(JSON.stringify({ + success: false, + error: 'Validation failed', + details: errorMessages + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + return new Response(JSON.stringify({ + success: false, + error: 'Invalid request data' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Additional tool-specific validation + const toolValidation = await validateToolData(validatedData.tool, validatedData.action); + if (!toolValidation.valid) { + return new Response(JSON.stringify({ + success: false, + error: 'Tool validation failed', + details: toolValidation.errors + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Prepare contribution data + const contributionData: ContributionData = { + type: validatedData.action, + tool: validatedData.tool, + metadata: { + submitter: userEmail, + reason: validatedData.metadata.reason + } + }; + + // Submit contribution via Git + const gitManager = new GitContributionManager(); + const result = await gitManager.submitContribution(contributionData); + + if (result.success) { + // Log successful contribution + console.log(`[CONTRIBUTION] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail} - PR: ${result.prUrl}`); + + return new Response(JSON.stringify({ + success: true, + message: result.message, + prUrl: result.prUrl, + branchName: result.branchName + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } else { + // Log failed contribution + console.error(`[CONTRIBUTION FAILED] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail}: ${result.message}`); + + return new Response(JSON.stringify({ + success: false, + error: result.message + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + } catch (error) { + console.error('Contribution API error:', error); + + return new Response(JSON.stringify({ + success: false, + error: 'Internal server error' + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; + +// Health check endpoint +export const GET: APIRoute = async ({ request }) => { + try { + // Simple authentication check for health endpoint + const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false'; + + if (authRequired) { + const sessionToken = getSessionFromRequest(request); + if (!sessionToken) { + return new Response(JSON.stringify({ error: 'Authentication required' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + const session = await verifySession(sessionToken); + if (!session) { + return new Response(JSON.stringify({ error: 'Invalid session' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + } + + const gitManager = new GitContributionManager(); + const health = await gitManager.checkHealth(); + + return new Response(JSON.stringify(health), { + status: health.healthy ? 200 : 503, + headers: { 'Content-Type': 'application/json' } + }); + + } catch (error) { + console.error('Health check error:', error); + return new Response(JSON.stringify({ + healthy: false, + issues: ['Health check failed'] + }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } +}; \ No newline at end of file diff --git a/src/pages/contribute/tool.astro b/src/pages/contribute/tool.astro new file mode 100644 index 0000000..3c4ac11 --- /dev/null +++ b/src/pages/contribute/tool.astro @@ -0,0 +1,732 @@ +--- +// src/pages/contribute/tool.astro +import BaseLayout from '../../layouts/BaseLayout.astro'; +import { getAuthContext, requireAuth } from '../../utils/serverAuth.js'; +import { getToolsData } from '../../utils/dataService.js'; + +// Check authentication +const authContext = await getAuthContext(Astro); +const authRedirect = requireAuth(authContext, Astro.url.toString()); +if (authRedirect) return authRedirect; + +// Load existing data for validation and editing +const data = await getToolsData(); +const domains = data.domains; +const phases = data.phases; +const domainAgnosticSoftware = data['domain-agnostic-software'] || []; +const existingTools = data.tools; + +// Check if this is an edit operation +const editToolName = Astro.url.searchParams.get('edit'); +const editTool = editToolName ? existingTools.find(tool => tool.name === editToolName) : null; +const isEdit = !!editTool; + +const title = isEdit ? `Edit ${editTool?.name}` : 'Contribute New Tool'; +--- + + +
+ +
+

+ + + + + + + + {isEdit ? `Edit Tool: ${editTool?.name}` : 'Contribute New Tool'} +

+

+ {isEdit + ? 'Update the information for this tool, method, or concept. Your changes will be submitted as a pull request for review.' + : 'Submit a new tool, method, or concept to the CC24-Guide database. Your contribution will be reviewed before being added.' + } +

+
+ + +
+
+
+ + +
+ + +
+ Software: Applications and tools • Method: Procedures and methodologies • Concept: Fundamental knowledge +
+
+ + +
+ +
+ + + +
+ + +
+ + +
+ Choose an emoji that represents your tool/method/concept. Leave blank if unsure. +
+
+
+ + +
+ + +
+
Be specific about functionality, use cases, and key features.
+
0/1000
+
+ +
+ + +
+ +
+ + +
Homepage, documentation, or primary resource link
+ +
+ + + +
+ + +
+ +
+ + +
Hold Ctrl/Cmd to select multiple. Leave empty for domain-agnostic.
+
+ + +
+ + +
Select applicable investigation phases
+
+
+ + + + + +
+
+ + +
+ +
+ +
+ +
+
+
+ + +
+ + +
+ Add relevant tags separated by commas. Use lowercase with hyphens for multi-word tags. +
+
+ + + + + +
+ + +
+
Help reviewers understand your contribution
+
0/500
+
+
+ + +
+
+ + +
+
+# YAML preview will appear here
+            
+
+ + +
+ Cancel + +
+
+
+
+ + + + +
+
+ + + + \ No newline at end of file diff --git a/src/styles/global.css b/src/styles/global.css index c69d515..5912017 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -469,6 +469,10 @@ input[type="checkbox"] { background: linear-gradient(to right, transparent 0%, var(--color-method-bg) 70%); } +.card-concept .tool-tags-container::after { + background: linear-gradient(to right, transparent 0%, var(--color-method-bg) 70%); +} + .tool-card-buttons { margin-top: auto; flex-shrink: 0; diff --git a/src/utils/gitContributions.ts b/src/utils/gitContributions.ts new file mode 100644 index 0000000..ee13ab3 --- /dev/null +++ b/src/utils/gitContributions.ts @@ -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(); + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; \ No newline at end of file