From b96c20db88c4cea897b7011072ffb20fe78fbb69 Mon Sep 17 00:00:00 2001 From: "CanbiZ (MickLesk)" <47820557+MickLesk@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:05:12 +0100 Subject: [PATCH 1/5] feat(workflows): add PocketBase Bot for contributor commands via issue comments --- .github/workflows/pocketbase-bot.yml | 336 +++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 .github/workflows/pocketbase-bot.yml diff --git a/.github/workflows/pocketbase-bot.yml b/.github/workflows/pocketbase-bot.yml new file mode 100644 index 000000000..c5ef5b932 --- /dev/null +++ b/.github/workflows/pocketbase-bot.yml @@ -0,0 +1,336 @@ +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 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 = [= ...]`\n\n' + + '**Examples:**\n' + + '```\n' + + '/pocketbase homeassistant documentation=https://www.home-assistant.io/docs\n' + + '/pocketbase homeassistant is_dev=false\n' + + '/pocketbase homeassistant description="My cool app" website=https://example.com\n' + + '/pocketbase homeassistant default_passwd=\n' + + '```' + ); + 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 =`' + ); + process.exit(0); + } + + // ── Allowed fields and their types ───────────────────────────────── + const ALLOWED_FIELDS = { + documentation: 'string', + website: 'string', + logo: 'string', + description: 'string', + config_path: 'string', + port: 'number', + default_user: 'nullable_string', + default_passwd: 'nullable_string', + is_dev: 'boolean', + is_deleted: 'boolean', + updateable: 'boolean', + privileged: 'boolean', + version: 'string', + changelog: '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 From 3981500e655f897493975f010b1de329399eb96c Mon Sep 17 00:00:00 2001 From: "CanbiZ (MickLesk)" <47820557+MickLesk@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:13:10 +0100 Subject: [PATCH 2/5] fix(pocketbase-bot): align ALLOWED_FIELDS with actual PocketBase schema --- .github/workflows/pocketbase-bot.yml | 50 +++++++++++++++++++--------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/.github/workflows/pocketbase-bot.yml b/.github/workflows/pocketbase-bot.yml index c5ef5b932..db2650122 100644 --- a/.github/workflows/pocketbase-bot.yml +++ b/.github/workflows/pocketbase-bot.yml @@ -144,9 +144,16 @@ jobs: '```\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); } @@ -165,21 +172,34 @@ jobs: } // ── 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 = { - documentation: 'string', - website: 'string', - logo: 'string', - description: 'string', - config_path: 'string', - port: 'number', - default_user: 'nullable_string', - default_passwd: 'nullable_string', - is_dev: 'boolean', - is_deleted: 'boolean', - updateable: 'boolean', - privileged: 'boolean', - version: 'string', - changelog: 'string' + // 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) ───────────────────── From 599aa97a922792b50cafc8df5a33c50f5db6b0f9 Mon Sep 17 00:00:00 2001 From: "CanbiZ (MickLesk)" <47820557+MickLesk@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:24:10 +0100 Subject: [PATCH 3/5] feat(pocketbase-bot): add note add/edit/remove/list subcommands --- .github/workflows/pocketbase-bot.yml | 559 +++++++++++++++++++-------- 1 file changed, 397 insertions(+), 162 deletions(-) diff --git a/.github/workflows/pocketbase-bot.yml b/.github/workflows/pocketbase-bot.yml index db2650122..42a499f5f 100644 --- a/.github/workflows/pocketbase-bot.yml +++ b/.github/workflows/pocketbase-bot.yml @@ -128,162 +128,48 @@ jobs: await addReaction('eyes'); // ── Parse command ────────────────────────────────────────────────── - // Format: /pocketbase 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). + // Formats: + // /pocketbase field=value [field=value ...] ← field updates + // /pocketbase note list ← show notes + // /pocketbase note add "" ← add note + // /pocketbase note edit "" "" ← edit note text + // /pocketbase note remove "" ← remove note const commentBody = process.env.COMMENT_BODY || ''; const firstLine = commentBody.trim().split('\n')[0].trim(); const withoutCmd = firstLine.replace(/^\/pocketbase\s+/, '').trim(); + const HELP_TEXT = + '**Field update:** `/pocketbase field=value [field=value ...]`\n\n' + + '**Note management:**\n' + + '```\n' + + '/pocketbase note list\n' + + '/pocketbase note add ""\n' + + '/pocketbase note edit "" ""\n' + + '/pocketbase note remove ""\n' + + '```\n\n' + + '**Editable fields:** `name` `description` `logo` `documentation` `website` `project_url` `github` ' + + '`config_path` `port` `default_user` `default_passwd` ' + + '`updateable` `privileged` `has_arm` `is_dev` ' + + '`is_disabled` `disable_message` `is_deleted` `deleted_message`'; + if (!withoutCmd) { await addReaction('-1'); - await postComment( - '❌ **PocketBase Bot**: No slug or fields specified.\n\n' + - '**Usage:** `/pocketbase = [= ...]`\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`' - ); + await postComment('❌ **PocketBase Bot**: No slug or command specified.\n\n' + HELP_TEXT); 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(); + const rest = spaceIdx === -1 ? '' : withoutCmd.substring(spaceIdx + 1).trim(); - if (!fieldsStr) { + if (!rest) { await addReaction('-1'); - await postComment( - '❌ **PocketBase Bot**: No fields specified for slug `' + slug + '`.\n\n' + - '**Usage:** `/pocketbase =`' - ); + await postComment('❌ **PocketBase Bot**: No command specified for slug `' + slug + '`.\n\n' + HELP_TEXT); 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 ─────────────────────────────────────── + // ── PocketBase: authenticate (shared by all paths) ───────────────── const raw = process.env.POCKETBASE_URL.replace(/\/$/, ''); const apiBase = /\/api$/i.test(raw) ? raw : raw + '/api'; const coll = process.env.POCKETBASE_COLLECTION; @@ -303,7 +189,7 @@ jobs: } const token = JSON.parse(authRes.body).token; - // ── PocketBase: find record by slug ──────────────────────────────── + // ── PocketBase: find record by slug (shared by all paths) ────────── const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records'; const filter = "(slug='" + slug.replace(/'/g, "''") + "')"; const listRes = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', { @@ -321,33 +207,382 @@ jobs: 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) - }); + // ── Route: note subcommand vs field=value ────────────────────────── + const noteMatch = rest.match(/^note\s+(list|add|edit|remove)\b/i); - if (!patchRes.ok) { - await addReaction('-1'); + if (noteMatch) { + // ── NOTE SUBCOMMAND ────────────────────────────────────────────── + const noteAction = noteMatch[1].toLowerCase(); + const noteArgsStr = rest.substring(noteMatch[0].length).trim(); + + // Load note types from PocketBase + const noteTypeToId = {}; + const noteTypeToName = {}; + try { + const ntRes = await request(apiBase + '/collections/z_ref_note_types/records?perPage=500', { headers: { 'Authorization': token } }); + if (ntRes.ok) { + JSON.parse(ntRes.body).items.forEach(function (item) { + if (item.type != null) { + noteTypeToId[item.type.toLowerCase()] = item.id; + noteTypeToName[item.id] = item.type; + } + }); + } + } catch (e) { console.warn('z_ref_note_types:', e.message); } + + const VALID_NOTE_TYPES = Object.keys(noteTypeToId); + + // Token parser: unquoted-word OR "quoted string" (supports \" escapes) + function parseNoteTokens(str) { + const tokens = []; + let pos = 0; + while (pos < str.length) { + while (pos < str.length && /\s/.test(str[pos])) pos++; + if (pos >= str.length) break; + if (str[pos] === '"') { + pos++; + let start = pos; + while (pos < str.length && str[pos] !== '"') { + if (str[pos] === '\\') pos++; + pos++; + } + tokens.push(str.substring(start, pos).replace(/\\"/g, '"')); + if (pos < str.length) pos++; + } else { + let start = pos; + while (pos < str.length && !/\s/.test(str[pos])) pos++; + tokens.push(str.substring(start, pos)); + } + } + return tokens; + } + + // Helper: fetch record with expanded notes relation + async function fetchExpandedNotes() { + const r = await request(recordsUrl + '/' + record.id + '?expand=notes', { headers: { 'Authorization': token } }); + const rec = JSON.parse(r.body); + return { rec, notes: (rec.expand && rec.expand.notes) || [] }; + } + + // Helper: format notes list for display + function formatNotesList(notes) { + if (notes.length === 0) return '*None*'; + return notes.map(function (n, i) { + return (i + 1) + '. **`' + (noteTypeToName[n.type] || n.type) + '`**: ' + n.text; + }).join('\n'); + } + + if (noteAction === 'list') { + // ── note list ──────────────────────────────────────────────── + const { notes } = await fetchExpandedNotes(); + await addReaction('+1'); + await postComment( + 'ℹ️ **PocketBase Bot**: Notes for **`' + slug + '`** (' + notes.length + ' total)\n\n' + + formatNotesList(notes) + ); + + } else if (noteAction === 'add') { + // ── note add "" ────────────────────────────────── + const tokens = parseNoteTokens(noteArgsStr); + if (tokens.length < 2) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: `note add` requires `` and `""`.\n\n' + + '**Usage:** `/pocketbase ' + slug + ' note add ""`\n' + + '**Valid types:** `' + VALID_NOTE_TYPES.join('`, `') + '`' + ); + process.exit(0); + } + const noteType = tokens[0].toLowerCase(); + const noteText = tokens.slice(1).join(' '); + const typeId = noteTypeToId[noteType]; + if (!typeId) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: Unknown note type `' + noteType + '`.\n' + + '**Valid types:** `' + VALID_NOTE_TYPES.join('`, `') + '`' + ); + process.exit(0); + } + // POST new note to script_notes + const postNoteRes = await request(apiBase + '/collections/script_notes/records', { + method: 'POST', + headers: { 'Authorization': token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: noteText, type: typeId, script: record.id }) + }); + if (!postNoteRes.ok) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: Failed to create note:\n```\n' + postNoteRes.body + '\n```'); + process.exit(1); + } + const newNoteId = JSON.parse(postNoteRes.body).id; + // PATCH script to include new note in relation + const existingNoteIds = Array.isArray(record.notes) ? record.notes : []; + const patchLinkRes = await request(recordsUrl + '/' + record.id, { + method: 'PATCH', + headers: { 'Authorization': token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ notes: [...existingNoteIds, newNoteId] }) + }); + if (!patchLinkRes.ok) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: Note created but failed to link to script:\n```\n' + patchLinkRes.body + '\n```'); + process.exit(1); + } + await addReaction('+1'); + await postComment( + '✅ **PocketBase Bot**: Added note to **`' + slug + '`**\n\n' + + '- **Type:** `' + noteType + '`\n' + + '- **Text:** ' + noteText + '\n\n' + + '*Executed by @' + actor + '*' + ); + + } else if (noteAction === 'edit') { + // ── note edit "" "" ──────────────── + const tokens = parseNoteTokens(noteArgsStr); + if (tokens.length < 3) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: `note edit` requires ``, `""`, and `""`.\n\n' + + '**Usage:** `/pocketbase ' + slug + ' note edit "" ""`\n' + + '**Valid types:** `' + VALID_NOTE_TYPES.join('`, `') + '`\n\n' + + 'Use `/pocketbase ' + slug + ' note list` to see current notes.' + ); + process.exit(0); + } + const noteType = tokens[0].toLowerCase(); + const oldText = tokens[1]; + const newText = tokens[2]; + const typeId = noteTypeToId[noteType]; + if (!typeId) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: Unknown note type `' + noteType + '`.\n' + + '**Valid types:** `' + VALID_NOTE_TYPES.join('`, `') + '`' + ); + process.exit(0); + } + const { notes } = await fetchExpandedNotes(); + const matchingNote = notes.find(function (n) { return n.type === typeId && n.text === oldText; }); + if (!matchingNote) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: No `' + noteType + '` note found with that exact text.\n\n' + + '**Current notes for `' + slug + '`:**\n' + formatNotesList(notes) + ); + process.exit(0); + } + // PATCH the note record directly + const patchNoteRes = await request(apiBase + '/collections/script_notes/records/' + matchingNote.id, { + method: 'PATCH', + headers: { 'Authorization': token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: newText }) + }); + if (!patchNoteRes.ok) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: Failed to update note:\n```\n' + patchNoteRes.body + '\n```'); + process.exit(1); + } + await addReaction('+1'); + await postComment( + '✅ **PocketBase Bot**: Edited note in **`' + slug + '`**\n\n' + + '- **Type:** `' + noteType + '`\n' + + '- **Old:** ' + oldText + '\n' + + '- **New:** ' + newText + '\n\n' + + '*Executed by @' + actor + '*' + ); + + } else if (noteAction === 'remove') { + // ── note remove "" ─────────────────────────────── + const tokens = parseNoteTokens(noteArgsStr); + if (tokens.length < 2) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: `note remove` requires `` and `""`.\n\n' + + '**Usage:** `/pocketbase ' + slug + ' note remove ""`\n' + + '**Valid types:** `' + VALID_NOTE_TYPES.join('`, `') + '`\n\n' + + 'Use `/pocketbase ' + slug + ' note list` to see current notes.' + ); + process.exit(0); + } + const noteType = tokens[0].toLowerCase(); + const noteText = tokens[1]; + const typeId = noteTypeToId[noteType]; + if (!typeId) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: Unknown note type `' + noteType + '`.\n' + + '**Valid types:** `' + VALID_NOTE_TYPES.join('`, `') + '`' + ); + process.exit(0); + } + const { rec: expandedRecord, notes } = await fetchExpandedNotes(); + const matchingNote = notes.find(function (n) { return n.type === typeId && n.text === noteText; }); + if (!matchingNote) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: No `' + noteType + '` note found with that exact text.\n\n' + + '**Current notes for `' + slug + '`:**\n' + formatNotesList(notes) + ); + process.exit(0); + } + // PATCH script to remove note ID from relation, then DELETE the note record + const existingNoteIds = Array.isArray(expandedRecord.notes) ? expandedRecord.notes : []; + const patchRes = await request(recordsUrl + '/' + record.id, { + method: 'PATCH', + headers: { 'Authorization': token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ notes: existingNoteIds.filter(function (id) { return id !== matchingNote.id; }) }) + }); + if (!patchRes.ok) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: Failed to unlink note from script:\n```\n' + patchRes.body + '\n```'); + process.exit(1); + } + const delRes = await request(apiBase + '/collections/script_notes/records/' + matchingNote.id, { + method: 'DELETE', + headers: { 'Authorization': token } + }); + if (!delRes.ok && delRes.statusCode !== 204) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: Note unlinked but could not be deleted:\n```\n' + delRes.body + '\n```'); + process.exit(1); + } + await addReaction('+1'); + await postComment( + '✅ **PocketBase Bot**: Removed note from **`' + slug + '`**\n\n' + + '- **Type:** `' + noteType + '`\n' + + '- **Text:** ' + noteText + '\n\n' + + '*Executed by @' + actor + '*' + ); + } + + } else { + // ── FIELD=VALUE PATH ───────────────────────────────────────────── + const fieldsStr = rest; + + // Skipped: slug, script_created/updated, created (auto), categories/ + // install_methods/notes/type (relations), github_data/install_methods_json/ + // notes_json (auto-generated), execute_in (select relation), last_update_commit (auto) + const ALLOWED_FIELDS = { + name: 'string', + description: 'string', + logo: 'string', + documentation: 'string', + website: 'string', + project_url: 'string', + github: 'string', + config_path: 'string', + port: 'number', + default_user: 'nullable_string', + default_passwd: 'nullable_string', + 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 and empty=null) + function parseFields(str) { + const fields = {}; + let pos = 0; + while (pos < str.length) { + while (pos < str.length && /\s/.test(str[pos])) pos++; + if (pos >= str.length) break; + 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++; + 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++; + } 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); + + 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.\n\n' + HELP_TEXT); + 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; + } + } + + 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); + } + await addReaction('+1'); + const changesLines = Object.entries(payload) + .map(function ([k, v]) { return '- `' + k + '` → `' + JSON.stringify(v) + '`'; }) + .join('\n'); await postComment( - '❌ **PocketBase Bot**: PATCH failed for `' + slug + '`:\n```\n' + patchRes.body + '\n```' + '✅ **PocketBase Bot**: Updated **`' + slug + '`** successfully!\n\n' + + '**Changes applied:**\n' + changesLines + '\n\n' + + '*Executed by @' + actor + '*' ); - 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); - + console.log('Done.'); })().catch(function (e) { console.error('Fatal error:', e.message || e); process.exit(1); From 366626be9c58c7e243389054e4a51e3bb7c69a2d Mon Sep 17 00:00:00 2001 From: "CanbiZ (MickLesk)" <47820557+MickLesk@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:26:43 +0100 Subject: [PATCH 4/5] feat(pocketbase-bot): add method subcommand + set command for HTML/multiline values --- .github/workflows/pocketbase-bot.yml | 195 +++++++++++++++++++++++++-- 1 file changed, 184 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pocketbase-bot.yml b/.github/workflows/pocketbase-bot.yml index 42a499f5f..5d0118e15 100644 --- a/.github/workflows/pocketbase-bot.yml +++ b/.github/workflows/pocketbase-bot.yml @@ -128,18 +128,33 @@ jobs: await addReaction('eyes'); // ── Parse command ────────────────────────────────────────────────── - // Formats: - // /pocketbase field=value [field=value ...] ← field updates - // /pocketbase note list ← show notes - // /pocketbase note add "" ← add note - // /pocketbase note edit "" "" ← edit note text - // /pocketbase note remove "" ← remove note + // Formats (first line of comment): + // /pocketbase field=value [field=value ...] ← field updates (simple values) + // /pocketbase set ← value from code block below + // /pocketbase note list|add|edit|remove ... ← note management + // /pocketbase method list ← list install methods + // /pocketbase method cpu=N ram=N hdd=N ← edit install method resources const commentBody = process.env.COMMENT_BODY || ''; - const firstLine = commentBody.trim().split('\n')[0].trim(); + const lines = commentBody.trim().split('\n'); + const firstLine = lines[0].trim(); const withoutCmd = firstLine.replace(/^\/pocketbase\s+/, '').trim(); + // Extract code block content from comment body (```...``` or ```lang\n...```) + function extractCodeBlock(body) { + const m = body.match(/```[^\n]*\n([\s\S]*?)```/); + return m ? m[1].trim() : null; + } + const codeBlockValue = extractCodeBlock(commentBody); + const HELP_TEXT = - '**Field update:** `/pocketbase field=value [field=value ...]`\n\n' + + '**Field update (simple):** `/pocketbase field=value [field=value ...]`\n\n' + + '**Field update (HTML/multiline) — value from code block:**\n' + + '````\n' + + '/pocketbase set description\n' + + '```html\n' + + '

Your HTML or multi-line content here

\n' + + '```\n' + + '````\n\n' + '**Note management:**\n' + '```\n' + '/pocketbase note list\n' + @@ -147,6 +162,12 @@ jobs: '/pocketbase note edit "" ""\n' + '/pocketbase note remove ""\n' + '```\n\n' + + '**Install method resources:**\n' + + '```\n' + + '/pocketbase method list\n' + + '/pocketbase method hdd=10\n' + + '/pocketbase method cpu=4 ram=2048 hdd=20\n' + + '```\n\n' + '**Editable fields:** `name` `description` `logo` `documentation` `website` `project_url` `github` ' + '`config_path` `port` `default_user` `default_passwd` ' + '`updateable` `privileged` `has_arm` `is_dev` ' + @@ -207,12 +228,164 @@ jobs: process.exit(0); } - // ── Route: note subcommand vs field=value ────────────────────────── - const noteMatch = rest.match(/^note\s+(list|add|edit|remove)\b/i); + // ── Route: dispatch to subcommand handler ────────────────────────── + const noteMatch = rest.match(/^note\s+(list|add|edit|remove)\b/i); + const methodMatch = rest.match(/^method\b/i); + const setMatch = rest.match(/^set\s+(\S+)/i); if (noteMatch) { // ── NOTE SUBCOMMAND ────────────────────────────────────────────── - const noteAction = noteMatch[1].toLowerCase(); + } else if (methodMatch) { + // ── METHOD SUBCOMMAND ──────────────────────────────────────────── + const methodArgs = rest.replace(/^method\s*/i, '').trim(); + const methodListMode = !methodArgs || methodArgs.toLowerCase() === 'list'; + + // Helper: format bytes/numbers nicely + function fmtMethod(im) { + const r = im.resources || {}; + const typeLabel = im.expand && im.expand.type ? (im.expand.type.type || im.expand.type.name || im.expand.type.value || im.type) : im.type; + return '**`' + (typeLabel || '(unknown type)') + '`** — CPU: `' + (r.cpu != null ? r.cpu : im.resources_cpu || 0) + '` · RAM: `' + (r.ram != null ? r.ram : im.resources_ram || 0) + ' MB` · HDD: `' + (r.hdd != null ? r.hdd : im.resources_hdd || 0) + ' GB`'; + } + + // Fetch expanded install_methods + const expRes = await request(recordsUrl + '/' + record.id + '?expand=install_methods,install_methods.type', { headers: { 'Authorization': token } }); + const expRec = JSON.parse(expRes.body); + const installMethods = (expRec.expand && expRec.expand.install_methods) || []; + + if (methodListMode) { + await addReaction('+1'); + if (installMethods.length === 0) { + await postComment('ℹ️ **PocketBase Bot**: No install methods found for **`' + slug + '`**.'); + } else { + const lines = installMethods.map(function (im, i) { + const r = im.resources || {}; + const typeLabel = im.expand && im.expand.type ? (im.expand.type.type || im.expand.type.name || im.expand.type.value || '') : ''; + return (i + 1) + '. **`' + (typeLabel || im.type || im.id) + '`** — CPU: `' + (im.resources_cpu || 0) + '` · RAM: `' + (im.resources_ram || 0) + ' MB` · HDD: `' + (im.resources_hdd || 0) + ' GB`'; + }).join('\n'); + await postComment('ℹ️ **PocketBase Bot**: Install methods for **`' + slug + '`** (' + installMethods.length + ' total)\n\n' + lines); + } + } else { + // Parse: cpu=N ram=N hdd=N + const methodParts = methodArgs.match(/^(\S+)\s+(.+)$/); + if (!methodParts) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: Invalid `method` syntax.\n\n' + + '**Usage:**\n```\n/pocketbase ' + slug + ' method list\n/pocketbase ' + slug + ' method hdd=10\n/pocketbase ' + slug + ' method cpu=4 ram=2048 hdd=20\n```' + ); + process.exit(0); + } + const targetType = methodParts[1].toLowerCase(); + const resourcesStr = methodParts[2]; + + // Parse resource fields (only cpu/ram/hdd allowed) + const RESOURCE_FIELDS = { cpu: true, ram: true, hdd: true }; + const resourceChanges = {}; + const rePairs = /([a-z]+)=(\d+)/gi; + let m; + while ((m = rePairs.exec(resourcesStr)) !== null) { + const key = m[1].toLowerCase(); + if (RESOURCE_FIELDS[key]) resourceChanges[key] = parseInt(m[2], 10); + } + if (Object.keys(resourceChanges).length === 0) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: No valid resource fields found. Use `cpu=N`, `ram=N`, `hdd=N`.'); + process.exit(0); + } + + // Find matching install method by type name/value + const matchedMethod = installMethods.find(function (im) { + const typeLabel = im.expand && im.expand.type ? + (im.expand.type.type || im.expand.type.name || im.expand.type.value || '') : ''; + return typeLabel.toLowerCase() === targetType || (im.type && im.type.toLowerCase && im.type.toLowerCase() === targetType); + }); + + if (!matchedMethod) { + await addReaction('-1'); + const availableTypes = installMethods.map(function (im) { + return im.expand && im.expand.type ? (im.expand.type.type || im.expand.type.name || im.expand.type.value || im.type || im.id) : (im.type || im.id); + }); + await postComment( + '❌ **PocketBase Bot**: No install method with type `' + targetType + '` found for `' + slug + '`.\n\n' + + '**Available types:** `' + availableTypes.join('`, `') + '`\n\n' + + 'Use `/pocketbase ' + slug + ' method list` to see all methods.' + ); + process.exit(0); + } + + // Build patch payload for script_install_methods record + const imPatch = {}; + if (resourceChanges.cpu != null) imPatch.resources_cpu = resourceChanges.cpu; + if (resourceChanges.ram != null) imPatch.resources_ram = resourceChanges.ram; + if (resourceChanges.hdd != null) imPatch.resources_hdd = resourceChanges.hdd; + + const imPatchRes = await request(apiBase + '/collections/script_install_methods/records/' + matchedMethod.id, { + method: 'PATCH', + headers: { 'Authorization': token, 'Content-Type': 'application/json' }, + body: JSON.stringify(imPatch) + }); + if (!imPatchRes.ok) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: PATCH failed for install method:\n```\n' + imPatchRes.body + '\n```'); + process.exit(1); + } + const typeLabel = matchedMethod.expand && matchedMethod.expand.type ? + (matchedMethod.expand.type.type || matchedMethod.expand.type.name || matchedMethod.expand.type.value || targetType) : targetType; + const changesLines = Object.entries(resourceChanges) + .map(function ([k, v]) { return '- `' + k + '` → `' + v + (k === 'ram' ? ' MB' : k === 'hdd' ? ' GB' : '') + '`'; }) + .join('\n'); + await addReaction('+1'); + await postComment( + '✅ **PocketBase Bot**: Updated install method **`' + typeLabel + '`** for **`' + slug + '`**\n\n' + + '**Changes applied:**\n' + changesLines + '\n\n' + + '*Executed by @' + actor + '*' + ); + } + + } else if (setMatch) { + // ── SET SUBCOMMAND (multi-line / HTML / special chars via code block) ── + const fieldName = setMatch[1].toLowerCase(); + const SET_ALLOWED = { + name: 'string', description: 'string', logo: 'string', + documentation: 'string', website: 'string', project_url: 'string', github: 'string', + config_path: 'string', disable_message: 'string', deleted_message: 'string' + }; + if (!SET_ALLOWED[fieldName]) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: `set` only supports text fields.\n\n' + + '**Allowed:** `' + Object.keys(SET_ALLOWED).join('`, `') + '`\n\n' + + 'For boolean/number fields use `field=value` syntax instead.' + ); + process.exit(0); + } + if (!codeBlockValue) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: `set` requires a code block with the value.\n\n' + + '**Usage:**\n````\n/pocketbase ' + slug + ' set ' + fieldName + '\n```\nYour content here (HTML, multiline, special chars all fine)\n```\n````' + ); + process.exit(0); + } + const setPayload = {}; + setPayload[fieldName] = codeBlockValue; + const setPatchRes = await request(recordsUrl + '/' + record.id, { + method: 'PATCH', + headers: { 'Authorization': token, 'Content-Type': 'application/json' }, + body: JSON.stringify(setPayload) + }); + if (!setPatchRes.ok) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: PATCH failed for `' + slug + '`:\n```\n' + setPatchRes.body + '\n```'); + process.exit(1); + } + const preview = codeBlockValue.length > 300 ? codeBlockValue.substring(0, 300) + '…' : codeBlockValue; + await addReaction('+1'); + await postComment( + '✅ **PocketBase Bot**: Set `' + fieldName + '` for **`' + slug + '`**\n\n' + + '**Value set:**\n```\n' + preview + '\n```\n\n' + + '*Executed by @' + actor + '*' + ); const noteArgsStr = rest.substring(noteMatch[0].length).trim(); // Load note types from PocketBase From 6a7da073f8cd213a81cbd4dec9754ea524892577 Mon Sep 17 00:00:00 2001 From: "CanbiZ (MickLesk)" <47820557+MickLesk@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:43:11 +0100 Subject: [PATCH 5/5] docs: add PocketBase Bot command reference --- docs/pocketbase-bot.md | 65 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/pocketbase-bot.md diff --git a/docs/pocketbase-bot.md b/docs/pocketbase-bot.md new file mode 100644 index 000000000..12539ba8e --- /dev/null +++ b/docs/pocketbase-bot.md @@ -0,0 +1,65 @@ +## 🤖 PocketBase Bot — Command Reference + +> Available to **org members only** (Contributors team). +> Trigger by posting a comment on any Issue or PR. + +--- + +### 🔧 Field Updates +Simple key=value pairs. Multiple in one line. +``` +/pocketbase field=value [field=value ...] +``` +**Boolean fields** (`true`/`false`): `updateable` `privileged` `has_arm` `is_dev` `is_disabled` `is_deleted` +**Text fields**: `name` `description` `logo` `documentation` `website` `project_url` `github` `config_path` `disable_message` `deleted_message` +**Number**: `port` +**Nullable**: `default_user` `default_passwd` *(empty value = null: `default_passwd=`)* + +**Examples:** +``` +/pocketbase homeassistant is_disabled=true disable_message="Broken upstream" +/pocketbase homeassistant documentation=https://www.home-assistant.io/docs +/pocketbase homeassistant is_dev=false +/pocketbase homeassistant default_passwd= +``` + +--- + +### 📝 set — HTML / Multiline / Special Characters +Use a code block for values that contain HTML, links, quotes or newlines. +```` +/pocketbase set +``` +Your content here — HTML tags, links, quotes, all fine +``` +```` +**Allowed fields:** `name` `description` `logo` `documentation` `website` `project_url` `github` `config_path` `disable_message` `deleted_message` + +--- + +### 🗒️ Notes +``` +/pocketbase note list +/pocketbase note add "" +/pocketbase note edit "" "" +/pocketbase note remove "" +``` +Note types come from `z_ref_note_types` in PocketBase (e.g. `info`, `warning`). +If text doesn't match exactly, the bot lists all current notes automatically. + +--- + +### ⚙️ Install Method Resources +``` +/pocketbase method list +/pocketbase method hdd=10 +/pocketbase method cpu=4 ram=2048 hdd=20 +``` +`` matches the install method type name (e.g. `default`, `alpine`). Use `method list` to see available types and current values. `ram` = MB, `hdd` = GB. + +--- + +### 💡 Tips +- The bot reacts with 👀 when it picks up the command, ✅ on success, 👎 on error +- On any error, a comment explains what went wrong +- `note edit` / `note remove` show the current note list if the text doesn't match