357 lines
17 KiB
YAML
Generated
357 lines
17 KiB
YAML
Generated
name: PocketBase Bot
|
|
|
|
on:
|
|
issue_comment:
|
|
types: [created]
|
|
|
|
permissions:
|
|
issues: write
|
|
pull-requests: write
|
|
contents: read
|
|
|
|
jobs:
|
|
pocketbase-bot:
|
|
runs-on: self-hosted
|
|
|
|
# Only act on /pocketbase commands
|
|
if: startsWith(github.event.comment.body, '/pocketbase')
|
|
|
|
steps:
|
|
- name: Execute PocketBase bot command
|
|
env:
|
|
POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }}
|
|
POCKETBASE_COLLECTION: ${{ secrets.POCKETBASE_COLLECTION }}
|
|
POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }}
|
|
POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }}
|
|
COMMENT_BODY: ${{ github.event.comment.body }}
|
|
COMMENT_ID: ${{ github.event.comment.id }}
|
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
REPO_OWNER: ${{ github.repository_owner }}
|
|
REPO_NAME: ${{ github.event.repository.name }}
|
|
ACTOR: ${{ github.event.comment.user.login }}
|
|
ACTOR_ASSOCIATION: ${{ github.event.comment.author_association }}
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
node << 'ENDSCRIPT'
|
|
(async function () {
|
|
const https = require('https');
|
|
const http = require('http');
|
|
const url = require('url');
|
|
|
|
// ── HTTP helper with redirect following ────────────────────────────
|
|
function request(fullUrl, opts, redirectCount) {
|
|
redirectCount = redirectCount || 0;
|
|
return new Promise(function (resolve, reject) {
|
|
const u = url.parse(fullUrl);
|
|
const isHttps = u.protocol === 'https:';
|
|
const body = opts.body;
|
|
const options = {
|
|
hostname: u.hostname,
|
|
port: u.port || (isHttps ? 443 : 80),
|
|
path: u.path,
|
|
method: opts.method || 'GET',
|
|
headers: opts.headers || {}
|
|
};
|
|
if (body) options.headers['Content-Length'] = Buffer.byteLength(body);
|
|
const lib = isHttps ? https : http;
|
|
const req = lib.request(options, function (res) {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
if (redirectCount >= 5) return reject(new Error('Too many redirects from ' + fullUrl));
|
|
const redirectUrl = url.resolve(fullUrl, res.headers.location);
|
|
res.resume();
|
|
resolve(request(redirectUrl, opts, redirectCount + 1));
|
|
return;
|
|
}
|
|
let data = '';
|
|
res.on('data', function (chunk) { data += chunk; });
|
|
res.on('end', function () {
|
|
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: data });
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
if (body) req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// ── GitHub API helpers ─────────────────────────────────────────────
|
|
const owner = process.env.REPO_OWNER;
|
|
const repo = process.env.REPO_NAME;
|
|
const issueNumber = parseInt(process.env.ISSUE_NUMBER, 10);
|
|
const commentId = parseInt(process.env.COMMENT_ID, 10);
|
|
const actor = process.env.ACTOR;
|
|
|
|
function ghRequest(path, method, body) {
|
|
const headers = {
|
|
'Authorization': 'Bearer ' + process.env.GITHUB_TOKEN,
|
|
'Accept': 'application/vnd.github+json',
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
'User-Agent': 'PocketBase-Bot'
|
|
};
|
|
const bodyStr = body ? JSON.stringify(body) : undefined;
|
|
if (bodyStr) headers['Content-Type'] = 'application/json';
|
|
return request('https://api.github.com' + path, { method: method || 'GET', headers, body: bodyStr });
|
|
}
|
|
|
|
async function addReaction(content) {
|
|
try {
|
|
await ghRequest(
|
|
'/repos/' + owner + '/' + repo + '/issues/comments/' + commentId + '/reactions',
|
|
'POST', { content }
|
|
);
|
|
} catch (e) {
|
|
console.warn('Could not add reaction:', e.message);
|
|
}
|
|
}
|
|
|
|
async function postComment(text) {
|
|
const res = await ghRequest(
|
|
'/repos/' + owner + '/' + repo + '/issues/' + issueNumber + '/comments',
|
|
'POST', { body: text }
|
|
);
|
|
if (!res.ok) console.warn('Could not post comment:', res.body);
|
|
}
|
|
|
|
// ── Permission check ───────────────────────────────────────────────
|
|
// author_association: OWNER = repo/org owner, MEMBER = org member (includes Contributors team)
|
|
const association = process.env.ACTOR_ASSOCIATION;
|
|
if (association !== 'OWNER' && association !== 'MEMBER') {
|
|
await addReaction('-1');
|
|
await postComment(
|
|
'❌ **PocketBase Bot**: @' + actor + ' is not authorized to use this command.\n' +
|
|
'Only org members (Contributors team) can use `/pocketbase`.'
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
// ── Acknowledge ────────────────────────────────────────────────────
|
|
await addReaction('eyes');
|
|
|
|
// ── Parse command ──────────────────────────────────────────────────
|
|
// Format: /pocketbase <slug> field1=value1 field2="value with spaces"
|
|
// Multiple field=value pairs are allowed on one line.
|
|
// Empty value (field=) sets the field to null (for nullable fields).
|
|
const commentBody = process.env.COMMENT_BODY || '';
|
|
const firstLine = commentBody.trim().split('\n')[0].trim();
|
|
const withoutCmd = firstLine.replace(/^\/pocketbase\s+/, '').trim();
|
|
|
|
if (!withoutCmd) {
|
|
await addReaction('-1');
|
|
await postComment(
|
|
'❌ **PocketBase Bot**: No slug or fields specified.\n\n' +
|
|
'**Usage:** `/pocketbase <slug> <field>=<value> [<field>=<value> ...]`\n\n' +
|
|
'**Examples:**\n' +
|
|
'```\n' +
|
|
'/pocketbase homeassistant documentation=https://www.home-assistant.io/docs\n' +
|
|
'/pocketbase homeassistant is_dev=false\n' +
|
|
'/pocketbase homeassistant is_disabled=true disable_message="Broken upstream, fix in progress"\n' +
|
|
'/pocketbase homeassistant description="My cool app" website=https://example.com\n' +
|
|
'/pocketbase homeassistant default_passwd=\n' +
|
|
'/pocketbase homeassistant github=owner/repo project_url=https://github.com/owner/repo\n' +
|
|
'```\n\n' +
|
|
'**Editable fields:**\n' +
|
|
'`name` `description` `logo` `documentation` `website` `project_url` `github`\n' +
|
|
'`config_path` `port` `default_user` `default_passwd`\n' +
|
|
'`updateable` `privileged` `has_arm` `is_dev`\n' +
|
|
'`is_disabled` `disable_message` `is_deleted` `deleted_message`'
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
const spaceIdx = withoutCmd.indexOf(' ');
|
|
const slug = (spaceIdx === -1 ? withoutCmd : withoutCmd.substring(0, spaceIdx)).trim();
|
|
const fieldsStr = spaceIdx === -1 ? '' : withoutCmd.substring(spaceIdx + 1).trim();
|
|
|
|
if (!fieldsStr) {
|
|
await addReaction('-1');
|
|
await postComment(
|
|
'❌ **PocketBase Bot**: No fields specified for slug `' + slug + '`.\n\n' +
|
|
'**Usage:** `/pocketbase <slug> <field>=<value>`'
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
// ── Allowed fields and their types ─────────────────────────────────
|
|
// Skipped: slug (identifier), script_created/updated (auto), categories/
|
|
// install_methods/notes/type (relations need IDs), github_data/
|
|
// install_methods_json/notes_json (auto-generated), execute_in (complex select),
|
|
// last_update_commit (auto from workflow), created (auto)
|
|
const ALLOWED_FIELDS = {
|
|
// display
|
|
name: 'string',
|
|
description: 'string',
|
|
logo: 'string',
|
|
// links
|
|
documentation: 'string',
|
|
website: 'string',
|
|
project_url: 'string',
|
|
github: 'string', // format: owner/repo
|
|
// runtime config
|
|
config_path: 'string',
|
|
port: 'number',
|
|
default_user: 'nullable_string',
|
|
default_passwd: 'nullable_string',
|
|
// flags
|
|
updateable: 'boolean',
|
|
privileged: 'boolean',
|
|
has_arm: 'boolean',
|
|
is_dev: 'boolean',
|
|
is_disabled: 'boolean',
|
|
disable_message: 'string',
|
|
is_deleted: 'boolean',
|
|
deleted_message: 'string',
|
|
};
|
|
|
|
// ── Field=value parser (handles quoted values) ─────────────────────
|
|
function parseFields(str) {
|
|
const fields = {};
|
|
let pos = 0;
|
|
while (pos < str.length) {
|
|
// skip whitespace
|
|
while (pos < str.length && /\s/.test(str[pos])) pos++;
|
|
if (pos >= str.length) break;
|
|
// read key
|
|
let keyStart = pos;
|
|
while (pos < str.length && str[pos] !== '=' && !/\s/.test(str[pos])) pos++;
|
|
const key = str.substring(keyStart, pos).trim();
|
|
if (!key || pos >= str.length || str[pos] !== '=') { pos++; continue; }
|
|
pos++; // skip '='
|
|
// read value
|
|
let value;
|
|
if (str[pos] === '"') {
|
|
pos++;
|
|
let valStart = pos;
|
|
while (pos < str.length && str[pos] !== '"') {
|
|
if (str[pos] === '\\') pos++;
|
|
pos++;
|
|
}
|
|
value = str.substring(valStart, pos).replace(/\\"/g, '"');
|
|
if (pos < str.length) pos++; // skip closing quote
|
|
} else {
|
|
let valStart = pos;
|
|
while (pos < str.length && !/\s/.test(str[pos])) pos++;
|
|
value = str.substring(valStart, pos);
|
|
}
|
|
fields[key] = value;
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
const parsedFields = parseFields(fieldsStr);
|
|
|
|
// ── Validate field names ───────────────────────────────────────────
|
|
const unknownFields = Object.keys(parsedFields).filter(function (f) { return !ALLOWED_FIELDS[f]; });
|
|
if (unknownFields.length > 0) {
|
|
await addReaction('-1');
|
|
await postComment(
|
|
'❌ **PocketBase Bot**: Unknown field(s): `' + unknownFields.join('`, `') + '`\n\n' +
|
|
'**Allowed fields:** `' + Object.keys(ALLOWED_FIELDS).join('`, `') + '`'
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (Object.keys(parsedFields).length === 0) {
|
|
await addReaction('-1');
|
|
await postComment('❌ **PocketBase Bot**: Could not parse any valid `field=value` pairs from the command.');
|
|
process.exit(0);
|
|
}
|
|
|
|
// ── Cast values to correct types ───────────────────────────────────
|
|
const payload = {};
|
|
for (const [key, rawVal] of Object.entries(parsedFields)) {
|
|
const type = ALLOWED_FIELDS[key];
|
|
if (type === 'boolean') {
|
|
if (rawVal === 'true') payload[key] = true;
|
|
else if (rawVal === 'false') payload[key] = false;
|
|
else {
|
|
await addReaction('-1');
|
|
await postComment('❌ **PocketBase Bot**: `' + key + '` must be `true` or `false`, got: `' + rawVal + '`');
|
|
process.exit(0);
|
|
}
|
|
} else if (type === 'number') {
|
|
const n = parseInt(rawVal, 10);
|
|
if (isNaN(n)) {
|
|
await addReaction('-1');
|
|
await postComment('❌ **PocketBase Bot**: `' + key + '` must be a number, got: `' + rawVal + '`');
|
|
process.exit(0);
|
|
}
|
|
payload[key] = n;
|
|
} else if (type === 'nullable_string') {
|
|
payload[key] = rawVal === '' ? null : rawVal;
|
|
} else {
|
|
payload[key] = rawVal;
|
|
}
|
|
}
|
|
|
|
// ── PocketBase: authenticate ───────────────────────────────────────
|
|
const raw = process.env.POCKETBASE_URL.replace(/\/$/, '');
|
|
const apiBase = /\/api$/i.test(raw) ? raw : raw + '/api';
|
|
const coll = process.env.POCKETBASE_COLLECTION;
|
|
|
|
const authRes = await request(apiBase + '/collections/users/auth-with-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
identity: process.env.POCKETBASE_ADMIN_EMAIL,
|
|
password: process.env.POCKETBASE_ADMIN_PASSWORD
|
|
})
|
|
});
|
|
if (!authRes.ok) {
|
|
await addReaction('-1');
|
|
await postComment('❌ **PocketBase Bot**: PocketBase authentication failed. CC @' + owner + '/maintainers');
|
|
process.exit(1);
|
|
}
|
|
const token = JSON.parse(authRes.body).token;
|
|
|
|
// ── PocketBase: find record by slug ────────────────────────────────
|
|
const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records';
|
|
const filter = "(slug='" + slug.replace(/'/g, "''") + "')";
|
|
const listRes = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', {
|
|
headers: { 'Authorization': token }
|
|
});
|
|
const list = JSON.parse(listRes.body);
|
|
const record = list.items && list.items[0];
|
|
|
|
if (!record) {
|
|
await addReaction('-1');
|
|
await postComment(
|
|
'❌ **PocketBase Bot**: No record found for slug `' + slug + '`.\n\n' +
|
|
'Make sure the script was already pushed to PocketBase (JSON must exist and have been synced).'
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
// ── PocketBase: patch record ───────────────────────────────────────
|
|
const patchRes = await request(recordsUrl + '/' + record.id, {
|
|
method: 'PATCH',
|
|
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!patchRes.ok) {
|
|
await addReaction('-1');
|
|
await postComment(
|
|
'❌ **PocketBase Bot**: PATCH failed for `' + slug + '`:\n```\n' + patchRes.body + '\n```'
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── Success ────────────────────────────────────────────────────────
|
|
await addReaction('+1');
|
|
const changesLines = Object.entries(payload)
|
|
.map(function ([k, v]) { return '- `' + k + '` → `' + JSON.stringify(v) + '`'; })
|
|
.join('\n');
|
|
await postComment(
|
|
'✅ **PocketBase Bot**: Updated **`' + slug + '`** successfully!\n\n' +
|
|
'**Changes applied:**\n' + changesLines + '\n\n' +
|
|
'*Executed by @' + actor + '*'
|
|
);
|
|
console.log('Success:', slug, payload);
|
|
|
|
})().catch(function (e) {
|
|
console.error('Fatal error:', e.message || e);
|
|
process.exit(1);
|
|
});
|
|
ENDSCRIPT
|
|
shell: bash
|