first draft contributions
This commit is contained in:
		
							parent
							
								
									9798837806
								
							
						
					
					
						commit
						043a2d32ac
					
				
							
								
								
									
										393
									
								
								src/pages/api/contribute/tool.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										393
									
								
								src/pages/api/contribute/tool.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<string, { count: number; resetTime: number }>();
 | 
			
		||||
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' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										732
									
								
								src/pages/contribute/tool.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										732
									
								
								src/pages/contribute/tool.astro
									
									
									
									
									
										Normal file
									
								
							@ -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';
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<BaseLayout title={title} description="Contribute tools, methods, and concepts to the CC24-Guide database">
 | 
			
		||||
  <section style="padding: 2rem 0;">
 | 
			
		||||
    <!-- Header -->
 | 
			
		||||
    <div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem; border: 1px solid var(--color-border);">
 | 
			
		||||
      <h1 style="margin-bottom: 1rem; font-size: 2rem;">
 | 
			
		||||
        <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.75rem; vertical-align: middle;">
 | 
			
		||||
          <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
 | 
			
		||||
          <polyline points="14 2 14 8 20 8"/>
 | 
			
		||||
          <line x1="16" y1="13" x2="8" y2="13"/>
 | 
			
		||||
          <line x1="16" y1="17" x2="8" y2="17"/>
 | 
			
		||||
          <polyline points="10 9 9 9 8 9"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
        {isEdit ? `Edit Tool: ${editTool?.name}` : 'Contribute New Tool'}
 | 
			
		||||
      </h1>
 | 
			
		||||
      <p style="margin: 0; opacity: 0.9; line-height: 1.5;">
 | 
			
		||||
        {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.'
 | 
			
		||||
        }
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Form Container -->
 | 
			
		||||
    <div style="max-width: 800px; margin: 0 auto;">
 | 
			
		||||
      <div class="card" style="padding: 2rem;">
 | 
			
		||||
        <form id="contribution-form" style="display: flex; flex-direction: column; gap: 1.5rem;">
 | 
			
		||||
          
 | 
			
		||||
          <!-- Tool Type Selection -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <label for="tool-type" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
              Tool Type <span style="color: var(--color-error);">*</span>
 | 
			
		||||
            </label>
 | 
			
		||||
            <select id="tool-type" name="type" required style="max-width: 300px;">
 | 
			
		||||
              <option value="">Select type...</option>
 | 
			
		||||
              <option value="software" selected={editTool?.type === 'software'}>Software</option>
 | 
			
		||||
              <option value="method" selected={editTool?.type === 'method'}>Method</option>
 | 
			
		||||
              <option value="concept" selected={editTool?.type === 'concept'}>Concept</option>
 | 
			
		||||
            </select>
 | 
			
		||||
            <div class="field-help" style="font-size: 0.8125rem; color: var(--color-text-secondary); margin-top: 0.25rem;">
 | 
			
		||||
              Software: Applications and tools • Method: Procedures and methodologies • Concept: Fundamental knowledge
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Basic Information -->
 | 
			
		||||
          <div style="display: grid; grid-template-columns: 1fr; gap: 1rem;">
 | 
			
		||||
            <!-- Tool Name -->
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="tool-name" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                Name <span style="color: var(--color-error);">*</span>
 | 
			
		||||
              </label>
 | 
			
		||||
              <input type="text" id="tool-name" name="name" required maxlength="100"
 | 
			
		||||
                     value={editTool?.name || ''} 
 | 
			
		||||
                     placeholder="e.g., Autopsy, Live Response Methodology, Regular Expressions" />
 | 
			
		||||
              <div id="name-error" class="field-error" style="display: none;"></div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <!-- Icon (Emoji) -->
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="tool-icon" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                Icon (Emoji)
 | 
			
		||||
              </label>
 | 
			
		||||
              <input type="text" id="tool-icon" name="icon" maxlength="10"
 | 
			
		||||
                     value={editTool?.icon || ''}
 | 
			
		||||
                     placeholder="📦 🔧 📋 (optional, single emoji recommended)" />
 | 
			
		||||
              <div class="field-help">
 | 
			
		||||
                Choose an emoji that represents your tool/method/concept. Leave blank if unsure.
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Description -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <label for="tool-description" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
              Description <span style="color: var(--color-error);">*</span>
 | 
			
		||||
            </label>
 | 
			
		||||
            <textarea id="tool-description" name="description" required 
 | 
			
		||||
                      rows="4" minlength="10" maxlength="1000"
 | 
			
		||||
                      placeholder="Provide a clear, concise description of what this tool/method/concept is and what it does...">{editTool?.description || ''}</textarea>
 | 
			
		||||
            <div style="display: flex; justify-content: space-between; margin-top: 0.25rem;">
 | 
			
		||||
              <div class="field-help">Be specific about functionality, use cases, and key features.</div>
 | 
			
		||||
              <div id="description-count" style="font-size: 0.75rem; color: var(--color-text-secondary);">0/1000</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div id="description-error" class="field-error" style="display: none;"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- URLs -->
 | 
			
		||||
          <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
 | 
			
		||||
            <!-- Main URL -->
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="tool-url" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                Main URL <span style="color: var(--color-error);">*</span>
 | 
			
		||||
              </label>
 | 
			
		||||
              <input type="url" id="tool-url" name="url" required
 | 
			
		||||
                     value={editTool?.url || ''}
 | 
			
		||||
                     placeholder="https://example.com" />
 | 
			
		||||
              <div class="field-help">Homepage, documentation, or primary resource link</div>
 | 
			
		||||
              <div id="url-error" class="field-error" style="display: none;"></div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <!-- Project URL (CC24 Server) -->
 | 
			
		||||
            <div id="project-url-field" style="display: none;">
 | 
			
		||||
              <label for="project-url" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                CC24 Server URL
 | 
			
		||||
              </label>
 | 
			
		||||
              <input type="url" id="project-url" name="projectUrl"
 | 
			
		||||
                     value={editTool?.projectUrl || ''}
 | 
			
		||||
                     placeholder="https://tool.cc24.dev" />
 | 
			
		||||
              <div class="field-help">Internal CC24 server URL (if hosted)</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Categories -->
 | 
			
		||||
          <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
 | 
			
		||||
            <!-- Domains -->
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="tool-domains" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                Forensic Domains
 | 
			
		||||
              </label>
 | 
			
		||||
              <select id="tool-domains" name="domains" multiple size="4">
 | 
			
		||||
                {domains.map(domain => (
 | 
			
		||||
                  <option value={domain.id} 
 | 
			
		||||
                          selected={editTool?.domains?.includes(domain.id)}>
 | 
			
		||||
                    {domain.name}
 | 
			
		||||
                  </option>
 | 
			
		||||
                ))}
 | 
			
		||||
              </select>
 | 
			
		||||
              <div class="field-help">Hold Ctrl/Cmd to select multiple. Leave empty for domain-agnostic.</div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <!-- Phases -->
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="tool-phases" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                Investigation Phases
 | 
			
		||||
              </label>
 | 
			
		||||
              <select id="tool-phases" name="phases" multiple size="4">
 | 
			
		||||
                {phases.map(phase => (
 | 
			
		||||
                  <option value={phase.id} 
 | 
			
		||||
                          selected={editTool?.phases?.includes(phase.id)}>
 | 
			
		||||
                    {phase.name}
 | 
			
		||||
                  </option>
 | 
			
		||||
                ))}
 | 
			
		||||
                {domainAgnosticSoftware.map(section => (
 | 
			
		||||
                  <option value={section.id} 
 | 
			
		||||
                          selected={editTool?.phases?.includes(section.id)}>
 | 
			
		||||
                    {section.name}
 | 
			
		||||
                  </option>
 | 
			
		||||
                ))}
 | 
			
		||||
              </select>
 | 
			
		||||
              <div class="field-help">Select applicable investigation phases</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Software-Specific Fields -->
 | 
			
		||||
          <div id="software-fields" style="display: none;">
 | 
			
		||||
            <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
 | 
			
		||||
              <!-- Platforms -->
 | 
			
		||||
              <div>
 | 
			
		||||
                <label for="tool-platforms" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                  Platforms <span id="platforms-required" style="color: var(--color-error);">*</span>
 | 
			
		||||
                </label>
 | 
			
		||||
                <div id="platforms-checkboxes" style="display: grid; gap: 0.25rem; font-size: 0.875rem;">
 | 
			
		||||
                  {['Windows', 'macOS', 'Linux', 'Web', 'Mobile', 'Cross-platform'].map(platform => (
 | 
			
		||||
                    <label class="checkbox-wrapper" style="margin-bottom: 0.25rem;">
 | 
			
		||||
                      <input type="checkbox" name="platforms" value={platform} 
 | 
			
		||||
                             checked={editTool?.platforms?.includes(platform)} />
 | 
			
		||||
                      <span>{platform}</span>
 | 
			
		||||
                    </label>
 | 
			
		||||
                  ))}
 | 
			
		||||
                </div>
 | 
			
		||||
                <div id="platforms-error" class="field-error" style="display: none;"></div>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <!-- License -->
 | 
			
		||||
              <div>
 | 
			
		||||
                <label for="tool-license" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                  License <span id="license-required" style="color: var(--color-error);">*</span>
 | 
			
		||||
                </label>
 | 
			
		||||
                <input type="text" id="tool-license" name="license" list="license-options"
 | 
			
		||||
                       value={editTool?.license || ''}
 | 
			
		||||
                       placeholder="e.g., MIT, Apache 2.0, GPL v3, Proprietary" />
 | 
			
		||||
                <datalist id="license-options">
 | 
			
		||||
                  <option value="MIT" />
 | 
			
		||||
                  <option value="Apache 2.0" />
 | 
			
		||||
                  <option value="GPL v3" />
 | 
			
		||||
                  <option value="GPL v2" />
 | 
			
		||||
                  <option value="LGPL-3.0" />
 | 
			
		||||
                  <option value="LGPL-2.1" />
 | 
			
		||||
                  <option value="BSD-3-Clause" />
 | 
			
		||||
                  <option value="BSD-2-Clause" />
 | 
			
		||||
                  <option value="ISC" />
 | 
			
		||||
                  <option value="Mozilla Public License 2.0" />
 | 
			
		||||
                  <option value="Open Source" />
 | 
			
		||||
                  <option value="Proprietary" />
 | 
			
		||||
                  <option value="Freeware" />
 | 
			
		||||
                  <option value="Commercial" />
 | 
			
		||||
                  <option value="Dual License" />
 | 
			
		||||
                </datalist>
 | 
			
		||||
                <div class="field-help">Type any license or select from suggestions</div>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <!-- Access Type -->
 | 
			
		||||
              <div style="grid-column: 1 / -1;">
 | 
			
		||||
                <label for="access-type" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                  Access Type
 | 
			
		||||
                </label>
 | 
			
		||||
                <select id="access-type" name="accessType">
 | 
			
		||||
                  <option value="">Select access type...</option>
 | 
			
		||||
                  <option value="download" selected={editTool?.accessType === 'download'}>Download</option>
 | 
			
		||||
                  <option value="web" selected={editTool?.accessType === 'web'}>Web Application</option>
 | 
			
		||||
                  <option value="api" selected={editTool?.accessType === 'api'}>API</option>
 | 
			
		||||
                  <option value="cli" selected={editTool?.accessType === 'cli'}>Command Line</option>
 | 
			
		||||
                  <option value="service" selected={editTool?.accessType === 'service'}>Service</option>
 | 
			
		||||
                </select>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Skill Level and Additional Options -->
 | 
			
		||||
          <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="skill-level" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                Skill Level <span style="color: var(--color-error);">*</span>
 | 
			
		||||
              </label>
 | 
			
		||||
              <select id="skill-level" name="skillLevel" required>
 | 
			
		||||
                <option value="">Select skill level...</option>
 | 
			
		||||
                <option value="novice" selected={editTool?.skillLevel === 'novice'}>Novice</option>
 | 
			
		||||
                <option value="beginner" selected={editTool?.skillLevel === 'beginner'}>Beginner</option>
 | 
			
		||||
                <option value="intermediate" selected={editTool?.skillLevel === 'intermediate'}>Intermediate</option>
 | 
			
		||||
                <option value="advanced" selected={editTool?.skillLevel === 'advanced'}>Advanced</option>
 | 
			
		||||
                <option value="expert" selected={editTool?.skillLevel === 'expert'}>Expert</option>
 | 
			
		||||
              </select>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div>
 | 
			
		||||
              <label style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                Additional Options
 | 
			
		||||
              </label>
 | 
			
		||||
              <div style="display: flex; flex-direction: column; gap: 0.5rem;">
 | 
			
		||||
                <label class="checkbox-wrapper">
 | 
			
		||||
                  <input type="checkbox" id="has-knowledgebase" name="knowledgebase" 
 | 
			
		||||
                         checked={editTool?.knowledgebase} />
 | 
			
		||||
                  <span>Has knowledgebase article</span>
 | 
			
		||||
                </label>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Tags -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <label for="tool-tags" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
              Tags
 | 
			
		||||
            </label>
 | 
			
		||||
            <input type="text" id="tool-tags" name="tags" 
 | 
			
		||||
                   value={editTool?.tags?.join(', ') || ''}
 | 
			
		||||
                   placeholder="gui, forensics, network-analysis, mobile (comma-separated)" />
 | 
			
		||||
            <div class="field-help">
 | 
			
		||||
              Add relevant tags separated by commas. Use lowercase with hyphens for multi-word tags.
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Related Concepts (for software/methods) -->
 | 
			
		||||
          <div id="related-concepts-field" style="display: none;">
 | 
			
		||||
            <label for="related-concepts" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
              Related Concepts
 | 
			
		||||
            </label>
 | 
			
		||||
            <select id="related-concepts" name="relatedConcepts" multiple size="3">
 | 
			
		||||
              {existingTools.filter(tool => tool.type === 'concept').map(concept => (
 | 
			
		||||
                <option value={concept.name} 
 | 
			
		||||
                        selected={editTool?.related_concepts?.includes(concept.name)}>
 | 
			
		||||
                  {concept.name}
 | 
			
		||||
                </option>
 | 
			
		||||
              ))}
 | 
			
		||||
            </select>
 | 
			
		||||
            <div class="field-help">
 | 
			
		||||
              Select concepts that users should understand when using this tool/method
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Contribution Reason -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <label for="contribution-reason" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
              Reason for Contribution (Optional)
 | 
			
		||||
            </label>
 | 
			
		||||
            <textarea id="contribution-reason" name="reason" 
 | 
			
		||||
                      rows="2" maxlength="500"
 | 
			
		||||
                      placeholder="Why are you adding/updating this tool? Any additional context for reviewers..."></textarea>
 | 
			
		||||
            <div style="display: flex; justify-content: space-between; margin-top: 0.25rem;">
 | 
			
		||||
              <div class="field-help">Help reviewers understand your contribution</div>
 | 
			
		||||
              <div id="reason-count" style="font-size: 0.75rem; color: var(--color-text-secondary);">0/500</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- YAML Preview -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <div style="display: flex; justify-content: between; align-items: center; margin-bottom: 0.5rem;">
 | 
			
		||||
              <label style="font-weight: 600;">YAML Preview</label>
 | 
			
		||||
              <button type="button" id="refresh-preview" class="btn btn-secondary" style="padding: 0.25rem 0.75rem; font-size: 0.8125rem;">
 | 
			
		||||
                Refresh Preview
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
            <pre id="yaml-preview" style="background-color: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 1rem; font-size: 0.8125rem; overflow-x: auto; max-height: 300px;">
 | 
			
		||||
# YAML preview will appear here
 | 
			
		||||
            </pre>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Submit Buttons -->
 | 
			
		||||
          <div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1rem;">
 | 
			
		||||
            <a href="/" class="btn btn-secondary">Cancel</a>
 | 
			
		||||
            <button type="submit" id="submit-btn" class="btn btn-primary">
 | 
			
		||||
              <span id="submit-text">{isEdit ? 'Update Tool' : 'Submit Contribution'}</span>
 | 
			
		||||
              <svg id="submit-spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: none; margin-left: 0.5rem; animation: pulse 2s ease-in-out infinite;">
 | 
			
		||||
                <path d="M21 12a9 9 0 0 0-9-9 7 7 0 0 0-7 7"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </form>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Success Modal -->
 | 
			
		||||
    <div id="success-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
 | 
			
		||||
      <div class="card" style="max-width: 500px; width: 90%; margin: 2rem;">
 | 
			
		||||
        <div style="text-align: center;">
 | 
			
		||||
          <div style="background-color: var(--color-accent); color: white; width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem;">
 | 
			
		||||
            <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
              <polyline points="20,6 9,17 4,12"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
          </div>
 | 
			
		||||
          <h3 style="margin-bottom: 1rem;">Contribution Submitted!</h3>
 | 
			
		||||
          <p id="success-message" style="margin-bottom: 1.5rem; line-height: 1.5;"></p>
 | 
			
		||||
          <div style="display: flex; gap: 1rem; justify-content: center;">
 | 
			
		||||
            <a id="pr-link" href="#" target="_blank" class="btn btn-primary" style="display: none;">View Pull Request</a>
 | 
			
		||||
            <a href="/" class="btn btn-secondary">Back to Home</a>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
  </section>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
  .field-error {
 | 
			
		||||
    color: var(--color-error);
 | 
			
		||||
    font-size: 0.8125rem;
 | 
			
		||||
    margin-top: 0.25rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .field-help {
 | 
			
		||||
    font-size: 0.8125rem;
 | 
			
		||||
    color: var(--color-text-secondary);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input:invalid, textarea:invalid, select:invalid {
 | 
			
		||||
    border-color: var(--color-error);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input:valid, textarea:valid, select:valid {
 | 
			
		||||
    border-color: var(--color-accent);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #yaml-preview {
 | 
			
		||||
    font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 | 
			
		||||
    line-height: 1.4;
 | 
			
		||||
    white-space: pre-wrap;
 | 
			
		||||
    word-break: break-all;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  select[multiple] {
 | 
			
		||||
    min-height: auto;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /* Responsive adjustments */
 | 
			
		||||
  @media (width <= 768px) {
 | 
			
		||||
    div[style*="grid-template-columns: 1fr 1fr"] {
 | 
			
		||||
      grid-template-columns: 1fr !important;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
<script define:vars={{ 
 | 
			
		||||
  isEdit, 
 | 
			
		||||
  editTool: editTool || null, 
 | 
			
		||||
  domains, 
 | 
			
		||||
  phases, 
 | 
			
		||||
  domainAgnosticSoftware,
 | 
			
		||||
  existingConcepts: existingTools.filter(t => t.type === 'concept')
 | 
			
		||||
}}>
 | 
			
		||||
document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
  const form = document.getElementById('contribution-form');
 | 
			
		||||
  const typeSelect = document.getElementById('tool-type');
 | 
			
		||||
  const submitBtn = document.getElementById('submit-btn');
 | 
			
		||||
  const submitText = document.getElementById('submit-text');
 | 
			
		||||
  const submitSpinner = document.getElementById('submit-spinner');
 | 
			
		||||
  const yamlPreview = document.getElementById('yaml-preview');
 | 
			
		||||
  const refreshPreviewBtn = document.getElementById('refresh-preview');
 | 
			
		||||
  const successModal = document.getElementById('success-modal');
 | 
			
		||||
 | 
			
		||||
  // Form elements
 | 
			
		||||
  const nameInput = document.getElementById('tool-name');
 | 
			
		||||
  const descriptionTextarea = document.getElementById('tool-description');
 | 
			
		||||
  const reasonTextarea = document.getElementById('contribution-reason');
 | 
			
		||||
  
 | 
			
		||||
  // Field groups
 | 
			
		||||
  const softwareFields = document.getElementById('software-fields');
 | 
			
		||||
  const projectUrlField = document.getElementById('project-url-field');
 | 
			
		||||
  const relatedConceptsField = document.getElementById('related-concepts-field');
 | 
			
		||||
  
 | 
			
		||||
  // Required indicators
 | 
			
		||||
  const platformsRequired = document.getElementById('platforms-required');
 | 
			
		||||
  const licenseRequired = document.getElementById('license-required');
 | 
			
		||||
 | 
			
		||||
  // Update character counters
 | 
			
		||||
  function updateCharacterCounter(textarea, countElement, maxLength) {
 | 
			
		||||
    const count = textarea.value.length;
 | 
			
		||||
    countElement.textContent = `${count}/${maxLength}`;
 | 
			
		||||
    countElement.style.color = count > maxLength * 0.9 ? 'var(--color-warning)' : 'var(--color-text-secondary)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Set up character counters
 | 
			
		||||
  const descriptionCount = document.getElementById('description-count');
 | 
			
		||||
  const reasonCount = document.getElementById('reason-count');
 | 
			
		||||
  
 | 
			
		||||
  descriptionTextarea.addEventListener('input', () => updateCharacterCounter(descriptionTextarea, descriptionCount, 1000));
 | 
			
		||||
  reasonTextarea.addEventListener('input', () => updateCharacterCounter(reasonTextarea, reasonCount, 500));
 | 
			
		||||
  
 | 
			
		||||
  // Initial counter update
 | 
			
		||||
  updateCharacterCounter(descriptionTextarea, descriptionCount, 1000);
 | 
			
		||||
  updateCharacterCounter(reasonTextarea, reasonCount, 500);
 | 
			
		||||
 | 
			
		||||
  // Handle type-specific field visibility
 | 
			
		||||
  function updateFieldVisibility() {
 | 
			
		||||
    const selectedType = typeSelect.value;
 | 
			
		||||
    
 | 
			
		||||
    // Hide all type-specific fields
 | 
			
		||||
    softwareFields.style.display = 'none';
 | 
			
		||||
    relatedConceptsField.style.display = 'none';
 | 
			
		||||
    
 | 
			
		||||
    // Show project URL for software only
 | 
			
		||||
    projectUrlField.style.display = selectedType === 'software' ? 'block' : 'none';
 | 
			
		||||
    
 | 
			
		||||
    // Handle required fields
 | 
			
		||||
    const platformsCheckboxes = document.querySelectorAll('input[name="platforms"]');
 | 
			
		||||
    const licenseSelect = document.getElementById('tool-license');
 | 
			
		||||
    
 | 
			
		||||
    if (selectedType === 'software') {
 | 
			
		||||
      // Show software-specific fields
 | 
			
		||||
      softwareFields.style.display = 'block';
 | 
			
		||||
      relatedConceptsField.style.display = 'block';
 | 
			
		||||
      
 | 
			
		||||
      // Make platforms and license required
 | 
			
		||||
      platformsRequired.style.display = 'inline';
 | 
			
		||||
      licenseRequired.style.display = 'inline';
 | 
			
		||||
      platformsCheckboxes.forEach(cb => cb.setAttribute('required', 'required'));
 | 
			
		||||
      licenseSelect.setAttribute('required', 'required');
 | 
			
		||||
      
 | 
			
		||||
    } else {
 | 
			
		||||
      // Hide required indicators and remove requirements
 | 
			
		||||
      platformsRequired.style.display = 'none';
 | 
			
		||||
      licenseRequired.style.display = 'none';
 | 
			
		||||
      platformsCheckboxes.forEach(cb => cb.removeAttribute('required'));
 | 
			
		||||
      licenseSelect.removeAttribute('required');
 | 
			
		||||
      
 | 
			
		||||
      // Show related concepts for methods
 | 
			
		||||
      if (selectedType === 'method') {
 | 
			
		||||
        relatedConceptsField.style.display = 'block';
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Update YAML preview
 | 
			
		||||
    updateYAMLPreview();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Generate YAML preview
 | 
			
		||||
  function updateYAMLPreview() {
 | 
			
		||||
    try {
 | 
			
		||||
      const formData = new FormData(form);
 | 
			
		||||
      const toolData = {
 | 
			
		||||
        name: formData.get('name') || '',
 | 
			
		||||
        icon: formData.get('icon') || null,
 | 
			
		||||
        type: formData.get('type') || '',
 | 
			
		||||
        description: formData.get('description') || '',
 | 
			
		||||
        domains: formData.getAll('domains') || [],
 | 
			
		||||
        phases: formData.getAll('phases') || [],
 | 
			
		||||
        skillLevel: formData.get('skillLevel') || '',
 | 
			
		||||
        url: formData.get('url') || ''
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // Add type-specific fields
 | 
			
		||||
      if (toolData.type === 'software') {
 | 
			
		||||
        toolData.platforms = formData.getAll('platforms') || [];
 | 
			
		||||
        toolData.license = formData.get('license')?.trim() || null;
 | 
			
		||||
        toolData.accessType = formData.get('accessType') || null;
 | 
			
		||||
        toolData.projectUrl = formData.get('projectUrl') || null;
 | 
			
		||||
      } else {
 | 
			
		||||
        toolData.platforms = [];
 | 
			
		||||
        toolData.license = null;
 | 
			
		||||
        toolData.accessType = null;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Add optional fields
 | 
			
		||||
      toolData.knowledgebase = formData.has('knowledgebase') || null;
 | 
			
		||||
      
 | 
			
		||||
      // Handle tags
 | 
			
		||||
      const tagsValue = formData.get('tags');
 | 
			
		||||
      toolData.tags = tagsValue ? tagsValue.split(',').map(tag => tag.trim()).filter(Boolean) : [];
 | 
			
		||||
      
 | 
			
		||||
      // Handle related concepts
 | 
			
		||||
      if (toolData.type !== 'concept') {
 | 
			
		||||
        toolData.related_concepts = formData.getAll('relatedConcepts') || null;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Convert to YAML-like format for preview
 | 
			
		||||
      let yamlContent = `- name: "${toolData.name}"\n`;
 | 
			
		||||
      if (toolData.icon) yamlContent += `  icon: "${toolData.icon}"\n`;
 | 
			
		||||
      yamlContent += `  type: ${toolData.type}\n`;
 | 
			
		||||
      yamlContent += `  description: >\n    ${toolData.description}\n`;
 | 
			
		||||
      if (toolData.domains.length > 0) {
 | 
			
		||||
        yamlContent += `  domains:\n${toolData.domains.map(d => `    - ${d}`).join('\n')}\n`;
 | 
			
		||||
      }
 | 
			
		||||
      if (toolData.phases.length > 0) {
 | 
			
		||||
        yamlContent += `  phases:\n${toolData.phases.map(p => `    - ${p}`).join('\n')}\n`;
 | 
			
		||||
      }
 | 
			
		||||
      if (toolData.platforms.length > 0) {
 | 
			
		||||
        yamlContent += `  platforms:\n${toolData.platforms.map(p => `    - ${p}`).join('\n')}\n`;
 | 
			
		||||
      }
 | 
			
		||||
      yamlContent += `  skillLevel: ${toolData.skillLevel}\n`;
 | 
			
		||||
      if (toolData.accessType) yamlContent += `  accessType: ${toolData.accessType}\n`;
 | 
			
		||||
      yamlContent += `  url: ${toolData.url}\n`;
 | 
			
		||||
      if (toolData.projectUrl) yamlContent += `  projectUrl: ${toolData.projectUrl}\n`;
 | 
			
		||||
      if (toolData.license) yamlContent += `  license: ${toolData.license}\n`;
 | 
			
		||||
      if (toolData.knowledgebase) yamlContent += `  knowledgebase: ${toolData.knowledgebase}\n`;
 | 
			
		||||
      if (toolData.related_concepts && toolData.related_concepts.length > 0) {
 | 
			
		||||
        yamlContent += `  related_concepts:\n${toolData.related_concepts.map(c => `    - ${c}`).join('\n')}\n`;
 | 
			
		||||
      }
 | 
			
		||||
      if (toolData.tags.length > 0) {
 | 
			
		||||
        yamlContent += `  tags:\n${toolData.tags.map(t => `    - ${t}`).join('\n')}\n`;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      yamlPreview.textContent = yamlContent;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      yamlPreview.textContent = `# Error generating preview: ${error.message}`;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Form validation
 | 
			
		||||
  function validateForm() {
 | 
			
		||||
    const errors = [];
 | 
			
		||||
    
 | 
			
		||||
    // Basic validation
 | 
			
		||||
    if (!nameInput.value.trim()) errors.push('Name is required');
 | 
			
		||||
    if (!descriptionTextarea.value.trim() || descriptionTextarea.value.length < 10) {
 | 
			
		||||
      errors.push('Description must be at least 10 characters');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const selectedType = typeSelect.value;
 | 
			
		||||
    
 | 
			
		||||
    // Type-specific validation
 | 
			
		||||
    if (selectedType === 'software') {
 | 
			
		||||
      const platforms = new FormData(form).getAll('platforms');
 | 
			
		||||
      if (platforms.length === 0) {
 | 
			
		||||
        errors.push('At least one platform is required for software');
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      if (!document.getElementById('tool-license').value.trim()) {
 | 
			
		||||
        errors.push('License is required for software');
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return errors;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Event listeners
 | 
			
		||||
  typeSelect.addEventListener('change', updateFieldVisibility);
 | 
			
		||||
  refreshPreviewBtn.addEventListener('click', updateYAMLPreview);
 | 
			
		||||
  
 | 
			
		||||
  // Update preview on form changes
 | 
			
		||||
  form.addEventListener('input', debounce(updateYAMLPreview, 500));
 | 
			
		||||
  form.addEventListener('change', updateYAMLPreview);
 | 
			
		||||
 | 
			
		||||
  // Form submission
 | 
			
		||||
  form.addEventListener('submit', async (e) => {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    
 | 
			
		||||
    const errors = validateForm();
 | 
			
		||||
    if (errors.length > 0) {
 | 
			
		||||
      alert('Please fix the following errors:\n' + errors.join('\n'));
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Show loading state
 | 
			
		||||
    submitBtn.disabled = true;
 | 
			
		||||
    submitText.textContent = isEdit ? 'Updating...' : 'Submitting...';
 | 
			
		||||
    submitSpinner.style.display = 'inline-block';
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const formData = new FormData(form);
 | 
			
		||||
      
 | 
			
		||||
      // Prepare submission data
 | 
			
		||||
      const submissionData = {
 | 
			
		||||
        action: isEdit ? 'edit' : 'add',
 | 
			
		||||
        tool: {
 | 
			
		||||
          name: formData.get('name'),
 | 
			
		||||
          icon: formData.get('icon') || null,
 | 
			
		||||
          type: formData.get('type'),
 | 
			
		||||
          description: formData.get('description'),
 | 
			
		||||
          domains: formData.getAll('domains'),
 | 
			
		||||
          phases: formData.getAll('phases'),
 | 
			
		||||
          skillLevel: formData.get('skillLevel'),
 | 
			
		||||
          url: formData.get('url'),
 | 
			
		||||
          tags: formData.get('tags') ? formData.get('tags').split(',').map(tag => tag.trim()).filter(Boolean) : []
 | 
			
		||||
        },
 | 
			
		||||
        metadata: {
 | 
			
		||||
          reason: formData.get('reason') || null
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // Add type-specific fields
 | 
			
		||||
      if (submissionData.tool.type === 'software') {
 | 
			
		||||
        submissionData.tool.platforms = formData.getAll('platforms');
 | 
			
		||||
        submissionData.tool.license = formData.get('license').trim();
 | 
			
		||||
        submissionData.tool.accessType = formData.get('accessType');
 | 
			
		||||
        submissionData.tool.projectUrl = formData.get('projectUrl') || null;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Add optional fields
 | 
			
		||||
      submissionData.tool.knowledgebase = formData.has('knowledgebase') || null;
 | 
			
		||||
      
 | 
			
		||||
      if (submissionData.tool.type !== 'concept') {
 | 
			
		||||
        const relatedConcepts = formData.getAll('relatedConcepts');
 | 
			
		||||
        submissionData.tool.related_concepts = relatedConcepts.length > 0 ? relatedConcepts : null;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const response = await fetch('/api/contribute/tool', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-Type': 'application/json'
 | 
			
		||||
        },
 | 
			
		||||
        body: JSON.stringify(submissionData)
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const result = await response.json();
 | 
			
		||||
 | 
			
		||||
      if (result.success) {
 | 
			
		||||
        // Show success modal
 | 
			
		||||
        document.getElementById('success-message').textContent = 
 | 
			
		||||
          `Your ${isEdit ? 'update' : 'contribution'} has been submitted successfully and will be reviewed by the maintainers.`;
 | 
			
		||||
        
 | 
			
		||||
        if (result.prUrl) {
 | 
			
		||||
          const prLink = document.getElementById('pr-link');
 | 
			
		||||
          prLink.href = result.prUrl;
 | 
			
		||||
          prLink.style.display = 'inline-flex';
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        successModal.style.display = 'flex';
 | 
			
		||||
      } else {
 | 
			
		||||
        let errorMessage = result.error || 'Submission failed';
 | 
			
		||||
        if (result.details && Array.isArray(result.details)) {
 | 
			
		||||
          errorMessage += '\n\nDetails:\n' + result.details.join('\n');
 | 
			
		||||
        }
 | 
			
		||||
        alert(errorMessage);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Submission error:', error);
 | 
			
		||||
      alert('An error occurred while submitting your contribution. Please try again.');
 | 
			
		||||
    } finally {
 | 
			
		||||
      // Reset loading state
 | 
			
		||||
      submitBtn.disabled = false;
 | 
			
		||||
      submitText.textContent = isEdit ? 'Update Tool' : 'Submit Contribution';
 | 
			
		||||
      submitSpinner.style.display = 'none';
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Initialize form
 | 
			
		||||
  if (isEdit && editTool) {
 | 
			
		||||
    // Pre-fill edit form
 | 
			
		||||
    typeSelect.value = editTool.type;
 | 
			
		||||
    updateFieldVisibility();
 | 
			
		||||
    
 | 
			
		||||
    // Set checkboxes for platforms
 | 
			
		||||
    if (editTool.platforms) {
 | 
			
		||||
      editTool.platforms.forEach(platform => {
 | 
			
		||||
        const checkbox = document.querySelector(`input[value="${platform}"]`);
 | 
			
		||||
        if (checkbox) checkbox.checked = true;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    updateYAMLPreview();
 | 
			
		||||
  } else {
 | 
			
		||||
    updateFieldVisibility();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Debounce utility
 | 
			
		||||
  function debounce(func, wait) {
 | 
			
		||||
    let timeout;
 | 
			
		||||
    return function executedFunction(...args) {
 | 
			
		||||
      const later = () => {
 | 
			
		||||
        clearTimeout(timeout);
 | 
			
		||||
        func(...args);
 | 
			
		||||
      };
 | 
			
		||||
      clearTimeout(timeout);
 | 
			
		||||
      timeout = setTimeout(later, wait);
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										613
									
								
								src/utils/gitContributions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										613
									
								
								src/utils/gitContributions.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,613 @@
 | 
			
		||||
// src/utils/gitContributions.ts
 | 
			
		||||
import { execSync, spawn } from 'child_process';
 | 
			
		||||
import { promises as fs } from 'fs';
 | 
			
		||||
import { load, dump } from 'js-yaml';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
 | 
			
		||||
interface ContributionData {
 | 
			
		||||
  type: 'add' | 'edit';
 | 
			
		||||
  tool: {
 | 
			
		||||
    name: string;
 | 
			
		||||
    icon?: string;
 | 
			
		||||
    type: 'software' | 'method' | 'concept';
 | 
			
		||||
    description: string;
 | 
			
		||||
    domains: string[];
 | 
			
		||||
    phases: string[];
 | 
			
		||||
    platforms: string[];
 | 
			
		||||
    skillLevel: string;
 | 
			
		||||
    accessType?: string;
 | 
			
		||||
    url: string;
 | 
			
		||||
    projectUrl?: string;
 | 
			
		||||
    license?: string;
 | 
			
		||||
    knowledgebase?: boolean;
 | 
			
		||||
    'domain-agnostic-software'?: string[];
 | 
			
		||||
    related_concepts?: string[];
 | 
			
		||||
    tags: string[];
 | 
			
		||||
    statusUrl?: string;
 | 
			
		||||
  };
 | 
			
		||||
  metadata: {
 | 
			
		||||
    submitter: string;
 | 
			
		||||
    reason?: string;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface GitOperationResult {
 | 
			
		||||
  success: boolean;
 | 
			
		||||
  message: string;
 | 
			
		||||
  prUrl?: string;
 | 
			
		||||
  branchName?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface GitConfig {
 | 
			
		||||
  localRepoPath: string;
 | 
			
		||||
  provider: 'gitea' | 'github' | 'gitlab';
 | 
			
		||||
  apiEndpoint: string;
 | 
			
		||||
  apiToken: string;
 | 
			
		||||
  repoUrl: string;
 | 
			
		||||
  repoOwner: string;
 | 
			
		||||
  repoName: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class GitContributionManager {
 | 
			
		||||
  private config: GitConfig;
 | 
			
		||||
  private activeBranches = new Set<string>();
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
    const repoUrl = process.env.GIT_REPO_URL || '';
 | 
			
		||||
    const { owner, name } = this.parseRepoUrl(repoUrl);
 | 
			
		||||
    
 | 
			
		||||
    this.config = {
 | 
			
		||||
      localRepoPath: process.env.LOCAL_REPO_PATH || '/var/git/cc24-hub',
 | 
			
		||||
      provider: (process.env.GIT_PROVIDER as any) || 'gitea',
 | 
			
		||||
      apiEndpoint: process.env.GIT_API_ENDPOINT || '',
 | 
			
		||||
      apiToken: process.env.GIT_API_TOKEN || '',
 | 
			
		||||
      repoUrl,
 | 
			
		||||
      repoOwner: owner,
 | 
			
		||||
      repoName: name
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (!this.config.apiEndpoint || !this.config.apiToken || !this.config.repoUrl) {
 | 
			
		||||
      throw new Error('Missing required git configuration');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseRepoUrl(url: string): { owner: string; name: string } {
 | 
			
		||||
    try {
 | 
			
		||||
      // Parse URLs like: https://git.cc24.dev/mstoeck3/cc24-hub.git
 | 
			
		||||
      const match = url.match(/\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
 | 
			
		||||
      if (!match) {
 | 
			
		||||
        throw new Error('Invalid repository URL format');
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      return {
 | 
			
		||||
        owner: match[1],
 | 
			
		||||
        name: match[2]
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`Failed to parse repository URL: ${url}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async submitContribution(data: ContributionData): Promise<GitOperationResult> {
 | 
			
		||||
    const branchName = this.generateBranchName(data);
 | 
			
		||||
    
 | 
			
		||||
    // Check if branch is already being processed
 | 
			
		||||
    if (this.activeBranches.has(branchName)) {
 | 
			
		||||
      return {
 | 
			
		||||
        success: false,
 | 
			
		||||
        message: 'A contribution with similar details is already being processed'
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      this.activeBranches.add(branchName);
 | 
			
		||||
      
 | 
			
		||||
      // Ensure repository is in clean state
 | 
			
		||||
      await this.ensureCleanRepo();
 | 
			
		||||
      
 | 
			
		||||
      // Create and checkout new branch
 | 
			
		||||
      await this.createBranch(branchName);
 | 
			
		||||
      
 | 
			
		||||
      // Modify tools.yaml
 | 
			
		||||
      await this.modifyToolsYaml(data);
 | 
			
		||||
      
 | 
			
		||||
      // Commit changes
 | 
			
		||||
      await this.commitChanges(data, branchName);
 | 
			
		||||
      
 | 
			
		||||
      // Push branch
 | 
			
		||||
      await this.pushBranch(branchName);
 | 
			
		||||
      
 | 
			
		||||
      // Create pull request
 | 
			
		||||
      const prUrl = await this.createPullRequest(data, branchName);
 | 
			
		||||
      
 | 
			
		||||
      return {
 | 
			
		||||
        success: true,
 | 
			
		||||
        message: 'Contribution submitted successfully',
 | 
			
		||||
        prUrl,
 | 
			
		||||
        branchName
 | 
			
		||||
      };
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Git contribution failed:', error);
 | 
			
		||||
      
 | 
			
		||||
      // Attempt cleanup
 | 
			
		||||
      await this.cleanup(branchName);
 | 
			
		||||
      
 | 
			
		||||
      return {
 | 
			
		||||
        success: false,
 | 
			
		||||
        message: error instanceof Error ? error.message : 'Unknown error occurred'
 | 
			
		||||
      };
 | 
			
		||||
    } finally {
 | 
			
		||||
      this.activeBranches.delete(branchName);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private generateBranchName(data: ContributionData): string {
 | 
			
		||||
    const timestamp = Date.now();
 | 
			
		||||
    const toolSlug = data.tool.name.toLowerCase()
 | 
			
		||||
      .replace(/[^a-z0-9]/g, '-')
 | 
			
		||||
      .replace(/-+/g, '-')
 | 
			
		||||
      .replace(/^-|-$/g, '');
 | 
			
		||||
    
 | 
			
		||||
    return `contrib-${data.type}-${toolSlug}-${timestamp}`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async executeGitCommand(command: string, options: { cwd?: string; timeout?: number } = {}): Promise<string> {
 | 
			
		||||
    return new Promise((resolve, reject) => {
 | 
			
		||||
      const { cwd = this.config.localRepoPath, timeout = 30000 } = options;
 | 
			
		||||
      
 | 
			
		||||
      const child = spawn('sh', ['-c', command], {
 | 
			
		||||
        cwd,
 | 
			
		||||
        stdio: ['pipe', 'pipe', 'pipe'],
 | 
			
		||||
        env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      let stdout = '';
 | 
			
		||||
      let stderr = '';
 | 
			
		||||
 | 
			
		||||
      child.stdout.on('data', (data) => {
 | 
			
		||||
        stdout += data.toString();
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      child.stderr.on('data', (data) => {
 | 
			
		||||
        stderr += data.toString();
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const timeoutId = setTimeout(() => {
 | 
			
		||||
        child.kill('SIGTERM');
 | 
			
		||||
        reject(new Error(`Git command timed out: ${command}`));
 | 
			
		||||
      }, timeout);
 | 
			
		||||
 | 
			
		||||
      child.on('close', (code) => {
 | 
			
		||||
        clearTimeout(timeoutId);
 | 
			
		||||
        
 | 
			
		||||
        if (code === 0) {
 | 
			
		||||
          resolve(stdout.trim());
 | 
			
		||||
        } else {
 | 
			
		||||
          reject(new Error(`Git command failed (${code}): ${command}\n${stderr}`));
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      child.on('error', (error) => {
 | 
			
		||||
        clearTimeout(timeoutId);
 | 
			
		||||
        reject(new Error(`Failed to execute git command: ${error.message}`));
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async ensureCleanRepo(): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
      // Fetch latest changes
 | 
			
		||||
      await this.executeGitCommand('git fetch origin');
 | 
			
		||||
      
 | 
			
		||||
      // Reset to main branch
 | 
			
		||||
      await this.executeGitCommand('git checkout main');
 | 
			
		||||
      await this.executeGitCommand('git reset --hard origin/main');
 | 
			
		||||
      
 | 
			
		||||
      // Clean untracked files
 | 
			
		||||
      await this.executeGitCommand('git clean -fd');
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`Failed to clean repository: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async createBranch(branchName: string): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
      await this.executeGitCommand(`git checkout -b ${branchName}`);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`Failed to create branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async modifyToolsYaml(data: ContributionData): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
      const yamlPath = path.join(this.config.localRepoPath, 'src/data/tools.yaml');
 | 
			
		||||
      const originalContent = await fs.readFile(yamlPath, 'utf8');
 | 
			
		||||
      
 | 
			
		||||
      if (data.type === 'add') {
 | 
			
		||||
        // For adding, append to the tools section
 | 
			
		||||
        const newToolYaml = this.generateToolYaml(data.tool);
 | 
			
		||||
        const updatedContent = this.insertNewTool(originalContent, newToolYaml);
 | 
			
		||||
        await fs.writeFile(yamlPath, updatedContent, 'utf8');
 | 
			
		||||
      } else {
 | 
			
		||||
        // For editing, we still need to parse and regenerate (unfortunately)
 | 
			
		||||
        // But let's at least preserve the overall structure
 | 
			
		||||
        const yamlData = load(originalContent) as any;
 | 
			
		||||
        
 | 
			
		||||
        const existingIndex = yamlData.tools.findIndex((tool: any) => tool.name === data.tool.name);
 | 
			
		||||
        if (existingIndex === -1) {
 | 
			
		||||
          throw new Error(`Tool "${data.tool.name}" not found for editing`);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        yamlData.tools[existingIndex] = this.normalizeToolObject(data.tool);
 | 
			
		||||
        
 | 
			
		||||
        // Use consistent YAML formatting
 | 
			
		||||
        const newYamlContent = dump(yamlData, {
 | 
			
		||||
          lineWidth: 120,
 | 
			
		||||
          noRefs: true,
 | 
			
		||||
          sortKeys: false,
 | 
			
		||||
          forceQuotes: false,
 | 
			
		||||
          flowLevel: -1,
 | 
			
		||||
          styles: {
 | 
			
		||||
            '!!null': 'canonical'
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        await fs.writeFile(yamlPath, newYamlContent, 'utf8');
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`Failed to modify tools.yaml: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private normalizeToolObject(tool: any): any {
 | 
			
		||||
    const normalized = { ...tool };
 | 
			
		||||
    
 | 
			
		||||
    // Convert empty strings and undefined to null
 | 
			
		||||
    Object.keys(normalized).forEach(key => {
 | 
			
		||||
      if (normalized[key] === '' || normalized[key] === undefined) {
 | 
			
		||||
        normalized[key] = null;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Ensure arrays are preserved as arrays (even if empty)
 | 
			
		||||
    ['domains', 'phases', 'platforms', 'tags', 'related_concepts'].forEach(key => {
 | 
			
		||||
      if (!Array.isArray(normalized[key])) {
 | 
			
		||||
        normalized[key] = [];
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    return normalized;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private generateToolYaml(tool: any): string {
 | 
			
		||||
    const normalized = this.normalizeToolObject(tool);
 | 
			
		||||
    
 | 
			
		||||
    let yaml = `  - name: ${normalized.name}\n`;
 | 
			
		||||
    
 | 
			
		||||
    if (normalized.icon) yaml += `    icon: ${normalized.icon}\n`;
 | 
			
		||||
    yaml += `    type: ${normalized.type}\n`;
 | 
			
		||||
    
 | 
			
		||||
    // Handle description with proper formatting for long text
 | 
			
		||||
    if (normalized.description) {
 | 
			
		||||
      if (normalized.description.length > 80) {
 | 
			
		||||
        yaml += `    description: >-\n`;
 | 
			
		||||
        const words = normalized.description.split(' ');
 | 
			
		||||
        let line = '      ';
 | 
			
		||||
        for (const word of words) {
 | 
			
		||||
          if ((line + word).length > 80 && line.length > 6) {
 | 
			
		||||
            yaml += line.trimEnd() + '\n';
 | 
			
		||||
            line = '      ' + word + ' ';
 | 
			
		||||
          } else {
 | 
			
		||||
            line += word + ' ';
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        yaml += line.trimEnd() + '\n';
 | 
			
		||||
      } else {
 | 
			
		||||
        yaml += `    description: ${normalized.description}\n`;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Arrays
 | 
			
		||||
    ['domains', 'phases', 'platforms'].forEach(key => {
 | 
			
		||||
      if (normalized[key] && normalized[key].length > 0) {
 | 
			
		||||
        yaml += `    ${key}:\n`;
 | 
			
		||||
        normalized[key].forEach((item: string) => {
 | 
			
		||||
          yaml += `      - ${item}\n`;
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        yaml += `    ${key}: []\n`;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Add other fields
 | 
			
		||||
    if (normalized['domain-agnostic-software']) {
 | 
			
		||||
      yaml += `    domain-agnostic-software: ${JSON.stringify(normalized['domain-agnostic-software'])}\n`;
 | 
			
		||||
    } else {
 | 
			
		||||
      yaml += `    domain-agnostic-software: null\n`;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    yaml += `    skillLevel: ${normalized.skillLevel}\n`;
 | 
			
		||||
    yaml += `    accessType: ${normalized.accessType || 'null'}\n`;
 | 
			
		||||
    
 | 
			
		||||
    // Handle URL with proper formatting for long URLs
 | 
			
		||||
    if (normalized.url) {
 | 
			
		||||
      if (normalized.url.length > 80) {
 | 
			
		||||
        yaml += `    url: >-\n      ${normalized.url}\n`;
 | 
			
		||||
      } else {
 | 
			
		||||
        yaml += `    url: ${normalized.url}\n`;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    yaml += `    projectUrl: ${normalized.projectUrl || 'null'}\n`;
 | 
			
		||||
    yaml += `    license: ${normalized.license || 'null'}\n`;
 | 
			
		||||
    yaml += `    knowledgebase: ${normalized.knowledgebase || 'null'}\n`;
 | 
			
		||||
    
 | 
			
		||||
    // Related concepts
 | 
			
		||||
    if (normalized.related_concepts && normalized.related_concepts.length > 0) {
 | 
			
		||||
      yaml += `    related_concepts:\n`;
 | 
			
		||||
      normalized.related_concepts.forEach((concept: string) => {
 | 
			
		||||
        yaml += `      - ${concept}\n`;
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      yaml += `    related_concepts: null\n`;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Tags
 | 
			
		||||
    if (normalized.tags && normalized.tags.length > 0) {
 | 
			
		||||
      yaml += `    tags:\n`;
 | 
			
		||||
      normalized.tags.forEach((tag: string) => {
 | 
			
		||||
        yaml += `      - ${tag}\n`;
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      yaml += `    tags: []\n`;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (normalized.statusUrl) {
 | 
			
		||||
      yaml += `    statusUrl: ${normalized.statusUrl}\n`;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return yaml;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private insertNewTool(originalContent: string, newToolYaml: string): string {
 | 
			
		||||
    // Find the end of the tools section (before domains:)
 | 
			
		||||
    const domainsIndex = originalContent.indexOf('\ndomains:');
 | 
			
		||||
    if (domainsIndex === -1) {
 | 
			
		||||
      // If no domains section, just append to end with proper spacing
 | 
			
		||||
      return originalContent.trimEnd() + '\n\n' + newToolYaml.trimEnd() + '\n';
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Insert before the domains section with proper newline spacing
 | 
			
		||||
    const beforeDomains = originalContent.slice(0, domainsIndex).trimEnd();
 | 
			
		||||
    const afterDomains = originalContent.slice(domainsIndex);
 | 
			
		||||
    
 | 
			
		||||
    return beforeDomains + '\n\n' + newToolYaml.trimEnd() + afterDomains;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async commitChanges(data: ContributionData, branchName: string): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
      // Configure git user for this commit
 | 
			
		||||
      await this.executeGitCommand('git config user.name "CC24-Hub Contributors"');
 | 
			
		||||
      await this.executeGitCommand('git config user.email "contributors@cc24.dev"');
 | 
			
		||||
      
 | 
			
		||||
      // Stage changes
 | 
			
		||||
      await this.executeGitCommand('git add src/data/tools.yaml');
 | 
			
		||||
      
 | 
			
		||||
      // Create commit message
 | 
			
		||||
      const action = data.type === 'add' ? 'Add' : 'Update';
 | 
			
		||||
      const commitMessage = `${action} ${data.tool.type}: ${data.tool.name}
 | 
			
		||||
 | 
			
		||||
Submitted by: ${data.metadata.submitter}
 | 
			
		||||
${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}
 | 
			
		||||
 | 
			
		||||
Branch: ${branchName}`;
 | 
			
		||||
 | 
			
		||||
      await this.executeGitCommand(`git commit -m "${commitMessage}"`);
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`Failed to commit changes: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async pushBranch(branchName: string): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
      await this.executeGitCommand(`git push origin ${branchName}`);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`Failed to push branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async createPullRequest(data: ContributionData, branchName: string): Promise<string> {
 | 
			
		||||
    const action = data.type === 'add' ? 'Add' : 'Update';
 | 
			
		||||
    const title = `${action} ${data.tool.type}: ${data.tool.name}`;
 | 
			
		||||
    
 | 
			
		||||
    const body = `## Contribution Details
 | 
			
		||||
 | 
			
		||||
**Type**: ${data.tool.type}  
 | 
			
		||||
**Action**: ${action}  
 | 
			
		||||
**Submitted by**: ${data.metadata.submitter}  
 | 
			
		||||
 | 
			
		||||
### Tool Information
 | 
			
		||||
- **Name**: ${data.tool.name}
 | 
			
		||||
- **Description**: ${data.tool.description}
 | 
			
		||||
- **Domains**: ${data.tool.domains.join(', ')}
 | 
			
		||||
- **Phases**: ${data.tool.phases.join(', ')}
 | 
			
		||||
- **Skill Level**: ${data.tool.skillLevel}
 | 
			
		||||
- **License**: ${data.tool.license || 'N/A'}
 | 
			
		||||
- **URL**: ${data.tool.url}
 | 
			
		||||
 | 
			
		||||
${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}` : ''}
 | 
			
		||||
 | 
			
		||||
### Review Checklist
 | 
			
		||||
- [ ] Tool information is accurate and complete
 | 
			
		||||
- [ ] Description is clear and informative
 | 
			
		||||
- [ ] Domains and phases are correctly assigned
 | 
			
		||||
- [ ] Tags are relevant and consistent
 | 
			
		||||
- [ ] License information is correct
 | 
			
		||||
- [ ] URLs are valid and accessible
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
*This contribution was submitted via the CC24-Hub web interface.*`;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      let apiUrl: string;
 | 
			
		||||
      let requestBody: any;
 | 
			
		||||
 | 
			
		||||
      switch (this.config.provider) {
 | 
			
		||||
        case 'gitea':
 | 
			
		||||
          apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/pulls`;
 | 
			
		||||
          requestBody = {
 | 
			
		||||
            title,
 | 
			
		||||
            body,
 | 
			
		||||
            head: branchName,
 | 
			
		||||
            base: 'main'
 | 
			
		||||
          };
 | 
			
		||||
          break;
 | 
			
		||||
        
 | 
			
		||||
        case 'github':
 | 
			
		||||
          apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/pulls`;
 | 
			
		||||
          requestBody = {
 | 
			
		||||
            title,
 | 
			
		||||
            body,
 | 
			
		||||
            head: branchName,
 | 
			
		||||
            base: 'main'
 | 
			
		||||
          };
 | 
			
		||||
          break;
 | 
			
		||||
          
 | 
			
		||||
        case 'gitlab':
 | 
			
		||||
          apiUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}/merge_requests`;
 | 
			
		||||
          requestBody = {
 | 
			
		||||
            title,
 | 
			
		||||
            description: body,
 | 
			
		||||
            source_branch: branchName,
 | 
			
		||||
            target_branch: 'main'
 | 
			
		||||
          };
 | 
			
		||||
          break;
 | 
			
		||||
          
 | 
			
		||||
        default:
 | 
			
		||||
          throw new Error(`Unsupported git provider: ${this.config.provider}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const response = await fetch(apiUrl, {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Authorization': `Bearer ${this.config.apiToken}`,
 | 
			
		||||
          'Content-Type': 'application/json'
 | 
			
		||||
        },
 | 
			
		||||
        body: JSON.stringify(requestBody)
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        const errorText = await response.text();
 | 
			
		||||
        throw new Error(`PR creation failed (${response.status}): ${errorText}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const prData = await response.json();
 | 
			
		||||
      
 | 
			
		||||
      // Extract PR URL based on provider
 | 
			
		||||
      let prUrl: string;
 | 
			
		||||
      switch (this.config.provider) {
 | 
			
		||||
        case 'gitea':
 | 
			
		||||
        case 'github':
 | 
			
		||||
          prUrl = prData.html_url || prData.url;
 | 
			
		||||
          break;
 | 
			
		||||
        case 'gitlab':
 | 
			
		||||
          prUrl = prData.web_url;
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
          throw new Error('Unknown provider response format');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return prUrl;
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`Failed to create pull request: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async cleanup(branchName: string): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
      // Switch back to main and delete the failed branch
 | 
			
		||||
      await this.executeGitCommand('git checkout main', { timeout: 10000 });
 | 
			
		||||
      await this.executeGitCommand(`git branch -D ${branchName}`, { timeout: 10000 });
 | 
			
		||||
      
 | 
			
		||||
      // Try to delete remote branch if it exists
 | 
			
		||||
      try {
 | 
			
		||||
        await this.executeGitCommand(`git push origin --delete ${branchName}`, { timeout: 10000 });
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        // Ignore errors when deleting remote branch (might not exist)
 | 
			
		||||
        console.warn(`Could not delete remote branch ${branchName}:`, error);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(`Cleanup failed for branch ${branchName}:`, error);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async checkHealth(): Promise<{ healthy: boolean; issues?: string[] }> {
 | 
			
		||||
    const issues: string[] = [];
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      // Check if local repo exists and is a git repository
 | 
			
		||||
      const repoExists = await fs.access(this.config.localRepoPath).then(() => true).catch(() => false);
 | 
			
		||||
      if (!repoExists) {
 | 
			
		||||
        issues.push(`Local repository path does not exist: ${this.config.localRepoPath}`);
 | 
			
		||||
        return { healthy: false, issues };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const gitDirExists = await fs.access(path.join(this.config.localRepoPath, '.git')).then(() => true).catch(() => false);
 | 
			
		||||
      if (!gitDirExists) {
 | 
			
		||||
        issues.push('Local path is not a git repository');
 | 
			
		||||
        return { healthy: false, issues };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Check git status
 | 
			
		||||
      try {
 | 
			
		||||
        await this.executeGitCommand('git status --porcelain', { timeout: 5000 });
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        issues.push(`Git status check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Check remote connectivity
 | 
			
		||||
      try {
 | 
			
		||||
        await this.executeGitCommand('git ls-remote origin HEAD', { timeout: 10000 });
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        issues.push(`Remote connectivity check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Check API connectivity
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch(this.config.apiEndpoint, {
 | 
			
		||||
          headers: { 'Authorization': `Bearer ${this.config.apiToken}` },
 | 
			
		||||
          signal: AbortSignal.timeout(5000)
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        if (!response.ok && response.status !== 404) { // 404 is expected for base API endpoint
 | 
			
		||||
          issues.push(`API connectivity check failed: HTTP ${response.status}`);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        issues.push(`API connectivity check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Check write permissions
 | 
			
		||||
      try {
 | 
			
		||||
        const testFile = path.join(this.config.localRepoPath, '.write-test');
 | 
			
		||||
        await fs.writeFile(testFile, 'test');
 | 
			
		||||
        await fs.unlink(testFile);
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        issues.push(`Write permission check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return { healthy: issues.length === 0, issues: issues.length > 0 ? issues : undefined };
 | 
			
		||||
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      issues.push(`Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
      return { healthy: false, issues };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { GitContributionManager, type ContributionData, type GitOperationResult };
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user