194 lines
5.5 KiB
JavaScript
194 lines
5.5 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|