Skip to content

[Plugin] self_writing_plugin #527

[Plugin] self_writing_plugin

[Plugin] self_writing_plugin #527

name: Validate Plugin Issue
on:
issues:
types: [opened, edited, labeled]
issue_comment:
types: [created]
jobs:
validate:
# 触发条件:
# 1. Issue 创建且标题以 [Plugin] 开头(模板自动添加)
# 2. Issue 编辑/添加标签且有 plugin-submission 标签
# 3. 评论包含 /recheck 命令且有 plugin-submission 标签
if: >-
(github.event_name == 'issues' && github.event.action == 'opened' && github.actor != 'github-actions[bot]' &&
(startsWith(github.event.issue.title, '[Plugin') ||
contains(github.event.issue.labels.*.name, 'plugin-submission') ||
contains(github.event.issue.labels.*.name, 'plugin-modification') ||
contains(github.event.issue.labels.*.name, 'plugin-removal'))) ||
(github.event_name == 'issues' && github.event.action != 'opened' && github.actor != 'github-actions[bot]' &&
(contains(github.event.issue.labels.*.name, 'plugin-submission') ||
contains(github.event.issue.labels.*.name, 'plugin-modification') ||
contains(github.event.issue.labels.*.name, 'plugin-removal'))) ||
(github.event_name == 'issue_comment' &&
(contains(github.event.issue.labels.*.name, 'plugin-submission') ||
contains(github.event.issue.labels.*.name, 'plugin-modification') ||
contains(github.event.issue.labels.*.name, 'plugin-removal') ||
contains(github.event.issue.labels.*.name, 'validated') ||
contains(github.event.issue.labels.*.name, 'validation-failed') ||
contains(github.event.issue.labels.*.name, 'pending-validation')) &&
contains(github.event.comment.body, '/recheck'))
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Check recheck permission
if: github.event_name == 'issue_comment'
id: check-recheck-permission
uses: actions/github-script@v7
with:
script: |
const commenter = context.payload.comment.user.login;
const issueAuthor = context.payload.issue.user.login;
// Issue 作者可以 /recheck
if (commenter === issueAuthor) {
console.log(`✅ @${commenter} 是 Issue 作者,允许重新验证`);
return;
}
// 检查是否是维护者 (admin/write)
try {
const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: commenter
});
const allowedPermissions = ['admin', 'write'];
if (allowedPermissions.includes(permission.permission)) {
console.log(`✅ @${commenter} 是维护者 (${permission.permission}),允许重新验证`);
return;
}
} catch (e) {
// 不是协作者
}
// 无权限,添加评论并退出
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `⚠️ @${commenter} 只有 **Issue 作者** 或 **仓库维护者** 可以使用 \`/recheck\` 命令。`
});
core.setFailed(`用户 @${commenter} 没有权限使用 /recheck`);
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Parse and Validate Plugin
id: validate
uses: actions/github-script@v7
with:
script: |
const https = require('https');
const fs = require('fs');
// ========== 解析 Issue 内容 ==========
const body = context.payload.issue.body;
console.log('Issue body:', body);
const labels = context.payload.issue.labels.map(label => label.name);
const requestType = labels.includes('plugin-modification')
? 'modify'
: labels.includes('plugin-removal')
? 'remove'
: 'add';
const pluginIdMatch = body.match(/### 插件 ID \/ Plugin ID\s*\n\s*\n(.+)/);
const currentPluginIdMatch = body.match(/### 当前插件 ID \/ Current Plugin ID\s*\n\s*\n(.+)/);
const newPluginIdMatch = body.match(/### 新插件 ID \/ New Plugin ID\s*\n\s*\n(.+)/);
const addRepoUrlMatch = body.match(/### 仓库地址 \/ Repository URL\s*\n\s*\n(.+)/);
const modifyRepoUrlMatch = body.match(/### 新的仓库地址 \/ New Repository URL\s*\n\s*\n(.+)/);
if (requestType === 'modify') {
if (!currentPluginIdMatch || !newPluginIdMatch) {
core.setOutput('status', 'error');
core.setOutput('message', '无法解析 Issue 内容,请使用 Issue 模板提交。\n\n请确保填写了「当前插件 ID」和「新插件 ID」字段。');
return;
}
} else if (!pluginIdMatch) {
core.setOutput('status', 'error');
core.setOutput('message', '无法解析 Issue 内容,请使用 Issue 模板提交。\n\n请确保填写了「插件 ID」字段。');
return;
}
const pluginId = requestType === 'modify'
? newPluginIdMatch[1].trim()
: pluginIdMatch[1].trim();
const currentPluginId = requestType === 'modify'
? currentPluginIdMatch[1].trim()
: pluginId;
const repoUrl = requestType === 'modify'
? modifyRepoUrlMatch && modifyRepoUrlMatch[1].trim()
: addRepoUrlMatch && addRepoUrlMatch[1].trim();
console.log(`Plugin ID: ${pluginId}`);
if (requestType === 'modify') {
console.log(`Current Plugin ID: ${currentPluginId}`);
}
if (repoUrl) {
console.log(`Repository URL: ${repoUrl}`);
}
// 保存解析结果供后续使用
core.setOutput('plugin_id', pluginId);
if (requestType === 'modify') {
core.setOutput('current_plugin_id', currentPluginId);
}
if (repoUrl) {
core.setOutput('repo_url', repoUrl);
}
core.setOutput('request_type', requestType);
if (requestType === 'add' && !repoUrl) {
core.setOutput('status', 'error');
core.setOutput('message', '无法解析 Issue 内容,请使用 Issue 模板提交。\n\n请确保填写了仓库地址字段。');
return;
}
// ========== 验证 URL 格式 ==========
const urlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/;
if (repoUrl && !urlPattern.test(repoUrl)) {
core.setOutput('status', 'error');
core.setOutput('message', `**无效的仓库 URL 格式**\n\n提供的 URL: \`${repoUrl}\`\n\n要求格式: \`https://github.com/username/repo-name\`\n\n❌ 不要包含 \`.git\` 后缀\n❌ 不要使用 SSH 地址`);
return;
}
// ========== 验证插件 ID 格式 ==========
const idPattern = /^[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)+$/;
if (!idPattern.test(pluginId)) {
core.setOutput('status', 'error');
core.setOutput('message', `**无效的插件 ID 格式**\n\n提供的 ID: \`${pluginId}\`\n\n必须使用字母、数字、下划线,并以点号或短横线分隔,例如 \`github.username.my-plugin\``);
return;
}
if (requestType === 'modify' && !idPattern.test(currentPluginId)) {
core.setOutput('status', 'error');
core.setOutput('message', `**无效的当前插件 ID 格式**\n\n提供的 ID: \`${currentPluginId}\`\n\n必须使用字母、数字、下划线,并以点号或短横线分隔,例如 \`github.username.my-plugin\``);
return;
}
// ========== 检查重复 ==========
const plugins = JSON.parse(fs.readFileSync('plugins.json', 'utf8'));
const existingPlugin = requestType === 'modify'
? plugins.find(p => p.id === currentPluginId)
: plugins.find(p => p.id === pluginId);
if (requestType === 'add') {
const duplicateId = plugins.find(p => p.id === pluginId);
if (duplicateId) {
core.setOutput('status', 'error');
core.setOutput('message', `**插件 ID 已存在**\n\n\`${pluginId}\` 已被注册。\n\n请使用不同的 ID,建议格式:\`您的用户名.插件名\``);
return;
}
const duplicateUrl = plugins.find(p => p.repositoryUrl === repoUrl);
if (duplicateUrl) {
core.setOutput('status', 'error');
core.setOutput('message', `**仓库 URL 已存在**\n\n${repoUrl}\n\n该仓库已被注册为插件 \`${duplicateUrl.id}\``);
return;
}
}
if (requestType !== 'add') {
if (!existingPlugin) {
core.setOutput('status', 'error');
core.setOutput('message', `**插件 ID 不存在**\n\n\`${requestType === 'modify' ? currentPluginId : pluginId}\` 未在插件中心登记。\n\n请确认插件 ID 是否正确。`);
return;
}
core.setOutput('existing_repo_url', existingPlugin.repositoryUrl);
}
let effectiveRepoUrl = repoUrl;
if (requestType === 'modify') {
effectiveRepoUrl = repoUrl || existingPlugin.repositoryUrl;
const hasIdChange = currentPluginId !== pluginId;
const hasRepoChange = Boolean(repoUrl && repoUrl !== existingPlugin.repositoryUrl);
if (!hasIdChange && !hasRepoChange) {
core.setOutput('status', 'error');
core.setOutput('message', `**未检测到修改内容**\n\n当前插件 ID: \`${currentPluginId}\`\n当前仓库地址: \`${existingPlugin.repositoryUrl}\`\n\n请至少修改插件 ID 或仓库地址。`);
return;
}
if (hasIdChange) {
const duplicateId = plugins.find(p => p.id === pluginId);
if (duplicateId) {
core.setOutput('status', 'error');
core.setOutput('message', `**新插件 ID 已存在**\n\n\`${pluginId}\` 已被注册。\n\n请使用不同的插件 ID。`);
return;
}
}
if (repoUrl) {
const duplicateUrl = plugins.find(p => p.repositoryUrl === repoUrl && p.id !== currentPluginId);
if (duplicateUrl) {
core.setOutput('status', 'error');
core.setOutput('message', `**仓库 URL 已被占用**\n\n${repoUrl}\n\n该仓库已被注册为插件 \`${duplicateUrl.id}\``);
return;
}
}
}
if (requestType === 'remove') {
const successMessage = `**移除请求验证通过**\n\n| 字段 | 值 |\n|------|----|\n| 插件 ID | ${pluginId} |\n| 当前仓库地址 | ${existingPlugin.repositoryUrl} |\n\n---\n\n**维护者操作:**\n- \`/approve\` - 批准并移除此插件\n- \`/reject 原因\` - 拒绝此请求`;
core.setOutput('status', 'success');
core.setOutput('message', successMessage);
return;
}
// ========== 获取 Manifest ==========
const branches = ['main', 'master', 'dev', 'develop'];
async function fetchManifest(repo) {
const fetchErrors = [];
async function fetchFromBranch(branch) {
const rawUrl = repo.replace('github.com', 'raw.githubusercontent.com') + `/refs/heads/${branch}/_manifest.json`;
console.log(`尝试从 ${branch} 分支获取: ${rawUrl}`);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('请求超时 (10秒)'));
}, 10000);
const req = https.get(rawUrl, res => {
clearTimeout(timeout);
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error('JSON 解析失败 - 请检查文件格式'));
}
});
});
req.on('error', err => {
clearTimeout(timeout);
reject(new Error(`网络错误: ${err.message}`));
});
});
}
for (const branch of branches) {
try {
const manifest = await fetchFromBranch(branch);
console.log(`✅ 成功从 ${branch} 分支获取到清单文件`);
return { manifest, branch };
} catch (e) {
fetchErrors.push(`- \`${branch}\`: ${e.message}`);
console.log(`❌ ${branch} 分支获取失败: ${e.message}`);
}
}
throw new Error(`无法从任何分支获取 _manifest.json 文件。\n尝试的分支:\n${fetchErrors.join('\n')}\n\n请检查:\n1. 文件名是否为 \`_manifest.json\`(注意下划线)\n2. 仓库是否为**公开**仓库\n3. 文件是否在上述分支的根目录中`);
}
let manifestResult;
try {
manifestResult = await fetchManifest(effectiveRepoUrl);
} catch (error) {
core.setOutput('status', 'error');
core.setOutput('message', `**无法获取 \`_manifest.json\` 文件**\n\n${error.message}`);
return;
}
const manifest = manifestResult.manifest;
const successBranch = manifestResult.branch;
// ========== 验证 Manifest 结构 ==========
const warnings = [];
// 检查空或无效
if (!manifest || typeof manifest !== 'object') {
core.setOutput('status', 'error');
core.setOutput('message', '`_manifest.json` 文件为空或格式无效');
return;
}
// 检查 manifest v2 必需字段
const requiredFields = [
'manifest_version',
'id',
'name',
'version',
'description',
'author',
'license',
'urls',
'host_application',
'sdk',
'capabilities',
'i18n'
];
const missingFields = requiredFields.filter(f => !manifest[f]);
if (missingFields.length > 0) {
core.setOutput('status', 'error');
core.setOutput('message', `**\`_manifest.json\` 缺少 manifest v2 必需字段**\n\n缺少: ${missingFields.map(f => '\`' + f + '\`').join(', ')}\n\n请参考 [Manifest 文档](https://docs.mai-mai.org/develop/plugin-dev/manifest) 添加这些字段。`);
return;
}
if (manifest.manifest_version !== 2) {
core.setOutput('status', 'error');
core.setOutput('message', '**`manifest_version` 必须为 `2`**\n\n新提交插件请升级为 `_manifest.json` v2。');
return;
}
const legacyFields = ['homepage_url', 'repository_url', 'categories', 'keywords', 'plugin_info', 'default_locale', 'locales_path'];
const legacyFieldsInManifest = legacyFields.filter(f => Object.prototype.hasOwnProperty.call(manifest, f));
if (legacyFieldsInManifest.length > 0) {
core.setOutput('status', 'error');
core.setOutput('message', `**\`_manifest.json\` 包含旧版或展示侧字段**\n\n请移除: ${legacyFieldsInManifest.map(f => '\`' + f + '\`').join(', ')}\n\nmanifest v2 使用 \`urls.repository\` / \`urls.homepage\` 等字段;\`categories\` 不属于 Host 严格校验的 v2 字段。`);
return;
}
const semverRegex = /^\d+\.\d+\.\d+$/;
const pluginIdRegex = /^[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)+$/;
const isObject = value => value && typeof value === 'object' && !Array.isArray(value);
const isHttpUrl = value => typeof value === 'string' && /^https?:\/\/.+/.test(value);
if (!pluginIdRegex.test(String(manifest.id || ''))) {
core.setOutput('status', 'error');
core.setOutput('message', '**`id` 字段格式错误**\n\n必须使用字母、数字、下划线,并以点号或短横线分隔,例如 `github.username.my-plugin`。');
return;
}
if (!semverRegex.test(String(manifest.version || ''))) {
core.setOutput('status', 'error');
core.setOutput('message', `**\`version\` 字段格式错误**\n\n当前值: \`${manifest.version}\`\n\n必须为三段式版本号,例如 \`1.0.0\`。`);
return;
}
// 验证 author 结构
if (!isObject(manifest.author) || !manifest.author.name || !isHttpUrl(manifest.author.url)) {
core.setOutput('status', 'error');
core.setOutput('message', '**\`author\` 字段格式错误**\n\n必须是包含 `name` 和 HTTP/HTTPS `url` 的对象:\n```json\n"author": {\n "name": "作者名",\n "url": "https://github.com/username"\n}\n```');
return;
}
// 验证 urls 结构
if (!isObject(manifest.urls) || !isHttpUrl(manifest.urls.repository)) {
core.setOutput('status', 'error');
core.setOutput('message', '**\`urls\` 字段格式错误**\n\n必须包含 HTTP/HTTPS 格式的 `repository`:\n```json\n"urls": {\n "repository": "https://github.com/username/my-plugin"\n}\n```');
return;
}
for (const optionalUrlField of ['homepage', 'documentation', 'issues']) {
if (manifest.urls[optionalUrlField] !== undefined && !isHttpUrl(manifest.urls[optionalUrlField])) {
core.setOutput('status', 'error');
core.setOutput('message', `**\`urls.${optionalUrlField}\` 字段格式错误**\n\n如果填写该字段,必须使用 HTTP/HTTPS URL。`);
return;
}
}
for (const rangeField of ['host_application', 'sdk']) {
const rangeValue = manifest[rangeField];
if (!isObject(rangeValue) || !semverRegex.test(String(rangeValue.min_version || '')) || !semverRegex.test(String(rangeValue.max_version || ''))) {
core.setOutput('status', 'error');
core.setOutput('message', `**\`${rangeField}\` 字段格式错误**\n\n必须包含三段式版本号 \`min_version\` 和 \`max_version\`,例如 \`1.0.0\`。`);
return;
}
}
if (!Array.isArray(manifest.capabilities) || manifest.capabilities.some(item => !String(item || '').trim())) {
core.setOutput('status', 'error');
core.setOutput('message', '**`capabilities` 字段格式错误**\n\n必须是字符串数组;没有额外能力时请填写空数组 `[]`。');
return;
}
if (!isObject(manifest.i18n) || !String(manifest.i18n.default_locale || '').trim()) {
core.setOutput('status', 'error');
core.setOutput('message', '**`i18n` 字段格式错误**\n\n必须至少包含 `default_locale`,例如 `{\"default_locale\": \"zh-CN\", \"supported_locales\": [\"zh-CN\"]}`。');
return;
}
// ========== 警告检查(不阻止提交)==========
if (pluginId !== manifest.id) {
warnings.push(`Issue 中填写的插件 ID \`${pluginId}\` 与 manifest.id \`${manifest.id}\` 不一致,建议保持一致。`);
}
if (requestType === 'modify') {
let existingManifestResult;
try {
existingManifestResult = await fetchManifest(existingPlugin.repositoryUrl);
} catch (error) {
core.setOutput('status', 'error');
core.setOutput('message', `**无法获取现有插件的 \`_manifest.json\` 文件**\n\n${error.message}`);
return;
}
const existingManifest = existingManifestResult.manifest;
if (!existingManifest || typeof existingManifest !== 'object') {
core.setOutput('status', 'error');
core.setOutput('message', '**现有插件的 _manifest.json 格式无效**');
return;
}
if (typeof existingManifest.author !== 'object' || !existingManifest.author.name || !existingManifest.author.url) {
core.setOutput('status', 'error');
core.setOutput('message', '**现有插件的 `author` 字段格式错误,无法验证作者一致性**');
return;
}
if (existingManifest.author.name !== manifest.author.name || existingManifest.author.url !== manifest.author.url) {
core.setOutput('status', 'error');
core.setOutput('message', `**作者信息不允许修改**\n\n当前作者: \`${existingManifest.author.name}\` (${existingManifest.author.url})\n新作者: \`${manifest.author.name}\` (${manifest.author.url})`);
return;
}
}
// ========== 验证成功 ==========
let successMessage = '';
if (requestType === 'add') {
successMessage = `**插件信息:**\n| 字段 | 值 |\n|------|----|\n| Manifest ID | ${manifest.id} |\n| 名称 | ${manifest.name} |\n| 版本 | ${manifest.version} |\n| 作者 | ${manifest.author.name} |\n| 许可证 | ${manifest.license} |\n| 描述 | ${manifest.description} |\n| 分支 | ${successBranch} |`;
} else {
successMessage = `**修改请求验证通过**\n| 字段 | 值 |\n|------|----|\n| 当前插件 ID | ${currentPluginId} |\n| 新插件 ID | ${pluginId} |\n| 原仓库地址 | ${existingPlugin.repositoryUrl} |\n| 新仓库地址 | ${repoUrl || '未修改'} |\n| 作者 | ${manifest.author.name} |\n| 分支 | ${successBranch} |`;
}
if (warnings.length > 0) {
successMessage += `\n\n**⚠️ 警告 (不影响提交):**\n${warnings.map(w => '- ' + w).join('\n')}`;
}
const approveAction = requestType === 'add'
? '批准并添加此插件'
: requestType === 'modify'
? '批准并更新插件仓库信息'
: '批准并移除此插件';
successMessage += `\n\n---\n\n**维护者操作:**\n- \`/approve\` - ${approveAction}\n- \`/reject 原因\` - 拒绝此请求\n\n**提交者操作:**\n- 如果修改了 manifest,可以评论 \`/recheck\` 重新验证`;
core.setOutput('status', 'success');
core.setOutput('message', successMessage);
core.setOutput('manifest_name', manifest.name);
core.setOutput('manifest_version', manifest.version);
core.setOutput('manifest_author', manifest.author.name);
core.setOutput('manifest_license', manifest.license);
- name: Remove old status labels
uses: actions/github-script@v7
with:
script: |
const labels = context.payload.issue.labels.map(l => l.name);
const statusLabels = ['validated', 'validation-failed', 'pending-validation'];
for (const label of statusLabels) {
if (labels.includes(label)) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: label
});
} catch (e) {
// Label might not exist, ignore
}
}
}
- name: Add status label and comment
uses: actions/github-script@v7
env:
VALIDATION_STATUS: ${{ steps.validate.outputs.status }}
VALIDATION_MESSAGE: ${{ steps.validate.outputs.message }}
EVENT_NAME: ${{ github.event_name }}
with:
script: |
const status = process.env.VALIDATION_STATUS;
const message = process.env.VALIDATION_MESSAGE;
const isRecheck = process.env.EVENT_NAME === 'issue_comment';
// 确定标签
const label = status === 'success' ? 'validated' : 'validation-failed';
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: [label]
});
// 构建评论
const emoji = status === 'success' ? '✅' : '❌';
const title = status === 'success' ? '验证通过 / Validation Passed' : '验证失败 / Validation Failed';
const recheckNote = isRecheck ? '\n\n> 🔄 此为重新验证结果' : '';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `## ${emoji} ${title}${recheckNote}\n\n${message}`
});