Skip to content

[Feature] Cinema Mode for episodes setting for Moonbase plugin #102

[Feature] Cinema Mode for episodes setting for Moonbase plugin

[Feature] Cinema Mode for episodes setting for Moonbase plugin #102

name: Require a real title and evidence on issues
# Every issue needs a title that says something beyond the template's "[Settings]" tag,
# and bug reports need a screenshot, since the plugin keeps no diagnostic log of its own.
# GitHub can't reject an issue at submission time, so this labels and asks instead,
# and it catches the "n/a" that a required form field can't.
on:
issues:
types: [opened, edited, reopened, labeled]
issue_comment:
types: [created, edited]
permissions:
issues: write
jobs:
check-issue:
runs-on: ubuntu-latest
# Don't spin up a runner to re-check a closed issue or to react to another bot.
if: >-
github.event.issue.state == 'open' &&
(github.event_name != 'issue_comment' || github.event.comment.user.type != 'Bot')
steps:
- name: Check title and screenshots
uses: actions/github-script@v7
with:
script: |
const MARKER = '<!-- missing-evidence-bot -->';
const MIN_TITLE = 10;
const CHECKS = {
template: {
name: 'Missing Template',
color: 'e99695',
description: 'Issue opened without one of the issue forms',
},
title: {
name: 'Missing Title',
color: 'c5def5',
description: 'Issue still has the untouched template title',
},
screenshot: {
name: 'Missing Screenshot',
color: 'fbca04',
description: 'Bug reported without a screenshot',
},
};
const issue = context.payload.issue;
if (!issue || issue.pull_request) return;
if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(issue.author_association)) return;
// The templates prefill "[Plugin] ", "[Settings] ", "[Feature] ",
// so split the leading tags off and judge the two halves apart.
const title = issue.title || '';
const prefix = (title.match(/^\s*(?:\[[^\]]*\]\s*)+/) || [''])[0];
const rest = title.slice(prefix.length).replace(/[\s.!?-]+$/, '').trim();
const tagged = tag => prefix.toLowerCase().includes(`[${tag}]`);
// Only the tag is worth reading here. The labels the forms ask for have never
// been created on this repo, so no issue has ever carried one.
const needsImage = tagged('plugin') || tagged('settings');
const GENERIC = /^(bug|issue|issues|problem|problems|help|help me|crash|crashes|broken|error|errors|fix|please fix|question|feature|request|title|test|n\/?a|none|plugin|moonbase|settings|jellyfin|emby|seerr|not working|doesn'?t work|does not work|no work)$/i;
const badTitle = rest.length < MIN_TITLE || GENERIC.test(rest);
const labels = (issue.labels || []).map(l => l.name);
// With nothing to ask for and no label left over from an earlier run, there's
// no reason to spend an API call reading the comments.
const leftover = Object.values(CHECKS).some(c => labels.includes(c.name));
// Every form renders one "### Question" heading per field, blank ones
// included, and the smallest still produces four. A body without them
// came in around the forms, which is also why it never picked up the
// labels the evidence checks read. Counting headings catches that even
// when the author typed a convincing tag into the title by hand.
const countFormHeadings = text => (text.match(/^### /gm) || []).length;
const mayNeedTemplate = countFormHeadings(issue.body || '') < 3;
if (!badTitle && !needsImage && !mayNeedTemplate && !leftover) return;
// Read the body and every human comment, so evidence posted later still counts.
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 100,
});
const authored = [issue.body || '']
.concat(comments.filter(c => c.user.type !== 'Bot').map(c => c.body || ''))
.join('\n');
const hasImage = [
/!\[[^\]]*\]\(\s*https?:\/\/\S+/i,
/<img\s[^>]*src\s*=/i,
/<video\s/i,
/https:\/\/github\.com\/user-attachments\/assets\/\S+/i, // current upload host
/https:\/\/user-images\.githubusercontent\.com\/\S+/i, // the older upload host
/https?:\/\/\S+\.(png|jpe?g|gif|webp|heic|bmp|mp4|mov|webm|m4v)(\?\S*)?/i,
].some(re => re.test(authored));
// Pasting the form's answers into a comment counts, which is what the
// ask invites, so this reads the same text the evidence checks do.
const needsTemplate = countFormHeadings(authored) < 3;
const wanted = [];
if (needsTemplate) wanted.push('template');
if (badTitle) wanted.push('title');
if (needsImage && !hasImage) wanted.push('screenshot');
// Drop any label whose problem has since been fixed.
for (const [kind, check] of Object.entries(CHECKS)) {
if (!wanted.includes(kind) && labels.includes(check.name)) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: check.name,
});
}
}
if (!wanted.length) return;
const toAdd = wanted.map(k => CHECKS[k]).filter(c => !labels.includes(c.name));
for (const check of toAdd) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner, repo: context.repo.repo, name: check.name,
});
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: check.name,
color: check.color,
description: check.description,
});
}
}
if (toAdd.length) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: toAdd.map(c => c.name),
});
}
const TEMPLATE_ASK = [
'### This issue skipped the issue form',
'',
'It was opened without one of the forms, so the fields that say which version, platform and setup this involves are all missing.',
'',
'The forms also decide how an issue gets labelled, and those labels are what tell this bot whether to ask you for a screenshot. Skipping the form means nobody was prompted for the one thing the report actually needs.',
'',
`Please open a fresh one from [New issue](https://github.com/${context.repo.owner}/${context.repo.repo}/issues/new/choose) and pick the form that fits, then this can be closed as a duplicate. If you would rather not start over, paste the form's answers into a comment below and they will be picked up.`,
'',
'- **Bug: Server / Plugin** for the plugin or the server side, which needs a screenshot',
'- **Bug: Settings Sync** for settings not carrying between clients, which needs a screenshot',
'- **Feature request** for something new',
].join('\n');
const TITLE_ASK = [
'### The title still looks like the template',
'',
`This issue is titled \`${title}\`, which doesn't say what actually went wrong. Titles like that are impossible to scan, search, or spot as a duplicate.`,
'',
'Please edit the title to describe the problem itself, keeping the tag on the front:',
'',
'- `[Settings] Expanded Home Rows never reaches the second client`',
'- `[Plugin] MDBList ratings stop syncing after the quota check`',
'- `[Plugin] Seerr requests fail with 403 behind a reverse proxy`',
'',
'You can change it with the **Edit** button next to the title.',
].join('\n');
const SCREENSHOT_ASK = [
'### A screenshot is missing',
'',
"The plugin keeps no diagnostic log of its own, so a picture of what you're seeing is the fastest way to make a report actionable.",
'',
'Drag a screenshot straight into a comment below, showing the plugin config page, the client screen, or whatever is wrong. For a sync problem, the same setting on each client side by side is what makes it obvious.',
'',
'- Windows: `Win + Shift + S`',
'- macOS: `Cmd + Shift + 4`',
'- Linux: `PrtSc`, or your desktop screenshot tool',
'',
"It's also worth pasting anything the server logged. Open the **Logs** page in your Jellyfin or Emby dashboard and search for `Moonfin`, which is the name the plugin logs under.",
].join('\n');
const ASKS = {
template: TEMPLATE_ASK,
title: TITLE_ASK,
screenshot: SCREENSHOT_ASK,
};
// Each ask opens with its own heading, so a later run can see which ones
// already went out and raise only what is new. That way an issue labelled
// during triage still gets its evidence request, rather than the whole
// comment being skipped because the bot spoke once already.
const spoken = comments.filter(c => (c.body || '').includes(MARKER));
const pending = wanted.filter(
kind => !spoken.some(c => c.body.includes(ASKS[kind].split('\n')[0])),
);
if (!pending.length) return;
const named = pending.map(k => `**${CHECKS[k].name}**`);
const listed = named.length === 1
? named[0]
: `${named.slice(0, -1).join(', ')} and ${named[named.length - 1]}`;
const parts = [
MARKER,
`Thanks for the report, @${issue.user.login}.`,
'',
`Before anyone can pick this up it needs a little more from you, so it has been labelled ${listed}.`,
'',
];
for (const kind of pending) parts.push(ASKS[kind], '');
parts.push('Each label comes off automatically once that piece is sorted.');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: parts.join('\n'),
});