Initial commit: Plugin Directory with automation
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -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
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
176
README.md
Normal file
176
README.md
Normal file
@@ -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
|
||||||
73
SCHEMA.md
Normal file
73
SCHEMA.md
Normal file
@@ -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
|
||||||
19
package.json
Normal file
19
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
plugins/example-plugin.json
Normal file
12
plugins/example-plugin.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
193
src/Validator.js
Normal file
193
src/Validator.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
232
src/ValidatorEnhanced.js
Normal file
232
src/ValidatorEnhanced.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
241
src/WebhookHandler.js
Normal file
241
src/WebhookHandler.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/server.js
Normal file
86
src/server.js
Normal file
@@ -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`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user