From 93f1ad05ed3bb95b34745baa8f53fac0230eddae Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:15:56 -0400 Subject: [PATCH 1/8] feat: allow external approvers --- .github/workflows/Comment_command.yml | 19 +- auto_approved_users.json | 9 + docs/approverGuide.md | 5 +- docs/themeApproverGuidelines.md | 27 +++ docs/themeGuidelines.md | 7 +- src/github-scripts/comment-command.js | 281 +++++++++++++++++++++- tests/fixtures/trusted-command-users.json | 39 +++ tests/github_scripts.test.js | 174 +++++++++++++- 8 files changed, 528 insertions(+), 33 deletions(-) create mode 100644 docs/themeApproverGuidelines.md create mode 100644 tests/fixtures/trusted-command-users.json diff --git a/.github/workflows/Comment_command.yml b/.github/workflows/Comment_command.yml index 7ec76b93bae2..470a1a637f81 100644 --- a/.github/workflows/Comment_command.yml +++ b/.github/workflows/Comment_command.yml @@ -16,35 +16,28 @@ jobs: comment_command: name: Comment command ${{ github.event.issue.number }} - env: - GH_TOKEN: ${{ github.token }} permissions: contents: read runs-on: ubuntu-latest steps: - - name: Check if member - id: member - run: | - gh api \ - -H "Accept: application/vnd.github+json" \ - "/orgs/${GITHUB_REPOSITORY_OWNER}/members/${GITHUB_ACTOR}" || echo "member=false" >> "${GITHUB_OUTPUT}" - - name: Checkout if: - steps.member.outputs.member != 'false' && - github.actor != 'LizardByte-bot' + github.actor != 'LizardByte-bot' && + startsWith(github.event.comment.body, '@LizardByte-bot') uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Comment command env: COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_AUTHOR_ID: ${{ github.event.comment.user.id }} COMMENT_ID: ${{ github.event.comment.id }} ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_AUTHOR_ID: ${{ github.event.issue.user.id }} # https://regex101.com/r/vHEc61/1 YT_REGEX: '^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(-nocookie)?\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$' # yamllint disable-line rule:line-length if: - steps.member.outputs.member != 'false' && - github.actor != 'LizardByte-bot' + github.actor != 'LizardByte-bot' && + startsWith(github.event.comment.body, '@LizardByte-bot') uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/auto_approved_users.json b/auto_approved_users.json index 0033051ce26b..004881fc8f89 100644 --- a/auto_approved_users.json +++ b/auto_approved_users.json @@ -1,13 +1,22 @@ [ { + "commands": [ + "*" + ], "user_id": 30657709, "username": "LuckyNoS7evin" }, { + "commands": [ + "*" + ], "user_id": 42013603, "username": "ReenigneArcher" }, { + "commands": [ + "*" + ], "user_id": 88998541, "username": "jjnether" } diff --git a/docs/approverGuide.md b/docs/approverGuide.md index 543e0ae920c3..0d5317f6f298 100644 --- a/docs/approverGuide.md +++ b/docs/approverGuide.md @@ -57,8 +57,9 @@ The following guide is meant for authorized Themerr approvers to use when approv 6. Watch/listen to the theme on youtube - - This portion comes down to human judgement, but the [Theme Guidelines](../docs/themeGuidelines.md) should be - referenced for precise rules when deciding. + - This portion comes down to human judgement, but the + [Theme Approver Guidelines](../docs/themeApproverGuidelines.md) and + [Theme Guidelines](../docs/themeGuidelines.md) should be referenced for precise rules when deciding. - Typically when examining a theme, we set the computer to about 50% volume then right-click and open the theme in an incognito window (so google doesn't recommend stuff based on every theme that's reviewed). diff --git a/docs/themeApproverGuidelines.md b/docs/themeApproverGuidelines.md new file mode 100644 index 000000000000..de7ca9332680 --- /dev/null +++ b/docs/themeApproverGuidelines.md @@ -0,0 +1,27 @@ +# Theme Approver Guidelines + +Use these guidelines when making the final approval decision for a theme submission. The public +[Theme Guidelines](../docs/themeGuidelines.md) still apply; this document is for approver judgement when a +submission passes the automated checks. + +## Core Preference + +Prefer themes that clearly represent the submitted movie, show, game, collection, or franchise. + +## Review Criteria + +- Themes should be representative of the movie, show, game, collection, etc. +- Fan-made themes should be avoided but can be accepted if there are no other alternatives. +- Trailer music should be avoided but can be accepted if there are no other alternatives. +- TV show themes should not be season-based, but can be accepted if there are no other alternatives. +- Collection and franchise themes should be representative of the entire group, not just one item in the group. +- Theme videos should not be an actual scene from the movie, show, or game. +- Themes should ideally not be from the opening sequence of the movie. +- Theme songs should not have extra sound effects other than what is in the song itself. In rare cases, themes with + sound effects can be accepted if there are no other alternatives. +- Theme songs should not contain racial slurs. + +## Exceptions + +If a submission relies on one of the exception cases above, check for better alternatives before approving it. If the +available choices are all imperfect, use the theme that best represents the item and leaves the fewest quality concerns. diff --git a/docs/themeGuidelines.md b/docs/themeGuidelines.md index 998128fe0dca..717eff9f3e85 100644 --- a/docs/themeGuidelines.md +++ b/docs/themeGuidelines.md @@ -44,9 +44,6 @@ the issue to let us know the reasoning behind the change. You can view and search the existing themes at [ThemerrDB](https://app.lizardbyte.dev/ThemerrDB). -## Miscellaneous +## Approval Review -- Theme videos should NOT be an actual scene from the movie or game. -- Themes should be representative of the movie or game. -- Themes should ideally NOT be from the opening sequence of the movie. -- Themes should NOT contain racial slurs. +Approvers use the [Theme Approver Guidelines](../docs/themeApproverGuidelines.md) when reviewing theme submissions. diff --git a/src/github-scripts/comment-command.js b/src/github-scripts/comment-command.js index 5b65b54d3c4b..4bb564fd24e0 100644 --- a/src/github-scripts/comment-command.js +++ b/src/github-scripts/comment-command.js @@ -2,6 +2,9 @@ * @file Handles issue comment commands for ThemerrDB moderation workflows. */ +const fs = require('node:fs') +const path = require('node:path') + const {queueIssueForApproval} = require('./approval-queue.js') const { delay, @@ -11,6 +14,11 @@ const { repoParams } = require('./github-issue.js') +const ADMIN_REPOSITORY_PERMISSION = 'admin' +const ALL_COMMANDS = '*' +const AUTO_APPROVED_USERS_FILE = 'auto_approved_users.json' +const KNOWN_COMMANDS = new Set(['approve', 'edit']) + /** * Normalize a command comment before parsing positional arguments. * @@ -27,6 +35,239 @@ function normalizeComment(commentBody) { return comment } +/** + * Normalize a GitHub user id for matching. + * + * @param {object} userId GitHub user id. + * @returns {string} Trimmed user id string. + */ +function normalizeUserId(userId) { + if (userId === null || userId === undefined) { + return '' + } + + return `${userId}`.trim() +} + +/** + * Normalize a command name for matching. + * + * @param {object} command Bot command name. + * @returns {string} Lowercase command name. + */ +function normalizeCommandName(command) { + return `${command}`.trim().toLowerCase() +} + +/** + * Normalize a configured command list. + * + * @param {object} commands Configured command list. + * @returns {Set} Normalized command names. + */ +function normalizeAllowedCommands(commands) { + if (!Array.isArray(commands)) { + return new Set() + } + + return new Set(commands.map(normalizeCommandName).filter(Boolean)) +} + +/** + * Resolve a trusted users file inside the current workspace. + * + * @param {string} trustedUsersFile Trusted users file path. + * @returns {string} Absolute trusted users file path. + */ +function resolveTrustedUsersFile(trustedUsersFile) { + const baseDir = process.cwd() + const resolvedFile = path.resolve(baseDir, trustedUsersFile) + const relativePath = path.relative(baseDir, resolvedFile) + + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new Error('trusted command users file must be inside the working directory') + } + + return resolvedFile +} + +/** + * Load trusted command users from the configured JSON file. + * + * @param {object} [options] Loader options. + * @param {string} [options.trustedUsersFile] Trusted users file path. + * @returns {Map>} Allowed command names keyed by GitHub user id. + */ +function loadTrustedCommandUsers({trustedUsersFile = process.env.AUTO_APPROVED_USERS_FILE || AUTO_APPROVED_USERS_FILE} = {}) { + let trustedUsers + + try { + const resolvedTrustedUsersFile = resolveTrustedUsersFile(trustedUsersFile) + trustedUsers = JSON.parse(fs.readFileSync(resolvedTrustedUsersFile, 'utf8')) + } catch (error) { + console.log(`trusted command users unavailable: ${error.message}`) + return new Map() + } + + if (!Array.isArray(trustedUsers)) { + return new Map() + } + + const trustedCommandUsers = new Map() + + for (const user of trustedUsers) { + if (!user || typeof user !== 'object' || Array.isArray(user)) { + continue + } + + const userId = normalizeUserId(user.user_id) + const commands = normalizeAllowedCommands(user.commands) + + if (userId && commands.size > 0) { + trustedCommandUsers.set(userId, commands) + } + } + + return trustedCommandUsers +} + +/** + * Determine whether a trusted user configuration allows a command. + * + * @param {object} options Options for checking the command. + * @param {Map>} options.trustedCommandUsers Trusted user command map. + * @param {object} options.userId GitHub user id. + * @param {string} options.command Bot command name. + * @returns {boolean} Whether the command is allowed. + */ +function trustedUserCanRunCommand({trustedCommandUsers, userId, command}) { + const commands = trustedCommandUsers.get(normalizeUserId(userId)) + + return Boolean(commands && (commands.has(ALL_COMMANDS) || commands.has(normalizeCommandName(command)))) +} + +/** + * Determine whether the command commenter is the issue author. + * + * @param {object} options Options for checking authorship. + * @param {string} options.command Bot command name. + * @param {object} options.commentAuthorId Comment author GitHub user id. + * @param {object} options.issueAuthorId Issue author GitHub user id. + * @returns {boolean} Whether the issue author may run the command. + */ +function issueAuthorCanRunCommand({command, commentAuthorId, issueAuthorId}) { + const normalizedCommentAuthorId = normalizeUserId(commentAuthorId) + + return normalizeCommandName(command) === 'edit' && + normalizedCommentAuthorId !== '' && + normalizedCommentAuthorId === normalizeUserId(issueAuthorId) +} + +/** + * Get the repository permission level for a GitHub actor. + * + * @param {object} options Options for checking repository permission. + * @param {object} options.github Authenticated Octokit client from actions/github-script. + * @param {import('./github-issue.js').GitHubScriptContext} options.context The actions/github-script context object. + * @param {string} options.actor GitHub actor login. + * @returns {Promise} Repository permission level. + */ +async function getRepositoryPermission({github, context, actor}) { + if (!actor) { + return '' + } + + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + ...repoParams(context), + username: actor + }) + + return `${response.data.permission}` + } catch (error) { + console.log(`repository permission unavailable for ${actor}: ${error.message}`) + return '' + } +} + +/** + * Determine whether the comment actor is a repository admin. + * + * @param {object} options Options for checking admin permissions. + * @param {object} options.github Authenticated Octokit client from actions/github-script. + * @param {import('./github-issue.js').GitHubScriptContext} options.context The actions/github-script context object. + * @param {string} options.actor GitHub actor login. + * @returns {Promise} Whether the actor has admin permission on the repository. + */ +async function actorIsRepositoryAdmin({github, context, actor}) { + const permission = await getRepositoryPermission({github, context, actor}) + + return permission === ADMIN_REPOSITORY_PERMISSION +} + +/** + * Determine whether the command is authorized for this comment. + * + * @param {object} options Options for checking authorization. + * @param {object} options.github Authenticated Octokit client from actions/github-script. + * @param {import('./github-issue.js').GitHubScriptContext} options.context The actions/github-script context object. + * @param {string} options.command Bot command name. + * @param {string} options.actor GitHub actor login. + * @param {object} options.commentAuthorId Comment author GitHub user id. + * @param {object} options.issueAuthorId Issue author GitHub user id. + * @param {Map>} [options.trustedCommandUsers] Trusted user command map. + * @returns {Promise} Whether the command is authorized. + */ +async function canRunCommand({ + github, + context, + command, + actor, + commentAuthorId, + issueAuthorId, + trustedCommandUsers = loadTrustedCommandUsers() +}) { + if (trustedUserCanRunCommand({trustedCommandUsers, userId: commentAuthorId, command})) { + return true + } + + if (issueAuthorCanRunCommand({command, commentAuthorId, issueAuthorId})) { + return true + } + + return actorIsRepositoryAdmin({github, context, actor}) +} + +/** + * Get the issue comment author id from the event payload or environment. + * + * @param {import('./github-issue.js').GitHubScriptContext} context The actions/github-script context object. + * @returns {string} GitHub user id. + */ +function getCommentAuthorId(context) { + return normalizeUserId(context.payload?.comment?.user?.id ?? process.env.COMMENT_AUTHOR_ID) +} + +/** + * Get the issue author id from the event payload or environment. + * + * @param {import('./github-issue.js').GitHubScriptContext} context The actions/github-script context object. + * @returns {string} GitHub user id. + */ +function getIssueAuthorId(context) { + return normalizeUserId(context.payload?.issue?.user?.id ?? process.env.ISSUE_AUTHOR_ID) +} + +/** + * Get the GitHub actor from the event context or environment. + * + * @param {import('./github-issue.js').GitHubScriptContext} context The actions/github-script context object. + * @returns {string} GitHub actor login. + */ +function getActor(context) { + return context.actor || context.payload?.comment?.user?.login || process.env.GITHUB_ACTOR || '' +} + /** * Update the issue body with a replacement YouTube URL and re-trigger request validation. * @@ -114,16 +355,31 @@ async function run({github, context}) { } const args = comment.split(' ') - let commandRan = false + const command = normalizeCommandName(args[1]) - if (args[1] === 'approve') { - console.log('approve command running') - await queueIssueForApproval({github, context}) + if (!KNOWN_COMMANDS.has(command)) { + console.log('the bot command is unknown, exiting') + return + } - commandRan = true + const commandAllowed = await canRunCommand({ + github, + context, + command, + actor: getActor(context), + commentAuthorId: getCommentAuthorId(context), + issueAuthorId: getIssueAuthorId(context) + }) + + if (!commandAllowed) { + console.log('the bot command is not authorized for this commenter, exiting') + return } - if (args[1] === 'edit') { + if (command === 'approve') { + console.log('approve command running') + await queueIssueForApproval({github, context}) + } else { console.log('edit command running') await editThemeRequest({ @@ -133,19 +389,20 @@ async function run({github, context}) { youtubeRegex, replacementUrl: args[2] }) - - commandRan = true } - if (commandRan) { - console.log('command ran, adding reaction') - await addCommandReaction({github, context, commentId}) - } + console.log('command ran, adding reaction') + await addCommandReaction({github, context, commentId}) } module.exports = { addCommandReaction, + actorIsRepositoryAdmin, + canRunCommand, editThemeRequest, + issueAuthorCanRunCommand, + loadTrustedCommandUsers, normalizeComment, + normalizeUserId, run } diff --git a/tests/fixtures/trusted-command-users.json b/tests/fixtures/trusted-command-users.json new file mode 100644 index 000000000000..fa18a4bba0a1 --- /dev/null +++ b/tests/fixtures/trusted-command-users.json @@ -0,0 +1,39 @@ +[ + [], + { + "commands": [ + "*" + ], + "username": "missing-user-id" + }, + { + "commands": [ + "*" + ], + "user_id": null, + "username": "null-user-id" + }, + { + "commands": [], + "user_id": 1111, + "username": "missing-commands" + }, + { + "user_id": 4444, + "username": "missing-command-list" + }, + { + "commands": [ + " edit " + ], + "user_id": 2222, + "username": "limited-user" + }, + { + "commands": [ + "*" + ], + "user_id": 3333, + "username": "wildcard-user" + } +] diff --git a/tests/github_scripts.test.js b/tests/github_scripts.test.js index e6b879480480..d8506f32a8f8 100644 --- a/tests/github_scripts.test.js +++ b/tests/github_scripts.test.js @@ -684,14 +684,103 @@ describe('comment command script', () => { expect(github.rest.reactions.createForIssueComment).not.toHaveBeenCalled() }) - test('queues approval commands and reacts to the comment', async () => { + test('loads trusted command users from configured allowlists', () => { + expect(commentCommand.loadTrustedCommandUsers({ + trustedUsersFile: 'auto_approved_users.json' + }).get('42013603')).toEqual(new Set(['*'])) + + expect(commentCommand.loadTrustedCommandUsers({ + trustedUsersFile: 'tests/fixtures/trusted-command-users.json' + })).toEqual(new Map([ + ['2222', new Set(['edit'])], + ['3333', new Set(['*'])] + ])) + }) + + test('fails closed when trusted command users cannot be loaded', () => { + expect(commentCommand.loadTrustedCommandUsers({ + trustedUsersFile: 'missing-command-users.json' + })).toEqual(new Map()) + expect(commentCommand.loadTrustedCommandUsers({ + trustedUsersFile: 'package.json' + })).toEqual(new Map()) + expect(commentCommand.loadTrustedCommandUsers({ + trustedUsersFile: '../auto_approved_users.json' + })).toEqual(new Map()) + }) + + test('allows configured users to run selected commands', async () => { + const github = {} + const trustedCommandUsers = new Map([ + ['1111', new Set(['approve'])], + ['2222', new Set(['*'])] + ]) + + await expect(commentCommand.canRunCommand({ + github, + context, + command: 'approve', + actor: '', + commentAuthorId: '1111', + issueAuthorId: '9999', + trustedCommandUsers + })).resolves.toBe(true) + await expect(commentCommand.canRunCommand({ + github, + context, + command: 'edit', + actor: '', + commentAuthorId: '1111', + issueAuthorId: '9999', + trustedCommandUsers + })).resolves.toBe(false) + await expect(commentCommand.canRunCommand({ + github, + context, + command: 'approve', + actor: '', + commentAuthorId: '2222', + issueAuthorId: '9999', + trustedCommandUsers + })).resolves.toBe(true) + }) + + test('allows issue authors to run edit commands only', () => { + expect(commentCommand.issueAuthorCanRunCommand({ + command: 'edit', + commentAuthorId: 1234, + issueAuthorId: '1234' + })).toBe(true) + expect(commentCommand.issueAuthorCanRunCommand({ + command: 'approve', + commentAuthorId: '1234', + issueAuthorId: '1234' + })).toBe(false) + expect(commentCommand.issueAuthorCanRunCommand({ + command: 'edit', + commentAuthorId: '', + issueAuthorId: '' + })).toBe(false) + }) + + test('allows repository admins to run commands', async () => { process.env.COMMENT_BODY = '@LizardByte-bot approve' process.env.COMMENT_ID = '123' + process.env.COMMENT_AUTHOR_ID = '9999' + process.env.GITHUB_ACTOR = 'repo-admin' + process.env.ISSUE_AUTHOR_ID = '8888' process.env.ISSUE_BODY = 'https://youtu.be/old' process.env.YT_REGEX = String.raw`youtu\.be` const github = { paginate: jest.fn(async () => []), rest: { + repos: { + getCollaboratorPermissionLevel: jest.fn(async () => ({ + data: { + permission: 'admin' + } + })) + }, issues: { listForRepo: jest.fn(), addLabels: jest.fn() @@ -716,6 +805,87 @@ describe('comment command script', () => { }) }) + test('blocks commands from unauthorized commenters', async () => { + process.env.COMMENT_BODY = '@LizardByte-bot approve' + process.env.COMMENT_ID = '123' + process.env.COMMENT_AUTHOR_ID = '9999' + process.env.GITHUB_ACTOR = 'outside-user' + process.env.ISSUE_AUTHOR_ID = '8888' + process.env.ISSUE_BODY = 'https://youtu.be/old' + process.env.YT_REGEX = String.raw`youtu\.be` + const github = { + paginate: jest.fn(async () => []), + rest: { + repos: { + getCollaboratorPermissionLevel: jest.fn(async () => ({ + data: { + permission: 'write' + } + })) + }, + issues: { + listForRepo: jest.fn(), + addLabels: jest.fn() + }, + reactions: { + createForIssueComment: jest.fn() + } + } + } + + await commentCommand.run({github, context}) + + expect(github.rest.issues.addLabels).not.toHaveBeenCalled() + expect(github.rest.reactions.createForIssueComment).not.toHaveBeenCalled() + }) + + test('fails closed when repository admin lookup fails', async () => { + const github = { + rest: { + repos: { + getCollaboratorPermissionLevel: jest.fn(async () => { + throw new Error('not found') + }) + } + } + } + + await expect(commentCommand.actorIsRepositoryAdmin({ + github, + context, + actor: 'outside-user' + })).resolves.toBe(false) + }) + + test('runs commands from trusted wildcard users', async () => { + process.env.COMMENT_BODY = '@LizardByte-bot approve' + process.env.COMMENT_ID = '123' + process.env.COMMENT_AUTHOR_ID = '42013603' + process.env.GITHUB_ACTOR = 'trusted-user' + process.env.ISSUE_AUTHOR_ID = '8888' + process.env.ISSUE_BODY = 'https://youtu.be/old' + process.env.YT_REGEX = String.raw`youtu\.be` + const github = { + paginate: jest.fn(async () => []), + rest: { + issues: { + listForRepo: jest.fn(), + addLabels: jest.fn() + }, + reactions: { + createForIssueComment: jest.fn() + } + } + } + + await commentCommand.run({github, context}) + + expect(github.rest.issues.addLabels).toHaveBeenCalledWith(expect.objectContaining({ + labels: ['approve-queue', 'approve-theme'] + })) + expect(github.rest.reactions.createForIssueComment).toHaveBeenCalled() + }) + test('skips edit updates when no YouTube URL is present', async () => { const github = { rest: { @@ -796,6 +966,8 @@ describe('comment command script', () => { runTimersImmediately() process.env.COMMENT_BODY = '@LizardByte-bot edit https://youtu.be/new' process.env.COMMENT_ID = '123' + process.env.COMMENT_AUTHOR_ID = '1234' + process.env.ISSUE_AUTHOR_ID = '1234' process.env.ISSUE_BODY = 'https://youtu.be/old' process.env.YT_REGEX = String.raw`https:\/\/youtu\.be\/old` const github = { From eebe79d21c6c1dad1a874edd6e78849b541f8d3c Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:23:38 -0400 Subject: [PATCH 2/8] feat: add /question command --- .github/workflows/Comment_command.yml | 10 ++++- bot_commands.md | 1 + docs/approverGuide.md | 2 +- src/github-scripts/comment-command.js | 63 +++++++++++++++++++++++--- tests/github_scripts.test.js | 64 +++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 10 deletions(-) diff --git a/.github/workflows/Comment_command.yml b/.github/workflows/Comment_command.yml index 470a1a637f81..65529b65c8a5 100644 --- a/.github/workflows/Comment_command.yml +++ b/.github/workflows/Comment_command.yml @@ -23,7 +23,10 @@ jobs: - name: Checkout if: github.actor != 'LizardByte-bot' && - startsWith(github.event.comment.body, '@LizardByte-bot') + ( + startsWith(github.event.comment.body, '@LizardByte-bot') || + startsWith(github.event.comment.body, '/question') + ) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Comment command @@ -37,7 +40,10 @@ jobs: YT_REGEX: '^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(-nocookie)?\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$' # yamllint disable-line rule:line-length if: github.actor != 'LizardByte-bot' && - startsWith(github.event.comment.body, '@LizardByte-bot') + ( + startsWith(github.event.comment.body, '@LizardByte-bot') || + startsWith(github.event.comment.body, '/question') + ) uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/bot_commands.md b/bot_commands.md index d4873c1dde31..7357b84fd3d0 100644 --- a/bot_commands.md +++ b/bot_commands.md @@ -7,6 +7,7 @@ A moderator can trigger LizardByte-bot actions by commenting on this issue: - `@LizardByte-bot approve` approve the theme - `@LizardByte-bot edit {youtube_url}` replace the YouTube url in the issue body with the given url +- `/question {message}` add the question label while asking the submitter for more information diff --git a/docs/approverGuide.md b/docs/approverGuide.md index 0d5317f6f298..30212f51046a 100644 --- a/docs/approverGuide.md +++ b/docs/approverGuide.md @@ -78,7 +78,7 @@ The following guide is meant for authorized Themerr approvers to use when approv ## Helpful Tools -- If you have a question or suggestion for a theme submission, place your comment, then add the `question` label to mark the issue. +- If you have a question or suggestion for a theme submission, comment `/question {message}` to mark the issue. > [!NOTE] > If a user fails to answer your question after about a week or longer, you can proceed how you think best with the theme. diff --git a/src/github-scripts/comment-command.js b/src/github-scripts/comment-command.js index 4bb564fd24e0..cb372ceff2f6 100644 --- a/src/github-scripts/comment-command.js +++ b/src/github-scripts/comment-command.js @@ -17,7 +17,9 @@ const { const ADMIN_REPOSITORY_PERMISSION = 'admin' const ALL_COMMANDS = '*' const AUTO_APPROVED_USERS_FILE = 'auto_approved_users.json' -const KNOWN_COMMANDS = new Set(['approve', 'edit']) +const BOT_COMMAND_PREFIX = '@LizardByte-bot' +const KNOWN_COMMANDS = new Set(['approve', 'edit', 'question']) +const QUESTION_LABEL = 'question' /** * Normalize a command comment before parsing positional arguments. @@ -35,6 +37,34 @@ function normalizeComment(commentBody) { return comment } +/** + * Parse a normalized comment into a command and positional arguments. + * + * @param {string} comment Normalized issue comment body. + * @returns {{command: string, args: string[]}} Parsed command details. + */ +function parseCommandComment(comment) { + const args = comment.split(' ') + if (args[0] === BOT_COMMAND_PREFIX) { + return { + command: normalizeCommandName(args[1]), + args + } + } + + if (args[0].startsWith('/')) { + return { + command: normalizeCommandName(args[0].slice(1)), + args + } + } + + return { + command: '', + args: [] + } +} + /** * Normalize a GitHub user id for matching. * @@ -316,6 +346,21 @@ async function editThemeRequest({github, context, issueBody, youtubeRegex, repla } } +/** + * Add the question label to an issue. + * + * @param {object} options Options for adding the label. + * @param {object} options.github Authenticated Octokit client from actions/github-script. + * @param {import('./github-issue.js').GitHubScriptContext} options.context The actions/github-script context object. + * @returns {Promise} Promise resolved after the label is added. + */ +async function addQuestionLabel({github, context}) { + await github.rest.issues.addLabels({ + ...issueParams(context), + labels: [QUESTION_LABEL] + }) +} + /** * Add a positive reaction to a handled bot command comment. * @@ -349,14 +394,13 @@ async function run({github, context}) { console.log(`comment: ${comment}`) - if (!comment.startsWith('@LizardByte-bot')) { - console.log('the comment is not a @LizardByte-bot command, exiting') + const {command, args} = parseCommandComment(comment) + + if (!command) { + console.log('the comment is not a bot command, exiting') return } - const args = comment.split(' ') - const command = normalizeCommandName(args[1]) - if (!KNOWN_COMMANDS.has(command)) { console.log('the bot command is unknown, exiting') return @@ -379,7 +423,7 @@ async function run({github, context}) { if (command === 'approve') { console.log('approve command running') await queueIssueForApproval({github, context}) - } else { + } else if (command === 'edit') { console.log('edit command running') await editThemeRequest({ @@ -389,6 +433,9 @@ async function run({github, context}) { youtubeRegex, replacementUrl: args[2] }) + } else { + console.log('question command running') + await addQuestionLabel({github, context}) } console.log('command ran, adding reaction') @@ -397,6 +444,7 @@ async function run({github, context}) { module.exports = { addCommandReaction, + addQuestionLabel, actorIsRepositoryAdmin, canRunCommand, editThemeRequest, @@ -404,5 +452,6 @@ module.exports = { loadTrustedCommandUsers, normalizeComment, normalizeUserId, + parseCommandComment, run } diff --git a/tests/github_scripts.test.js b/tests/github_scripts.test.js index d8506f32a8f8..204cf360f0d1 100644 --- a/tests/github_scripts.test.js +++ b/tests/github_scripts.test.js @@ -644,6 +644,21 @@ describe('comment command script', () => { .toBe('@LizardByte-bot approve') }) + test('parses bot mention and slash commands', () => { + expect(commentCommand.parseCommandComment('@LizardByte-bot approve')).toEqual({ + command: 'approve', + args: ['@LizardByte-bot', 'approve'] + }) + expect(commentCommand.parseCommandComment('/question Can you fix this theme?')).toEqual({ + command: 'question', + args: ['/question', 'Can', 'you', 'fix', 'this', 'theme?'] + }) + expect(commentCommand.parseCommandComment('plain comment')).toEqual({ + command: '', + args: [] + }) + }) + test('ignores comments that are not bot commands', async () => { process.env.COMMENT_BODY = 'plain comment' process.env.COMMENT_ID = '123' @@ -743,6 +758,15 @@ describe('comment command script', () => { issueAuthorId: '9999', trustedCommandUsers })).resolves.toBe(true) + await expect(commentCommand.canRunCommand({ + github, + context, + command: 'question', + actor: '', + commentAuthorId: '1111', + issueAuthorId: '9999', + trustedCommandUsers + })).resolves.toBe(false) }) test('allows issue authors to run edit commands only', () => { @@ -756,6 +780,11 @@ describe('comment command script', () => { commentAuthorId: '1234', issueAuthorId: '1234' })).toBe(false) + expect(commentCommand.issueAuthorCanRunCommand({ + command: 'question', + commentAuthorId: '1234', + issueAuthorId: '1234' + })).toBe(false) expect(commentCommand.issueAuthorCanRunCommand({ command: 'edit', commentAuthorId: '', @@ -886,6 +915,41 @@ describe('comment command script', () => { expect(github.rest.reactions.createForIssueComment).toHaveBeenCalled() }) + test('runs question commands from trusted wildcard users', async () => { + process.env.COMMENT_BODY = '/question Can you fix this theme?' + process.env.COMMENT_ID = '123' + process.env.COMMENT_AUTHOR_ID = '42013603' + process.env.GITHUB_ACTOR = 'trusted-user' + process.env.ISSUE_AUTHOR_ID = '8888' + process.env.ISSUE_BODY = 'https://youtu.be/old' + process.env.YT_REGEX = String.raw`youtu\.be` + const github = { + rest: { + issues: { + addLabels: jest.fn() + }, + reactions: { + createForIssueComment: jest.fn() + } + } + } + + await commentCommand.run({github, context}) + + expect(github.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'LizardByte', + repo: 'ThemerrDB', + issue_number: 7, + labels: ['question'] + }) + expect(github.rest.reactions.createForIssueComment).toHaveBeenCalledWith({ + owner: 'LizardByte', + repo: 'ThemerrDB', + comment_id: 123, + content: '+1' + }) + }) + test('skips edit updates when no YouTube URL is present', async () => { const github = { rest: { From 19590c7543e079237277cbb7207330e64a63358e Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:29:47 -0400 Subject: [PATCH 3/8] fix: handle auto-close on approve-theme Extend the update-labels workflow and script so approve-theme can trigger auto-close behavior. When auto-close is active, the approval label is removed and the workflow skips the approval push/comment steps. Added coverage for the new auto-close path. --- .github/workflows/auto-update-db.yml | 5 ++++- src/github-scripts/update-labels.js | 5 +++-- tests/github_scripts.test.js | 21 ++++++++++++++++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/auto-update-db.yml b/.github/workflows/auto-update-db.yml index 5328cd9a2a11..ed9190daca69 100644 --- a/.github/workflows/auto-update-db.yml +++ b/.github/workflows/auto-update-db.yml @@ -172,6 +172,7 @@ jobs: if: always() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + AUTO_CLOSE: ${{ steps.outputs.outputs.auto_close }} DUPLICATE: ${{ steps.outputs.outputs.duplicate }} EXCEPTION: ${{ steps.outputs.outputs.exception }} with: @@ -204,7 +205,7 @@ jobs: - name: Auto Close if: - (github.event.label.name == 'request-theme') && + (github.event.label.name == 'request-theme' || github.event.label.name == 'approve-theme') && steps.outputs.outputs.auto_close == 'true' env: GH_TOKEN: ${{ secrets.GH_BOT_TOKEN }} @@ -220,6 +221,7 @@ jobs: if: (github.event.label.name == 'approve-theme') && steps.outputs.outputs.exception == 'false' && + steps.outputs.outputs.auto_close == 'false' && steps.labels.outputs.result == 'true' uses: actions-js/push@5a7cbd780d82c0c937b5977586e641b2fd94acc5 # v1.5 with: @@ -235,6 +237,7 @@ jobs: if: (github.event.label.name == 'approve-theme') && steps.outputs.outputs.exception == 'false' && + steps.outputs.outputs.auto_close == 'false' && steps.labels.outputs.result == 'true' env: GH_TOKEN: ${{ secrets.GH_BOT_TOKEN }} diff --git a/src/github-scripts/update-labels.js b/src/github-scripts/update-labels.js index 620301ce9746..e98a39e0595b 100644 --- a/src/github-scripts/update-labels.js +++ b/src/github-scripts/update-labels.js @@ -37,6 +37,7 @@ function setLabelState(currentLabels, labelName, enabled) { */ async function run({github, context}) { const labels = await github.rest.issues.listLabelsOnIssue(issueParams(context)) + const autoClose = process.env.AUTO_CLOSE const exception = process.env.EXCEPTION const duplicate = process.env.DUPLICATE @@ -47,7 +48,7 @@ async function run({github, context}) { const labelAdd = currentLabels.includes(APPROVE_THEME_LABEL) - if (labelAdd && exception === 'true') { + if (labelAdd && (autoClose === 'true' || exception === 'true')) { setLabelState(currentLabels, APPROVE_THEME_LABEL, false) } @@ -58,7 +59,7 @@ async function run({github, context}) { labels: currentLabels }) - if (labelAdd) { + if (labelAdd && autoClose !== 'true' && exception !== 'true') { return 'true' } } diff --git a/tests/github_scripts.test.js b/tests/github_scripts.test.js index 204cf360f0d1..cf21fe6569d6 100644 --- a/tests/github_scripts.test.js +++ b/tests/github_scripts.test.js @@ -1176,12 +1176,31 @@ describe('update labels script', () => { } } - await expect(updateLabels.run({github, context})).resolves.toBe('true') + await expect(updateLabels.run({github, context})).resolves.toBeUndefined() expect(github.rest.issues.setLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ['exception'] })) }) + test('removes approval label when auto-close is active', async () => { + process.env.AUTO_CLOSE = 'true' + process.env.EXCEPTION = 'false' + process.env.DUPLICATE = 'true' + const github = { + rest: { + issues: { + listLabelsOnIssue: jest.fn(async () => labels('approve-theme', 'approve-queue')), + setLabels: jest.fn() + } + } + } + + await expect(updateLabels.run({github, context})).resolves.toBeUndefined() + expect(github.rest.issues.setLabels).toHaveBeenCalledWith(expect.objectContaining({ + labels: ['duplicate'] + })) + }) + test('returns undefined when approval is not active', async () => { process.env.EXCEPTION = 'false' process.env.DUPLICATE = 'false' From 82dd4ea5a0085ef8c430a6a430d5ed5908ed0734 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:34:43 -0400 Subject: [PATCH 4/8] feat: Add bot check command to rerun request checks Introduce `@LizardByte-bot check` as a recognized moderator command in the comment script. The command now re-triggers validation by removing and re-adding the `request-theme` label (with a short delay), and is wired into command dispatch/export paths. Documentation was updated to point approvers to the new command, and tests were expanded to cover permissions and end-to-end check command behavior. --- bot_commands.md | 1 + docs/approverGuide.md | 2 +- src/github-scripts/comment-command.js | 33 +++++++++++++++- tests/github_scripts.test.js | 54 +++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/bot_commands.md b/bot_commands.md index 7357b84fd3d0..6c2e317aba9d 100644 --- a/bot_commands.md +++ b/bot_commands.md @@ -6,6 +6,7 @@ A moderator can trigger LizardByte-bot actions by commenting on this issue: - `@LizardByte-bot approve` approve the theme +- `@LizardByte-bot check` re-run the request checks - `@LizardByte-bot edit {youtube_url}` replace the YouTube url in the issue body with the given url - `/question {message}` add the question label while asking the submitter for more information diff --git a/docs/approverGuide.md b/docs/approverGuide.md index 30212f51046a..debee04a2293 100644 --- a/docs/approverGuide.md +++ b/docs/approverGuide.md @@ -86,7 +86,7 @@ The following guide is meant for authorized Themerr approvers to use when approv - A youtube URL in a user submission can be edited by commenting `@LizardByte-bot edit {youtube_url}`. This will replace the youtube URL and run the workflow to check the new URL. - A useful website for checking regional blocks on a Youtube video can be found [here](https://watannetwork.com/tools/blocked/). -- A bot workflow to check a theme can be manually initiated by first removing, then adding the `request-theme` label. +- A bot workflow to check a theme can be manually initiated by commenting `@LizardByte-bot check`. - The `approve-queue` label can be manually added to add an issue to the bot approval queue. > [!NOTE] diff --git a/src/github-scripts/comment-command.js b/src/github-scripts/comment-command.js index cb372ceff2f6..a911debba71c 100644 --- a/src/github-scripts/comment-command.js +++ b/src/github-scripts/comment-command.js @@ -18,8 +18,9 @@ const ADMIN_REPOSITORY_PERMISSION = 'admin' const ALL_COMMANDS = '*' const AUTO_APPROVED_USERS_FILE = 'auto_approved_users.json' const BOT_COMMAND_PREFIX = '@LizardByte-bot' -const KNOWN_COMMANDS = new Set(['approve', 'edit', 'question']) +const KNOWN_COMMANDS = new Set(['approve', 'check', 'edit', 'question']) const QUESTION_LABEL = 'question' +const REQUEST_THEME_LABEL = 'request-theme' /** * Normalize a command comment before parsing positional arguments. @@ -346,6 +347,32 @@ async function editThemeRequest({github, context, issueBody, youtubeRegex, repla } } +/** + * Re-trigger request validation by re-applying the request label. + * + * @param {object} options Options for checking a theme request. + * @param {object} options.github Authenticated Octokit client from actions/github-script. + * @param {import('./github-issue.js').GitHubScriptContext} options.context The actions/github-script context object. + * @returns {Promise} Promise resolved after the check workflow is triggered. + */ +async function checkThemeRequest({github, context}) { + const labels = await github.rest.issues.listLabelsOnIssue(issueParams(context)) + + await removeLabelsByName({ + github, + context, + currentLabels: labelNames(labels), + labelsToRemove: [REQUEST_THEME_LABEL] + }) + + await delay(10000) + + await github.rest.issues.addLabels({ + ...issueParams(context), + labels: [REQUEST_THEME_LABEL] + }) +} + /** * Add the question label to an issue. * @@ -423,6 +450,9 @@ async function run({github, context}) { if (command === 'approve') { console.log('approve command running') await queueIssueForApproval({github, context}) + } else if (command === 'check') { + console.log('check command running') + await checkThemeRequest({github, context}) } else if (command === 'edit') { console.log('edit command running') @@ -447,6 +477,7 @@ module.exports = { addQuestionLabel, actorIsRepositoryAdmin, canRunCommand, + checkThemeRequest, editThemeRequest, issueAuthorCanRunCommand, loadTrustedCommandUsers, diff --git a/tests/github_scripts.test.js b/tests/github_scripts.test.js index cf21fe6569d6..ec2c69772121 100644 --- a/tests/github_scripts.test.js +++ b/tests/github_scripts.test.js @@ -767,6 +767,15 @@ describe('comment command script', () => { issueAuthorId: '9999', trustedCommandUsers })).resolves.toBe(false) + await expect(commentCommand.canRunCommand({ + github, + context, + command: 'check', + actor: '', + commentAuthorId: '1111', + issueAuthorId: '9999', + trustedCommandUsers + })).resolves.toBe(false) }) test('allows issue authors to run edit commands only', () => { @@ -785,6 +794,11 @@ describe('comment command script', () => { commentAuthorId: '1234', issueAuthorId: '1234' })).toBe(false) + expect(commentCommand.issueAuthorCanRunCommand({ + command: 'check', + commentAuthorId: '1234', + issueAuthorId: '1234' + })).toBe(false) expect(commentCommand.issueAuthorCanRunCommand({ command: 'edit', commentAuthorId: '', @@ -915,6 +929,46 @@ describe('comment command script', () => { expect(github.rest.reactions.createForIssueComment).toHaveBeenCalled() }) + test('runs check commands by re-applying the request label', async () => { + runTimersImmediately() + const removedLabels = [] + process.env.COMMENT_BODY = '@LizardByte-bot check' + process.env.COMMENT_ID = '123' + process.env.COMMENT_AUTHOR_ID = '42013603' + process.env.GITHUB_ACTOR = 'trusted-user' + process.env.ISSUE_AUTHOR_ID = '8888' + process.env.ISSUE_BODY = 'https://youtu.be/old' + process.env.YT_REGEX = String.raw`youtu\.be` + const github = { + rest: { + issues: { + listLabelsOnIssue: jest.fn(async () => labels('request-theme', 'question')), + removeLabel: jest.fn(async params => removedLabels.push(params.name)), + addLabels: jest.fn() + }, + reactions: { + createForIssueComment: jest.fn() + } + } + } + + await commentCommand.run({github, context}) + + expect(removedLabels).toEqual(['request-theme']) + expect(github.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'LizardByte', + repo: 'ThemerrDB', + issue_number: 7, + labels: ['request-theme'] + }) + expect(github.rest.reactions.createForIssueComment).toHaveBeenCalledWith({ + owner: 'LizardByte', + repo: 'ThemerrDB', + comment_id: 123, + content: '+1' + }) + }) + test('runs question commands from trusted wildcard users', async () => { process.env.COMMENT_BODY = '/question Can you fix this theme?' process.env.COMMENT_ID = '123' From 97473bc2eba16eac90f86e1dfa30e20a1de2a946 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:41:35 -0400 Subject: [PATCH 5/8] refactor: move bot commands to docs folder Reorganize documentation by moving bot_commands.md to docs/botCommands.md. Update all references in workflows and documentation to point to the new location. Consolidate bot command references in approverGuide.md to reduce duplication and point to the centralized Bot Commands reference. --- .github/workflows/auto-update-db.yml | 2 +- docs/approverGuide.md | 11 ++++------- bot_commands.md => docs/botCommands.md | 1 - 3 files changed, 5 insertions(+), 9 deletions(-) rename bot_commands.md => docs/botCommands.md (99%) diff --git a/.github/workflows/auto-update-db.yml b/.github/workflows/auto-update-db.yml index ed9190daca69..480741fdb136 100644 --- a/.github/workflows/auto-update-db.yml +++ b/.github/workflows/auto-update-db.yml @@ -147,7 +147,7 @@ jobs: echo "\`\`\`" echo "" echo "" - cat ../bot_commands.md + cat ../docs/botCommands.md } >> ../comment.md cat ../comment.md echo "::endgroup::" diff --git a/docs/approverGuide.md b/docs/approverGuide.md index debee04a2293..01da3f194903 100644 --- a/docs/approverGuide.md +++ b/docs/approverGuide.md @@ -67,9 +67,9 @@ The following guide is meant for authorized Themerr approvers to use when approv - First, make sure the latest bot comment matches the latest edit of the issue body (it might take a few minutes, depending on how busy the GitHub activity is in our org at the time). - - To approve the theme, comment `@LizardByte-bot approve`. This will initiate a bot workflow where it will add the - `approve-queue` label. Once the issue comes to the front of the queue, the bot will run the checks again, then it - will add the theme to the database (assuming it passes everything). + - Use the approve command from the [Bot Commands](../docs/botCommands.md) reference. This will initiate a bot + workflow where it will add the `approve-queue` label. Once the issue comes to the front of the queue, the bot will + run the checks again, then it will add the theme to the database (assuming it passes everything). > [!WARNING] > Try to avoid approving any themes when a scheduled update is running. @@ -78,15 +78,12 @@ The following guide is meant for authorized Themerr approvers to use when approv ## Helpful Tools -- If you have a question or suggestion for a theme submission, comment `/question {message}` to mark the issue. +- Bot commands for approvals, checks, edits, and questions are documented in [Bot Commands](../docs/botCommands.md). > [!NOTE] > If a user fails to answer your question after about a week or longer, you can proceed how you think best with the theme. -- A youtube URL in a user submission can be edited by commenting `@LizardByte-bot edit {youtube_url}`. - This will replace the youtube URL and run the workflow to check the new URL. - A useful website for checking regional blocks on a Youtube video can be found [here](https://watannetwork.com/tools/blocked/). -- A bot workflow to check a theme can be manually initiated by commenting `@LizardByte-bot check`. - The `approve-queue` label can be manually added to add an issue to the bot approval queue. > [!NOTE] diff --git a/bot_commands.md b/docs/botCommands.md similarity index 99% rename from bot_commands.md rename to docs/botCommands.md index 6c2e317aba9d..b037dd137e22 100644 --- a/bot_commands.md +++ b/docs/botCommands.md @@ -10,5 +10,4 @@ A moderator can trigger LizardByte-bot actions by commenting on this issue: - `@LizardByte-bot edit {youtube_url}` replace the YouTube url in the issue body with the given url - `/question {message}` add the question label while asking the submitter for more information - From eccc2c19b27632505cd0a13755741cb9fe6f4ebd Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:51:14 -0400 Subject: [PATCH 6/8] Fix object stringification in normalize helpers Guard normalizeUserId and normalizeCommandName against non-primitive inputs (objects, null, undefined) to prevent unintended '[object Object]' strings. Introduce NORMALIZABLE_PRIMITIVE_TYPES set and export normalizeCommandName for testing. --- src/github-scripts/comment-command.js | 16 +++++++++++----- tests/github_scripts.test.js | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/github-scripts/comment-command.js b/src/github-scripts/comment-command.js index a911debba71c..6cfa983b221a 100644 --- a/src/github-scripts/comment-command.js +++ b/src/github-scripts/comment-command.js @@ -19,6 +19,7 @@ const ALL_COMMANDS = '*' const AUTO_APPROVED_USERS_FILE = 'auto_approved_users.json' const BOT_COMMAND_PREFIX = '@LizardByte-bot' const KNOWN_COMMANDS = new Set(['approve', 'check', 'edit', 'question']) +const NORMALIZABLE_PRIMITIVE_TYPES = new Set(['bigint', 'number', 'string']) const QUESTION_LABEL = 'question' const REQUEST_THEME_LABEL = 'request-theme' @@ -69,25 +70,29 @@ function parseCommandComment(comment) { /** * Normalize a GitHub user id for matching. * - * @param {object} userId GitHub user id. + * @param {*} userId GitHub user id. * @returns {string} Trimmed user id string. */ function normalizeUserId(userId) { - if (userId === null || userId === undefined) { + if (!NORMALIZABLE_PRIMITIVE_TYPES.has(typeof userId)) { return '' } - return `${userId}`.trim() + return String(userId).trim() } /** * Normalize a command name for matching. * - * @param {object} command Bot command name. + * @param {*} command Bot command name. * @returns {string} Lowercase command name. */ function normalizeCommandName(command) { - return `${command}`.trim().toLowerCase() + if (!NORMALIZABLE_PRIMITIVE_TYPES.has(typeof command)) { + return '' + } + + return String(command).trim().toLowerCase() } /** @@ -482,6 +487,7 @@ module.exports = { issueAuthorCanRunCommand, loadTrustedCommandUsers, normalizeComment, + normalizeCommandName, normalizeUserId, parseCommandComment, run diff --git a/tests/github_scripts.test.js b/tests/github_scripts.test.js index ec2c69772121..0f4414f7243e 100644 --- a/tests/github_scripts.test.js +++ b/tests/github_scripts.test.js @@ -644,6 +644,22 @@ describe('comment command script', () => { .toBe('@LizardByte-bot approve') }) + test('normalizes user ids without object stringification', () => { + expect(commentCommand.normalizeUserId(' 1234 ')).toBe('1234') + expect(commentCommand.normalizeUserId(1234)).toBe('1234') + expect(commentCommand.normalizeUserId({user_id: 1234})).toBe('') + expect(commentCommand.normalizeUserId(null)).toBe('') + expect(commentCommand.normalizeUserId(undefined)).toBe('') + }) + + test('normalizes command names without object stringification', () => { + expect(commentCommand.normalizeCommandName(' CHECK ')).toBe('check') + expect(commentCommand.normalizeCommandName(123)).toBe('123') + expect(commentCommand.normalizeCommandName({command: 'check'})).toBe('') + expect(commentCommand.normalizeCommandName(null)).toBe('') + expect(commentCommand.normalizeCommandName(undefined)).toBe('') + }) + test('parses bot mention and slash commands', () => { expect(commentCommand.parseCommandComment('@LizardByte-bot approve')).toEqual({ command: 'approve', From a94bd943863c5def70ff3fc0fe5b12b8be78dc97 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:57:16 -0400 Subject: [PATCH 7/8] Export getActor and add tests Export the `getActor` function from comment-command.js and add tests covering all supported actor sources: context, comment payload, environment variable, and fallback to empty string. --- src/github-scripts/comment-command.js | 1 + tests/github_scripts.test.js | 29 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/github-scripts/comment-command.js b/src/github-scripts/comment-command.js index 6cfa983b221a..a1f11342a772 100644 --- a/src/github-scripts/comment-command.js +++ b/src/github-scripts/comment-command.js @@ -484,6 +484,7 @@ module.exports = { canRunCommand, checkThemeRequest, editThemeRequest, + getActor, issueAuthorCanRunCommand, loadTrustedCommandUsers, normalizeComment, diff --git a/tests/github_scripts.test.js b/tests/github_scripts.test.js index 0f4414f7243e..e3da5fc07d66 100644 --- a/tests/github_scripts.test.js +++ b/tests/github_scripts.test.js @@ -660,6 +660,35 @@ describe('comment command script', () => { expect(commentCommand.normalizeCommandName(undefined)).toBe('') }) + test('gets the actor from each supported source', () => { + process.env.GITHUB_ACTOR = 'env-user' + + expect(commentCommand.getActor({ + actor: 'context-user', + payload: { + comment: { + user: { + login: 'comment-user' + } + } + } + })).toBe('context-user') + expect(commentCommand.getActor({ + payload: { + comment: { + user: { + login: 'comment-user' + } + } + } + })).toBe('comment-user') + expect(commentCommand.getActor({payload: {}})).toBe('env-user') + + delete process.env.GITHUB_ACTOR + + expect(commentCommand.getActor({payload: {}})).toBe('') + }) + test('parses bot mention and slash commands', () => { expect(commentCommand.parseCommandComment('@LizardByte-bot approve')).toEqual({ command: 'approve', From 4afbbec847a9d8f6e0bd74b135747516a70b9588 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:05:53 -0400 Subject: [PATCH 8/8] Add YouTube theme URL to issue metadata Include the youtube_theme_url in issue comment tables and generated JSON files. This ensures approvers use the canonical URL from the LizardByte-Bot comment (as documented in the updated guidelines) rather than the original issue body, maintaining consistency across all generated database files. --- docs/approverGuide.md | 8 ++++++-- docs/themeApproverGuidelines.md | 7 +++++++ src/updater.py | 9 +++++++-- tests/unit/test_issue_updater.py | 13 +++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/approverGuide.md b/docs/approverGuide.md index 01da3f194903..7db195d26762 100644 --- a/docs/approverGuide.md +++ b/docs/approverGuide.md @@ -60,8 +60,12 @@ The following guide is meant for authorized Themerr approvers to use when approv - This portion comes down to human judgement, but the [Theme Approver Guidelines](../docs/themeApproverGuidelines.md) and [Theme Guidelines](../docs/themeGuidelines.md) should be referenced for precise rules when deciding. - - Typically when examining a theme, we set the computer to about 50% volume then right-click and open the theme in an - incognito window (so google doesn't recommend stuff based on every theme that's reviewed). + - Typically when examining a theme, we set the computer to about 50% volume. + - Open only the `youtube_theme_url` link from the latest LizardByte-Bot comment table. Do not use the link from the + original issue body, since the bot comment contains the same canonical URL that will be written to the generated + JSON files. + - Prefer opening the theme in a private or incognito browsing session so YouTube does not adjust personal account + recommendations based on reviewed submissions. 7. Approve the theme diff --git a/docs/themeApproverGuidelines.md b/docs/themeApproverGuidelines.md index de7ca9332680..d7d5f273d2aa 100644 --- a/docs/themeApproverGuidelines.md +++ b/docs/themeApproverGuidelines.md @@ -8,6 +8,13 @@ submission passes the automated checks. Prefer themes that clearly represent the submitted movie, show, game, collection, or franchise. +## Review Source + +Open the theme only from the `youtube_theme_url` row in the latest LizardByte-Bot comment table, not from the original +issue body. The bot comment contains the canonical URL that will be written to the generated JSON files. Prefer using a +private or incognito browsing session so YouTube does not adjust personal account recommendations based on reviewed +submissions. + ## Review Criteria - Themes should be representative of the movie, show, game, collection, etc. diff --git a/src/updater.py b/src/updater.py index ff336b7eaebe..008f8b1e6571 100644 --- a/src/updater.py +++ b/src/updater.py @@ -1248,7 +1248,7 @@ def _build_issue_metadata(item_type: str, json_data: dict) -> dict: return metadata_builders[item_type](json_data) -def _write_issue_metadata_files(item_type: str, json_data: dict) -> None: +def _write_issue_metadata_files(item_type: str, json_data: dict, youtube_url: Optional[str]) -> None: """Write the issue comment and title files for an issue update.""" metadata = _build_issue_metadata(item_type=item_type, json_data=json_data) issue_comment = f""" @@ -1258,6 +1258,7 @@ def _write_issue_metadata_files(item_type: str, json_data: dict) -> None: | year | {metadata['year']} | | summary | {metadata['summary']} | | id | {json_data['id']} | +| youtube_theme_url | {youtube_url or ''} | | poster | {metadata['poster']} | """ with open("comment.md", "a") as comment_f: @@ -1381,7 +1382,11 @@ def process_item_id(item_type: str, pass else: if args.issue_update: - _write_issue_metadata_files(item_type=item_type, json_data=json_data) + _write_issue_metadata_files( + item_type=item_type, + json_data=json_data, + youtube_url=youtube_url, + ) _update_issue_audit_data( og_data=og_data, item_type=item_type, diff --git a/tests/unit/test_issue_updater.py b/tests/unit/test_issue_updater.py index b0a7d64dba56..c775a2b0314f 100644 --- a/tests/unit/test_issue_updater.py +++ b/tests/unit/test_issue_updater.py @@ -404,12 +404,14 @@ def test_write_issue_metadata_files(tmp_path, monkeypatch): updater._write_issue_metadata_files( item_type='game_collection', json_data={'id': 326, 'name': 'James Bond'}, + youtube_url='https://www.youtube.com/watch?v=themevideo', ) assert (tmp_path / 'title.md').read_text(encoding='utf-8') == '[GAME COLLECTION]: James Bond' comment = (tmp_path / 'comment.md').read_text(encoding='utf-8') assert '| title | James Bond |' in comment assert '| id | 326 |' in comment + assert '| youtube_theme_url | https://www.youtube.com/watch?v=themevideo |' in comment def test_update_issue_audit_data_sets_original_submission_fields(tmp_path, monkeypatch, youtube_url): @@ -604,6 +606,17 @@ def test_process_issue_update( data = updater.process_issue_update(database_url=db_url, youtube_url=youtube_url) assert data == db_type + comment = (tmp_path / 'comment.md').read_text(encoding='utf-8') + assert f'| youtube_theme_url | {youtube_url} |' in comment + + generated_theme_urls = [] + for item_file in (tmp_path / 'database').rglob('*.json'): + item_data = json.loads(item_file.read_text(encoding='utf-8')) + if 'youtube_theme_url' in item_data: + generated_theme_urls.append(item_data['youtube_theme_url']) + + assert generated_theme_urls + assert set(generated_theme_urls) == {youtube_url} def test_process_issue_update_invalid_youtube(issue_update_args, submission_invalid_youtube):