first draft contributions

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

View File

@ -0,0 +1,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' }
});
}
};

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

View File

@ -469,6 +469,10 @@ input[type="checkbox"] {
background: linear-gradient(to right, transparent 0%, var(--color-method-bg) 70%); 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 { .tool-card-buttons {
margin-top: auto; margin-top: auto;
flex-shrink: 0; flex-shrink: 0;

View File

@ -0,0 +1,613 @@
// src/utils/gitContributions.ts
import { execSync, spawn } from 'child_process';
import { promises as fs } from 'fs';
import { load, dump } from 'js-yaml';
import path from 'path';
interface ContributionData {
type: 'add' | 'edit';
tool: {
name: string;
icon?: string;
type: 'software' | 'method' | 'concept';
description: string;
domains: string[];
phases: string[];
platforms: string[];
skillLevel: string;
accessType?: string;
url: string;
projectUrl?: string;
license?: string;
knowledgebase?: boolean;
'domain-agnostic-software'?: string[];
related_concepts?: string[];
tags: string[];
statusUrl?: string;
};
metadata: {
submitter: string;
reason?: string;
};
}
interface GitOperationResult {
success: boolean;
message: string;
prUrl?: string;
branchName?: string;
}
interface GitConfig {
localRepoPath: string;
provider: 'gitea' | 'github' | 'gitlab';
apiEndpoint: string;
apiToken: string;
repoUrl: string;
repoOwner: string;
repoName: string;
}
class GitContributionManager {
private config: GitConfig;
private activeBranches = new Set<string>();
constructor() {
const repoUrl = process.env.GIT_REPO_URL || '';
const { owner, name } = this.parseRepoUrl(repoUrl);
this.config = {
localRepoPath: process.env.LOCAL_REPO_PATH || '/var/git/cc24-hub',
provider: (process.env.GIT_PROVIDER as any) || 'gitea',
apiEndpoint: process.env.GIT_API_ENDPOINT || '',
apiToken: process.env.GIT_API_TOKEN || '',
repoUrl,
repoOwner: owner,
repoName: name
};
if (!this.config.apiEndpoint || !this.config.apiToken || !this.config.repoUrl) {
throw new Error('Missing required git configuration');
}
}
private parseRepoUrl(url: string): { owner: string; name: string } {
try {
// Parse URLs like: https://git.cc24.dev/mstoeck3/cc24-hub.git
const match = url.match(/\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
if (!match) {
throw new Error('Invalid repository URL format');
}
return {
owner: match[1],
name: match[2]
};
} catch (error) {
throw new Error(`Failed to parse repository URL: ${url}`);
}
}
async submitContribution(data: ContributionData): Promise<GitOperationResult> {
const branchName = this.generateBranchName(data);
// Check if branch is already being processed
if (this.activeBranches.has(branchName)) {
return {
success: false,
message: 'A contribution with similar details is already being processed'
};
}
try {
this.activeBranches.add(branchName);
// Ensure repository is in clean state
await this.ensureCleanRepo();
// Create and checkout new branch
await this.createBranch(branchName);
// Modify tools.yaml
await this.modifyToolsYaml(data);
// Commit changes
await this.commitChanges(data, branchName);
// Push branch
await this.pushBranch(branchName);
// Create pull request
const prUrl = await this.createPullRequest(data, branchName);
return {
success: true,
message: 'Contribution submitted successfully',
prUrl,
branchName
};
} catch (error) {
console.error('Git contribution failed:', error);
// Attempt cleanup
await this.cleanup(branchName);
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred'
};
} finally {
this.activeBranches.delete(branchName);
}
}
private generateBranchName(data: ContributionData): string {
const timestamp = Date.now();
const toolSlug = data.tool.name.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return `contrib-${data.type}-${toolSlug}-${timestamp}`;
}
private async executeGitCommand(command: string, options: { cwd?: string; timeout?: number } = {}): Promise<string> {
return new Promise((resolve, reject) => {
const { cwd = this.config.localRepoPath, timeout = 30000 } = options;
const child = spawn('sh', ['-c', command], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
const timeoutId = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error(`Git command timed out: ${command}`));
}, timeout);
child.on('close', (code) => {
clearTimeout(timeoutId);
if (code === 0) {
resolve(stdout.trim());
} else {
reject(new Error(`Git command failed (${code}): ${command}\n${stderr}`));
}
});
child.on('error', (error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to execute git command: ${error.message}`));
});
});
}
private async ensureCleanRepo(): Promise<void> {
try {
// Fetch latest changes
await this.executeGitCommand('git fetch origin');
// Reset to main branch
await this.executeGitCommand('git checkout main');
await this.executeGitCommand('git reset --hard origin/main');
// Clean untracked files
await this.executeGitCommand('git clean -fd');
} catch (error) {
throw new Error(`Failed to clean repository: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async createBranch(branchName: string): Promise<void> {
try {
await this.executeGitCommand(`git checkout -b ${branchName}`);
} catch (error) {
throw new Error(`Failed to create branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async modifyToolsYaml(data: ContributionData): Promise<void> {
try {
const yamlPath = path.join(this.config.localRepoPath, 'src/data/tools.yaml');
const originalContent = await fs.readFile(yamlPath, 'utf8');
if (data.type === 'add') {
// For adding, append to the tools section
const newToolYaml = this.generateToolYaml(data.tool);
const updatedContent = this.insertNewTool(originalContent, newToolYaml);
await fs.writeFile(yamlPath, updatedContent, 'utf8');
} else {
// For editing, we still need to parse and regenerate (unfortunately)
// But let's at least preserve the overall structure
const yamlData = load(originalContent) as any;
const existingIndex = yamlData.tools.findIndex((tool: any) => tool.name === data.tool.name);
if (existingIndex === -1) {
throw new Error(`Tool "${data.tool.name}" not found for editing`);
}
yamlData.tools[existingIndex] = this.normalizeToolObject(data.tool);
// Use consistent YAML formatting
const newYamlContent = dump(yamlData, {
lineWidth: 120,
noRefs: true,
sortKeys: false,
forceQuotes: false,
flowLevel: -1,
styles: {
'!!null': 'canonical'
}
});
await fs.writeFile(yamlPath, newYamlContent, 'utf8');
}
} catch (error) {
throw new Error(`Failed to modify tools.yaml: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private normalizeToolObject(tool: any): any {
const normalized = { ...tool };
// Convert empty strings and undefined to null
Object.keys(normalized).forEach(key => {
if (normalized[key] === '' || normalized[key] === undefined) {
normalized[key] = null;
}
});
// Ensure arrays are preserved as arrays (even if empty)
['domains', 'phases', 'platforms', 'tags', 'related_concepts'].forEach(key => {
if (!Array.isArray(normalized[key])) {
normalized[key] = [];
}
});
return normalized;
}
private generateToolYaml(tool: any): string {
const normalized = this.normalizeToolObject(tool);
let yaml = ` - name: ${normalized.name}\n`;
if (normalized.icon) yaml += ` icon: ${normalized.icon}\n`;
yaml += ` type: ${normalized.type}\n`;
// Handle description with proper formatting for long text
if (normalized.description) {
if (normalized.description.length > 80) {
yaml += ` description: >-\n`;
const words = normalized.description.split(' ');
let line = ' ';
for (const word of words) {
if ((line + word).length > 80 && line.length > 6) {
yaml += line.trimEnd() + '\n';
line = ' ' + word + ' ';
} else {
line += word + ' ';
}
}
yaml += line.trimEnd() + '\n';
} else {
yaml += ` description: ${normalized.description}\n`;
}
}
// Arrays
['domains', 'phases', 'platforms'].forEach(key => {
if (normalized[key] && normalized[key].length > 0) {
yaml += ` ${key}:\n`;
normalized[key].forEach((item: string) => {
yaml += ` - ${item}\n`;
});
} else {
yaml += ` ${key}: []\n`;
}
});
// Add other fields
if (normalized['domain-agnostic-software']) {
yaml += ` domain-agnostic-software: ${JSON.stringify(normalized['domain-agnostic-software'])}\n`;
} else {
yaml += ` domain-agnostic-software: null\n`;
}
yaml += ` skillLevel: ${normalized.skillLevel}\n`;
yaml += ` accessType: ${normalized.accessType || 'null'}\n`;
// Handle URL with proper formatting for long URLs
if (normalized.url) {
if (normalized.url.length > 80) {
yaml += ` url: >-\n ${normalized.url}\n`;
} else {
yaml += ` url: ${normalized.url}\n`;
}
}
yaml += ` projectUrl: ${normalized.projectUrl || 'null'}\n`;
yaml += ` license: ${normalized.license || 'null'}\n`;
yaml += ` knowledgebase: ${normalized.knowledgebase || 'null'}\n`;
// Related concepts
if (normalized.related_concepts && normalized.related_concepts.length > 0) {
yaml += ` related_concepts:\n`;
normalized.related_concepts.forEach((concept: string) => {
yaml += ` - ${concept}\n`;
});
} else {
yaml += ` related_concepts: null\n`;
}
// Tags
if (normalized.tags && normalized.tags.length > 0) {
yaml += ` tags:\n`;
normalized.tags.forEach((tag: string) => {
yaml += ` - ${tag}\n`;
});
} else {
yaml += ` tags: []\n`;
}
if (normalized.statusUrl) {
yaml += ` statusUrl: ${normalized.statusUrl}\n`;
}
return yaml;
}
private insertNewTool(originalContent: string, newToolYaml: string): string {
// Find the end of the tools section (before domains:)
const domainsIndex = originalContent.indexOf('\ndomains:');
if (domainsIndex === -1) {
// If no domains section, just append to end with proper spacing
return originalContent.trimEnd() + '\n\n' + newToolYaml.trimEnd() + '\n';
}
// Insert before the domains section with proper newline spacing
const beforeDomains = originalContent.slice(0, domainsIndex).trimEnd();
const afterDomains = originalContent.slice(domainsIndex);
return beforeDomains + '\n\n' + newToolYaml.trimEnd() + afterDomains;
}
private async commitChanges(data: ContributionData, branchName: string): Promise<void> {
try {
// Configure git user for this commit
await this.executeGitCommand('git config user.name "CC24-Hub Contributors"');
await this.executeGitCommand('git config user.email "contributors@cc24.dev"');
// Stage changes
await this.executeGitCommand('git add src/data/tools.yaml');
// Create commit message
const action = data.type === 'add' ? 'Add' : 'Update';
const commitMessage = `${action} ${data.tool.type}: ${data.tool.name}
Submitted by: ${data.metadata.submitter}
${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}
Branch: ${branchName}`;
await this.executeGitCommand(`git commit -m "${commitMessage}"`);
} catch (error) {
throw new Error(`Failed to commit changes: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async pushBranch(branchName: string): Promise<void> {
try {
await this.executeGitCommand(`git push origin ${branchName}`);
} catch (error) {
throw new Error(`Failed to push branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async createPullRequest(data: ContributionData, branchName: string): Promise<string> {
const action = data.type === 'add' ? 'Add' : 'Update';
const title = `${action} ${data.tool.type}: ${data.tool.name}`;
const body = `## Contribution Details
**Type**: ${data.tool.type}
**Action**: ${action}
**Submitted by**: ${data.metadata.submitter}
### Tool Information
- **Name**: ${data.tool.name}
- **Description**: ${data.tool.description}
- **Domains**: ${data.tool.domains.join(', ')}
- **Phases**: ${data.tool.phases.join(', ')}
- **Skill Level**: ${data.tool.skillLevel}
- **License**: ${data.tool.license || 'N/A'}
- **URL**: ${data.tool.url}
${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}` : ''}
### Review Checklist
- [ ] Tool information is accurate and complete
- [ ] Description is clear and informative
- [ ] Domains and phases are correctly assigned
- [ ] Tags are relevant and consistent
- [ ] License information is correct
- [ ] URLs are valid and accessible
---
*This contribution was submitted via the CC24-Hub web interface.*`;
try {
let apiUrl: string;
let requestBody: any;
switch (this.config.provider) {
case 'gitea':
apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/pulls`;
requestBody = {
title,
body,
head: branchName,
base: 'main'
};
break;
case 'github':
apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/pulls`;
requestBody = {
title,
body,
head: branchName,
base: 'main'
};
break;
case 'gitlab':
apiUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}/merge_requests`;
requestBody = {
title,
description: body,
source_branch: branchName,
target_branch: 'main'
};
break;
default:
throw new Error(`Unsupported git provider: ${this.config.provider}`);
}
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.config.apiToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`PR creation failed (${response.status}): ${errorText}`);
}
const prData = await response.json();
// Extract PR URL based on provider
let prUrl: string;
switch (this.config.provider) {
case 'gitea':
case 'github':
prUrl = prData.html_url || prData.url;
break;
case 'gitlab':
prUrl = prData.web_url;
break;
default:
throw new Error('Unknown provider response format');
}
return prUrl;
} catch (error) {
throw new Error(`Failed to create pull request: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async cleanup(branchName: string): Promise<void> {
try {
// Switch back to main and delete the failed branch
await this.executeGitCommand('git checkout main', { timeout: 10000 });
await this.executeGitCommand(`git branch -D ${branchName}`, { timeout: 10000 });
// Try to delete remote branch if it exists
try {
await this.executeGitCommand(`git push origin --delete ${branchName}`, { timeout: 10000 });
} catch (error) {
// Ignore errors when deleting remote branch (might not exist)
console.warn(`Could not delete remote branch ${branchName}:`, error);
}
} catch (error) {
console.error(`Cleanup failed for branch ${branchName}:`, error);
}
}
async checkHealth(): Promise<{ healthy: boolean; issues?: string[] }> {
const issues: string[] = [];
try {
// Check if local repo exists and is a git repository
const repoExists = await fs.access(this.config.localRepoPath).then(() => true).catch(() => false);
if (!repoExists) {
issues.push(`Local repository path does not exist: ${this.config.localRepoPath}`);
return { healthy: false, issues };
}
const gitDirExists = await fs.access(path.join(this.config.localRepoPath, '.git')).then(() => true).catch(() => false);
if (!gitDirExists) {
issues.push('Local path is not a git repository');
return { healthy: false, issues };
}
// Check git status
try {
await this.executeGitCommand('git status --porcelain', { timeout: 5000 });
} catch (error) {
issues.push(`Git status check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Check remote connectivity
try {
await this.executeGitCommand('git ls-remote origin HEAD', { timeout: 10000 });
} catch (error) {
issues.push(`Remote connectivity check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Check API connectivity
try {
const response = await fetch(this.config.apiEndpoint, {
headers: { 'Authorization': `Bearer ${this.config.apiToken}` },
signal: AbortSignal.timeout(5000)
});
if (!response.ok && response.status !== 404) { // 404 is expected for base API endpoint
issues.push(`API connectivity check failed: HTTP ${response.status}`);
}
} catch (error) {
issues.push(`API connectivity check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Check write permissions
try {
const testFile = path.join(this.config.localRepoPath, '.write-test');
await fs.writeFile(testFile, 'test');
await fs.unlink(testFile);
} catch (error) {
issues.push(`Write permission check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
return { healthy: issues.length === 0, issues: issues.length > 0 ? issues : undefined };
} catch (error) {
issues.push(`Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
return { healthy: false, issues };
}
}
}
export { GitContributionManager, type ContributionData, type GitOperationResult };