commit bd535138d6b31a6ad6d79ab228be4eba7d75e72e Author: Max Litruv Boonzaayer Date: Fri Apr 17 01:48:23 2026 +1000 Initial commit: Plugin Directory with automation diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2baa43d --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Gitea configuration +GITEA_URL=http://synbox.ruv.wtf:8418 +GITEA_TOKEN=your_gitea_access_token_here + +# Webhook secret (must match Gitea webhook configuration) +WEBHOOK_SECRET=your_webhook_secret_here + +# Server port +PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..daf1dd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..6112680 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# Plugin Directory + +Automated plugin registry with PR validation and auto-merge capabilities for the Plugin Host system. + +## Features + +- 🤖 **Automated PR Processing**: Automatically validates and merges plugin submissions +- ✅ **Strict Validation**: Enforces plugin schema and ownership rules +- 🔒 **Ownership Protection**: Users can only remove plugins they authored +- 📡 **Webhook Integration**: Gitea webhook support for real-time PR handling +- 🔍 **Schema Validation**: Ensures all plugins meet quality standards +- 📦 **REST API**: List and query available plugins + +## Setup + +### 1. Install Dependencies + +```bash +npm install +``` + +### 2. Configure Environment + +Copy `.env.example` to `.env` and fill in your Gitea details: + +```bash +cp .env.example .env +``` + +Edit `.env`: +```env +GITEA_URL=http://synbox.ruv.wtf:8418 +GITEA_TOKEN=your_gitea_access_token_here +WEBHOOK_SECRET=your_webhook_secret_here +PORT=3000 +``` + +### 3. Configure Gitea Webhook + +In your Gitea repository settings: + +1. Go to Settings → Webhooks → Add Webhook → Gitea +2. Set Payload URL: `http://your-server:3000/webhook` +3. Set Secret: (same as WEBHOOK_SECRET in .env) +4. Select events: `Pull Request` +5. Save webhook + +### 4. Start Server + +```bash +npm start +``` + +For development with auto-reload: +```bash +npm run dev +``` + +## Plugin Submission Rules + +### Valid PRs Must: + +1. ✅ Only modify files in the `plugins/` directory +2. ✅ Only add or remove `.json` files +3. ✅ Have valid JSON matching the plugin schema +4. ✅ Have plugin ID matching the filename +5. ✅ Have author field matching PR author (for new plugins) +6. ✅ Only remove plugins you authored + +### Plugin Schema + +```json +{ + "id": "your-plugin-id", + "name": "Your Plugin Name", + "version": "1.0.0", + "description": "What your plugin does", + "author": "your-gitea-username", + "repository": "http://synbox.ruv.wtf:8418/username/plugin-repo.git", + "downloadUrl": "http://synbox.ruv.wtf:8418/username/plugin-repo/archive/main.zip", + "homepage": "http://synbox.ruv.wtf:8418/username/plugin-repo", + "tags": ["tag1", "tag2"], + "addedDate": "2026-04-17T00:00:00.000Z" +} +``` + +**Required Fields:** +- `id` - Unique identifier (must match filename without .json) +- `name` - Display name +- `version` - Semantic version (X.Y.Z) +- `description` - What the plugin does +- `author` - Your Gitea username +- `repository` - Git repository URL + +**Optional Fields:** +- `downloadUrl` - Direct download link +- `homepage` - Plugin homepage or docs +- `tags` - Array of category tags +- `addedDate` - ISO 8601 date string +- `dependencies` - Required plugins or packages + +## API Endpoints + +### List All Plugins + +```bash +GET /plugins +``` + +Response: +```json +{ + "plugins": [...], + "count": 10 +} +``` + +### Get Specific Plugin + +```bash +GET /plugins/:pluginId +``` + +### Health Check + +```bash +GET /health +``` + +### Webhook Endpoint + +```bash +POST /webhook +``` + +## How It Works + +1. User creates a PR adding/removing a plugin JSON file +2. Gitea sends webhook to the directory server +3. Server validates the PR: + - Checks file locations + - Validates JSON schema + - Verifies ownership for removals + - Ensures author matches PR creator +4. If valid: Auto-approves and merges +5. If invalid: Comments with error details + +## Undoing Changes + +To undo a merged plugin submission: + +1. Create a new PR that reverses the change +2. To remove a plugin you added: delete the JSON file +3. To re-add a plugin you removed: add the JSON file back + +The same validation rules apply - you can only remove plugins you authored. + +## Development + +The system consists of: + +- `server.js` - Express server and endpoints +- `WebhookHandler.js` - Processes Gitea webhooks +- `ValidatorEnhanced.js` - Validates PR changes +- `plugins/` - Plugin registry JSON files + +## Security + +- Webhook signatures are verified using HMAC-SHA256 +- Only PR authors can modify their own plugins +- All file operations are restricted to the `plugins/` directory +- JSON parsing errors are caught and reported + +## License + +MIT diff --git a/SCHEMA.md b/SCHEMA.md new file mode 100644 index 0000000..4edcdbc --- /dev/null +++ b/SCHEMA.md @@ -0,0 +1,73 @@ +# Plugin Schema + +All plugin definitions must be valid JSON files following this schema. + +## Required Fields + +| Field | Type | Description | Example | +|-------|------|-------------|---------| +| `id` | string | Unique identifier (must match filename) | `"awesome-plugin"` | +| `name` | string | Display name | `"Awesome Plugin"` | +| `version` | string | Semantic version | `"1.0.0"` | +| `description` | string | What the plugin does | `"Does awesome things"` | +| `author` | string | Gitea username of creator | `"litruv"` | +| `repository` | string | Git repository URL | `"http://synbox.ruv.wtf:8418/litruv/awesome-plugin.git"` | + +## Optional Fields + +| Field | Type | Description | Example | +|-------|------|-------------|---------| +| `downloadUrl` | string | Direct download URL | `"http://synbox.ruv.wtf:8418/litruv/awesome-plugin/archive/main.zip"` | +| `homepage` | string | Plugin homepage or documentation | `"http://synbox.ruv.wtf:8418/litruv/awesome-plugin"` | +| `tags` | array | Category tags | `["utility", "automation"]` | +| `addedDate` | string | ISO 8601 date | `"2026-04-17T00:00:00.000Z"` | +| `dependencies` | object | Required dependencies | `{"lodash": "^4.17.21"}` | + +## Example + +```json +{ + "id": "example-plugin", + "name": "Example Plugin", + "version": "1.0.0", + "description": "An example plugin demonstrating the plugin system capabilities", + "author": "litruv", + "repository": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example.git", + "downloadUrl": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example/archive/main.zip", + "homepage": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example", + "tags": ["example", "demo"], + "addedDate": "2026-04-17T00:00:00.000Z" +} +``` + +## Validation Rules + +1. **Filename Match**: Plugin ID must match the filename (without .json extension) + - ✅ `awesome-plugin.json` with `"id": "awesome-plugin"` + - ❌ `awesome-plugin.json` with `"id": "different-name"` + +2. **Version Format**: Must follow semantic versioning (X.Y.Z) + - ✅ `"1.0.0"`, `"2.5.3"`, `"0.1.0"` + - ❌ `"1.0"`, `"v1.0.0"`, `"latest"` + +3. **Author Matching**: For new plugins, author must match PR creator + - If PR is by user `litruv`, author field must be `"litruv"` + +4. **Valid URLs**: Repository, downloadUrl, and homepage must be valid URLs + - ✅ `"http://example.com/repo.git"` + - ❌ `"not-a-url"`, `"//example.com"` + +5. **Tags**: If present, must be an array of strings + - ✅ `["tag1", "tag2"]` + - ❌ `"tag1,tag2"`, `{"tag": "value"}` + +## Submission Process + +1. Create a JSON file in the `plugins/` directory +2. Filename must be `{plugin-id}.json` +3. Ensure all required fields are present +4. Author field must match your Gitea username +5. Create a Pull Request +6. Automated validation will run +7. If valid, PR auto-merges +8. If invalid, you'll receive comments explaining what to fix diff --git a/package.json b/package.json new file mode 100644 index 0000000..8892c6b --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "@pluginhost/directory", + "version": "1.0.0", + "description": "Automated plugin directory with PR validation and auto-merge", + "main": "src/server.js", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "validate": "node src/validate.js" + }, + "keywords": ["plugin", "directory", "registry"], + "author": "", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "node-fetch": "^3.3.2" + } +} diff --git a/plugins/example-plugin.json b/plugins/example-plugin.json new file mode 100644 index 0000000..ee403f5 --- /dev/null +++ b/plugins/example-plugin.json @@ -0,0 +1,12 @@ +{ + "id": "example-plugin", + "name": "Example Plugin", + "version": "1.0.0", + "description": "An example plugin demonstrating the plugin system capabilities", + "author": "litruv", + "repository": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example.git", + "downloadUrl": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example/archive/main.zip", + "homepage": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example", + "tags": ["example", "demo"], + "addedDate": "2026-04-17T00:00:00.000Z" +} diff --git a/src/Validator.js b/src/Validator.js new file mode 100644 index 0000000..2f214c9 --- /dev/null +++ b/src/Validator.js @@ -0,0 +1,193 @@ +import { readFile } from 'fs/promises'; + +/** + * Validator - Validates PR changes against plugin directory rules + */ +export class Validator { + constructor() { + this.requiredFields = ['id', 'name', 'version', 'description', 'author', 'repository']; + this.optionalFields = ['downloadUrl', 'homepage', 'tags', 'addedDate', 'dependencies']; + } + + /** + * Validate a PR's file changes + */ + async validatePR(files, prAuthor) { + const errors = []; + const added = []; + const removed = []; + + // Check that all files are in the plugins/ directory + const invalidPaths = files.filter(f => !f.filename.startsWith('plugins/')); + if (invalidPaths.length > 0) { + errors.push(`Files outside plugins/ directory: ${invalidPaths.map(f => f.filename).join(', ')}`); + } + + // Check that all files are .json files + const nonJsonFiles = files.filter(f => + f.filename.startsWith('plugins/') && !f.filename.endsWith('.json') + ); + if (nonJsonFiles.length > 0) { + errors.push(`Non-JSON files in plugins/: ${nonJsonFiles.map(f => f.filename).join(', ')}`); + } + + // Process each changed file + for (const file of files) { + if (!file.filename.startsWith('plugins/') || !file.filename.endsWith('.json')) { + continue; + } + + const pluginId = file.filename.replace('plugins/', '').replace('.json', ''); + + if (file.status === 'added' || file.status === 'modified') { + // Validate added/modified plugins + try { + const validation = await this.validatePluginFile(file, pluginId, prAuthor); + + if (!validation.valid) { + errors.push(...validation.errors.map(e => `${file.filename}: ${e}`)); + } else { + added.push(pluginId); + } + } catch (error) { + errors.push(`${file.filename}: ${error.message}`); + } + } else if (file.status === 'removed') { + // Validate removed plugins (check ownership) + try { + const canRemove = await this.validateRemoval(pluginId, prAuthor); + + if (!canRemove.valid) { + errors.push(...canRemove.errors.map(e => `${file.filename}: ${e}`)); + } else { + removed.push(pluginId); + } + } catch (error) { + errors.push(`${file.filename}: ${error.message}`); + } + } + } + + // Must have at least one change + if (files.length === 0) { + errors.push('PR has no file changes'); + } + + return { + valid: errors.length === 0, + errors, + added, + removed + }; + } + + /** + * Validate a plugin JSON file + */ + async validatePluginFile(file, pluginId, prAuthor) { + const errors = []; + + try { + // Parse the JSON content from the file patch + const content = this.extractFileContent(file); + const plugin = JSON.parse(content); + + // Check required fields + for (const field of this.requiredFields) { + if (!plugin[field]) { + errors.push(`Missing required field: ${field}`); + } + } + + // Check that ID matches filename + if (plugin.id !== pluginId) { + errors.push(`Plugin ID "${plugin.id}" does not match filename "${pluginId}.json"`); + } + + // Validate version format (semver-ish) + if (plugin.version && !this.isValidVersion(plugin.version)) { + errors.push(`Invalid version format: ${plugin.version} (expected: X.Y.Z)`); + } + + // Validate repository URL + if (plugin.repository && !this.isValidUrl(plugin.repository)) { + errors.push(`Invalid repository URL: ${plugin.repository}`); + } + + // Validate author matches PR author (for new plugins) + if (file.status === 'added' && plugin.author !== prAuthor) { + errors.push(`Author "${plugin.author}" must match PR author "${prAuthor}"`); + } + + // Validate tags if present + if (plugin.tags && !Array.isArray(plugin.tags)) { + errors.push('Tags must be an array'); + } + + } catch (error) { + errors.push(`Invalid JSON: ${error.message}`); + } + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Validate that a user can remove a plugin + */ + async validateRemoval(pluginId, prAuthor) { + const errors = []; + + try { + // Read the existing plugin file from disk + const content = await readFile(`./plugins/${pluginId}.json`, 'utf-8'); + const plugin = JSON.parse(content); + + // Check if the PR author is the plugin author + if (plugin.author !== prAuthor) { + errors.push(`Cannot remove plugin: you are not the author (author: ${plugin.author})`); + } + } catch (error) { + // If file doesn't exist, it's probably already removed + console.warn(`Could not validate removal of ${pluginId}:`, error.message); + } + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Extract file content from Gitea file object + * In a real implementation, you'd fetch the raw content from the PR + */ + extractFileContent(file) { + // This is a simplified version + // In production, fetch the actual file content from the PR's head branch + + // For now, return a placeholder that would need to be fetched + throw new Error('File content extraction not fully implemented - fetch from PR branch'); + } + + /** + * Validate semantic version format + */ + isValidVersion(version) { + return /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/.test(version); + } + + /** + * Validate URL format + */ + isValidUrl(url) { + try { + new URL(url); + return true; + } catch { + return false; + } + } +} diff --git a/src/ValidatorEnhanced.js b/src/ValidatorEnhanced.js new file mode 100644 index 0000000..0daffd1 --- /dev/null +++ b/src/ValidatorEnhanced.js @@ -0,0 +1,232 @@ +import { WebhookHandler } from './WebhookHandler.js'; +import fetch from 'node-fetch'; + +/** + * Enhanced validator that fetches actual file content from PR + */ +export class Validator { + constructor(webhookHandler) { + this.handler = webhookHandler; + this.requiredFields = ['id', 'name', 'version', 'description', 'author', 'repository']; + this.optionalFields = ['downloadUrl', 'homepage', 'tags', 'addedDate', 'dependencies']; + } + + /** + * Validate a PR's file changes + */ + async validatePR(files, prAuthor, prNumber) { + const errors = []; + const added = []; + const removed = []; + + // Check that all files are in the plugins/ directory + const invalidPaths = files.filter(f => !f.filename.startsWith('plugins/')); + if (invalidPaths.length > 0) { + errors.push(`Files outside plugins/ directory: ${invalidPaths.map(f => f.filename).join(', ')}`); + } + + // Check that all files are .json files + const nonJsonFiles = files.filter(f => + f.filename.startsWith('plugins/') && !f.filename.endsWith('.json') + ); + if (nonJsonFiles.length > 0) { + errors.push(`Non-JSON files in plugins/: ${nonJsonFiles.map(f => f.filename).join(', ')}`); + } + + // Process each changed file + for (const file of files) { + if (!file.filename.startsWith('plugins/') || !file.filename.endsWith('.json')) { + continue; + } + + const pluginId = file.filename.replace('plugins/', '').replace('.json', ''); + + if (file.status === 'added' || file.status === 'modified') { + // Fetch and validate the file content + try { + const content = await this.fetchPRFileContent(prNumber, file.filename); + const validation = await this.validatePluginContent(content, pluginId, prAuthor, file.status); + + if (!validation.valid) { + errors.push(...validation.errors.map(e => `${file.filename}: ${e}`)); + } else { + added.push(pluginId); + } + } catch (error) { + errors.push(`${file.filename}: ${error.message}`); + } + } else if (file.status === 'removed') { + // Validate removed plugins (check ownership) + try { + const canRemove = await this.validateRemoval(pluginId, prAuthor); + + if (!canRemove.valid) { + errors.push(...canRemove.errors.map(e => `${file.filename}: ${e}`)); + } else { + removed.push(pluginId); + } + } catch (error) { + errors.push(`${file.filename}: ${error.message}`); + } + } + } + + // Must have at least one change + if (files.length === 0) { + errors.push('PR has no file changes'); + } + + return { + valid: errors.length === 0, + errors, + added, + removed + }; + } + + /** + * Fetch file content from a PR + */ + async fetchPRFileContent(prNumber, filename) { + const pr = await this.getPRDetails(prNumber); + const headSha = pr.head.sha; + + const url = `${this.handler.giteaUrl}/api/v1/repos/${this.handler.repoOwner}/${this.handler.repoName}/contents/${filename}?ref=${headSha}`; + + const response = await fetch(url, { + headers: { 'Authorization': `token ${this.handler.giteaToken}` } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch file: ${response.statusText}`); + } + + const data = await response.json(); + const content = Buffer.from(data.content, 'base64').toString('utf-8'); + return content; + } + + /** + * Get PR details + */ + async getPRDetails(prNumber) { + const url = `${this.handler.giteaUrl}/api/v1/repos/${this.handler.repoOwner}/${this.handler.repoName}/pulls/${prNumber}`; + + const response = await fetch(url, { + headers: { 'Authorization': `token ${this.handler.giteaToken}` } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch PR: ${response.statusText}`); + } + + return await response.json(); + } + + /** + * Validate plugin JSON content + */ + async validatePluginContent(content, pluginId, prAuthor, status) { + const errors = []; + + try { + const plugin = JSON.parse(content); + + // Check required fields + for (const field of this.requiredFields) { + if (!plugin[field]) { + errors.push(`Missing required field: ${field}`); + } + } + + // Check that ID matches filename + if (plugin.id !== pluginId) { + errors.push(`Plugin ID "${plugin.id}" does not match filename "${pluginId}.json"`); + } + + // Validate version format (semver-ish) + if (plugin.version && !this.isValidVersion(plugin.version)) { + errors.push(`Invalid version format: ${plugin.version} (expected: X.Y.Z)`); + } + + // Validate repository URL + if (plugin.repository && !this.isValidUrl(plugin.repository)) { + errors.push(`Invalid repository URL: ${plugin.repository}`); + } + + // Validate author matches PR author (for new plugins) + if (status === 'added' && plugin.author !== prAuthor) { + errors.push(`Author "${plugin.author}" must match PR author "${prAuthor}"`); + } + + // Validate tags if present + if (plugin.tags && !Array.isArray(plugin.tags)) { + errors.push('Tags must be an array'); + } + + } catch (error) { + errors.push(`Invalid JSON: ${error.message}`); + } + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Validate that a user can remove a plugin + */ + async validateRemoval(pluginId, prAuthor) { + const errors = []; + + try { + // Read the existing plugin file from main branch + const url = `${this.handler.giteaUrl}/api/v1/repos/${this.handler.repoOwner}/${this.handler.repoName}/contents/plugins/${pluginId}.json`; + + const response = await fetch(url, { + headers: { 'Authorization': `token ${this.handler.giteaToken}` } + }); + + if (!response.ok) { + // If file doesn't exist on main, removal is fine + return { valid: true, errors: [] }; + } + + const data = await response.json(); + const content = Buffer.from(data.content, 'base64').toString('utf-8'); + const plugin = JSON.parse(content); + + // Check if the PR author is the plugin author + if (plugin.author !== prAuthor) { + errors.push(`Cannot remove plugin: you are not the author (author: ${plugin.author})`); + } + } catch (error) { + console.warn(`Could not validate removal of ${pluginId}:`, error.message); + } + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Validate semantic version format + */ + isValidVersion(version) { + return /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/.test(version); + } + + /** + * Validate URL format + */ + isValidUrl(url) { + try { + new URL(url); + return true; + } catch { + return false; + } + } +} diff --git a/src/WebhookHandler.js b/src/WebhookHandler.js new file mode 100644 index 0000000..09d4746 --- /dev/null +++ b/src/WebhookHandler.js @@ -0,0 +1,241 @@ +import crypto from 'crypto'; +import fetch from 'node-fetch'; +import { readdir, readFile } from 'fs/promises'; +import { join } from 'path'; +import { Validator } from './ValidatorEnhanced.js'; + +/** + * WebhookHandler - Processes Gitea webhooks for automated PR handling + */ +export class WebhookHandler { + constructor(options) { + this.giteaUrl = options.giteaUrl; + this.giteaToken = options.giteaToken; + this.webhookSecret = options.webhookSecret; + this.repoOwner = options.repoOwner; + this.repoName = options.repoName; + this.validator = new Validator(this); + } + + /** + * Verify webhook signature + */ + verifySignature(payload, signature) { + if (!this.webhookSecret) return true; // Skip if no secret configured + + const hmac = crypto.createHmac('sha256', this.webhookSecret); + const digest = hmac.update(JSON.stringify(payload)).digest('hex'); + return signature === digest; + } + + /** + * Handle pull request webhook + */ + async handlePullRequest(payload) { + const { action, pull_request, repository } = payload; + + console.log(`PR #${pull_request.number}: ${action} by ${pull_request.user.login}`); + + // Only process opened or synchronized PRs + if (action !== 'opened' && action !== 'synchronize') { + console.log('Skipping PR action:', action); + return; + } + + try { + // Get PR details + const prNumber = pull_request.number; + const prAuthor = pull_request.user.login; + const headBranch = pull_request.head.ref; + const baseBranch = pull_request.base.ref; + + // Get file changes in the PR + const files = await this.getPRFiles(prNumber); + + console.log(`PR files changed: ${files.map(f => `${f.filename} (${f.status})`).join(', ')}`); + + // Validate the PR + const validation = await this.validator.validatePR(files, prAuthor, prNumber); + + if (!validation.valid) { + console.log('Validation failed:', validation.errors); + await this.commentOnPR(prNumber, this.buildErrorComment(validation.errors)); + await this.labelPR(prNumber, 'validation-failed'); + return; + } + + // PR is valid, auto-approve and merge + console.log('✓ PR validation passed'); + await this.commentOnPR(prNumber, this.buildSuccessComment(validation)); + await this.labelPR(prNumber, 'auto-approved'); + await this.mergePR(prNumber); + + console.log(`✓ Auto-merged PR #${prNumber}`); + } catch (error) { + console.error('Error handling PR:', error); + await this.commentOnPR(pull_request.number, + `❌ **Automation Error**\n\nFailed to process this PR: ${error.message}` + ); + } + } + + /** + * Get files changed in a PR + */ + async getPRFiles(prNumber) { + const url = `${this.giteaUrl}/api/v1/repos/${this.repoOwner}/${this.repoName}/pulls/${prNumber}/files`; + const response = await fetch(url, { + headers: { 'Authorization': `token ${this.giteaToken}` } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch PR files: ${response.statusText}`); + } + + return await response.json(); + } + + /** + * Comment on a PR + */ + async commentOnPR(prNumber, body) { + const url = `${this.giteaUrl}/api/v1/repos/${this.repoOwner}/${this.repoName}/issues/${prNumber}/comments`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `token ${this.giteaToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ body }) + }); + + if (!response.ok) { + console.error('Failed to comment on PR:', response.statusText); + } + } + + /** + * Label a PR + */ + async labelPR(prNumber, label) { + const url = `${this.giteaUrl}/api/v1/repos/${this.repoOwner}/${this.repoName}/issues/${prNumber}/labels`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `token ${this.giteaToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ labels: [label] }) + }); + + if (!response.ok) { + console.error('Failed to label PR:', response.statusText); + } + } + + /** + * Merge a PR + */ + async mergePR(prNumber) { + const url = `${this.giteaUrl}/api/v1/repos/${this.repoOwner}/${this.repoName}/pulls/${prNumber}/merge`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `token ${this.giteaToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + Do: 'squash', + MergeMessageField: 'Auto-merged plugin directory update', + delete_branch_after_merge: true + }) + }); + + if (!response.ok) { + throw new Error(`Failed to merge PR: ${response.statusText}`); + } + + return await response.json(); + } + + /** + * Build error comment for PR + */ + buildErrorComment(errors) { + let comment = '❌ **Validation Failed**\n\nThis PR cannot be auto-merged due to the following issues:\n\n'; + + errors.forEach(error => { + comment += `- ${error}\n`; + }); + + comment += '\n**Rules:**\n'; + comment += '1. Only modify files in the `plugins/` directory\n'; + comment += '2. Only add or remove `.json` files\n'; + comment += '3. JSON files must match the plugin schema\n'; + comment += '4. You can only remove plugins you authored\n'; + comment += '5. Plugin ID must match the filename (without .json)\n'; + comment += '\nPlease fix these issues and push again.'; + + return comment; + } + + /** + * Build success comment for PR + */ + buildSuccessComment(validation) { + let comment = '✅ **Validation Passed**\n\nThis PR has been automatically approved and will be merged.\n\n'; + + if (validation.added.length > 0) { + comment += `**Added plugins:** ${validation.added.join(', ')}\n`; + } + + if (validation.removed.length > 0) { + comment += `**Removed plugins:** ${validation.removed.join(', ')}\n`; + } + + comment += '\nTo undo this change, create a new PR that reverses these changes.'; + + return comment; + } + + /** + * List all plugins from the plugins directory + */ + async listPlugins() { + const pluginsDir = './plugins'; + const files = await readdir(pluginsDir); + const plugins = []; + + for (const file of files) { + if (file.endsWith('.json')) { + try { + const content = await readFile(join(pluginsDir, file), 'utf-8'); + const plugin = JSON.parse(content); + plugins.push(plugin); + } catch (error) { + console.error(`Error reading plugin ${file}:`, error); + } + } + } + + return plugins; + } + + /** + * Get a specific plugin + */ + async getPlugin(pluginId) { + const pluginsDir = './plugins'; + const filePath = join(pluginsDir, `${pluginId}.json`); + + try { + const content = await readFile(filePath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + return null; + } + } +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..7e0521e --- /dev/null +++ b/src/server.js @@ -0,0 +1,86 @@ +import express from 'express'; +import { WebhookHandler } from './WebhookHandler.js'; +import { readFile } from 'fs/promises'; + +const app = express(); +const port = process.env.PORT || 3000; +const webhookSecret = process.env.WEBHOOK_SECRET || ''; + +app.use(express.json()); + +const handler = new WebhookHandler({ + giteaUrl: process.env.GITEA_URL || 'http://synbox.ruv.wtf:8418', + giteaToken: process.env.GITEA_TOKEN || '', + webhookSecret, + repoOwner: 'litruv', + repoName: 'Plugin-Directory' +}); + +/** + * Health check endpoint + */ +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +/** + * List all plugins endpoint + */ +app.get('/plugins', async (req, res) => { + try { + const plugins = await handler.listPlugins(); + res.json({ plugins, count: plugins.length }); + } catch (error) { + console.error('Error listing plugins:', error); + res.status(500).json({ error: 'Failed to list plugins' }); + } +}); + +/** + * Get specific plugin endpoint + */ +app.get('/plugins/:pluginId', async (req, res) => { + try { + const plugin = await handler.getPlugin(req.params.pluginId); + if (plugin) { + res.json(plugin); + } else { + res.status(404).json({ error: 'Plugin not found' }); + } + } catch (error) { + console.error('Error getting plugin:', error); + res.status(500).json({ error: 'Failed to get plugin' }); + } +}); + +/** + * Webhook endpoint for Gitea PR events + */ +app.post('/webhook', async (req, res) => { + try { + const signature = req.headers['x-gitea-signature']; + const event = req.headers['x-gitea-event']; + + if (!handler.verifySignature(req.body, signature)) { + console.warn('Invalid webhook signature'); + return res.status(401).json({ error: 'Invalid signature' }); + } + + console.log(`Received webhook event: ${event}`); + + if (event === 'pull_request') { + await handler.handlePullRequest(req.body); + } + + res.json({ status: 'processed' }); + } catch (error) { + console.error('Webhook error:', error); + res.status(500).json({ error: 'Webhook processing failed' }); + } +}); + +app.listen(port, () => { + console.log(`Plugin Directory server running on port ${port}`); + console.log(`Webhook endpoint: http://localhost:${port}/webhook`); + console.log(`Plugin list: http://localhost:${port}/plugins`); +});