A question and bug reporter for the Inquisitor IRC trivia bot
Find a file
2026-10-29 13:28:26 -04:00
.planning docs(phase-07): add validation strategy 2026-10-29 13:28:26 -04:00
question-sets-template feat(06-02): post the recovery comment only when the edit link is missing 2026-10-29 12:47:06 -04:00
tests test(06-02): add TEST-04 transactional rollback tests and pin the D-10 busy text 2026-10-29 12:48:12 -04:00
.gitignore chore: ignore Limnoria bot runtime dirs 2026-02-19 19:22:33 -05:00
__init__.py chore: bump version number 2026-07-07 01:07:23 -04:00
config.py feat(03-01): wire export into report() and update config.py scope description 2026-02-23 02:02:11 -05:00
plugin.py feat(06-01): recover busy-path questions when an edit round merges 2026-10-29 12:39:31 -04:00
pytest.ini feat(05-01): pytest harness and report-to-import tracer test 2026-10-29 00:55:36 -04:00
README.md docs(06-03): sync root README with busy-path recovery and protected main 2026-10-29 12:44:00 -04:00
requirements-dev.txt feat(05-01): pytest harness and report-to-import tracer test 2026-10-29 00:55:36 -04:00
test.py fix: move plugin files to repo root 2026-02-19 19:22:21 -05:00

Herodotus

A Limnoria/Supybot plugin that lets registered players of the Inquisitor trivia bot file question reports, bug reports, and feature ideas as Forgejo issues directly from IRC. Reports also export the affected question's data as YAML to a Forgejo branch so editors can review and merge changes through a normal pull request, with CI importing approved edits back into the live database.

Requirements

  • Python 3.10+
  • Limnoria (Supybot framework)
  • The Inquisitor plugin loaded in the same bot instance — Herodotus delegates DB lookups and permission checks to it
  • A Forgejo (or Gitea 1.20+) instance reachable from the bot, with an API token that has write:issue and write:repository scopes
  • A companion question-sets repo with the validate / import workflows installed (templates ship in question-sets-template/ — see Setting up the question-sets repo below)

The plugin itself uses only the Python standard library (urllib, json, base64, re, datetime) — no third-party HTTP clients. The CI scripts in question-sets-template/scripts/ need PyYAML and mariadb, but those run in the question-sets repo's workflow container, not in the bot.

Installation

1. Install the plugin in your bot

Clone into your bot's plugin directory:

cd /path/to/limnoria/plugins
git clone https://git.ptp-trivia.me/Inquisitor/Herodotus.git

Then load it in the bot:

@load Herodotus

Inquisitor must already be loaded for permission and DB access to work.

2. Set up the question-sets repo

The report → edit → import loop needs CI on the other side of the wire. Follow Setting up the question-sets repo before you start filing reports — without it, exports will land in branches but never make it back into the database.

Configuration

All settings are registered as Limnoria plugin config values. Set them with @config plugins.Herodotus.<key> <value>.

Key Default Description
forgejoToken (empty) Forgejo API token. Private — set via @config plugins.Herodotus.forgejoToken <token> in a private query. Required scopes: write:issue, write:repository.
forgejoUrl https://git.ptp-trivia.me Base URL of the Forgejo instance. No trailing slash.
questionSetsRepo Inquisitor/question-sets owner/repo for question reports and feature ideas. Also the destination for YAML exports.
bugRepo Inquisitor/Inquisitor owner/repo for bug reports.
logChannel (empty) IRC channel that receives Forgejo API failure notices. Format network:#channel (e.g. libera:#ops). Leave empty to log only to the file log.

Commands

report <num> <text> — flag a bad question

Files an issue against questionSetsRepo for question <num>. Requires PLAY permission (registered Inquisitor player).

The plugin looks up the question in Inquisitor's database, embeds the question text, accepted answer, and any alt answers in the issue body, and tags it with issue and a label slugified from the question's set name (e.g. general-knowledge).

Once the report is filed, the question is temporarily parked (is_active = 0) so the bot stops asking it while it is under review. The park is reversed automatically by CI on merge: a merged edit reactivates the question (is_active = 1), while a merged delete (delete: true) leaves it inactive, retiring it for good. If the review PR is never merged, the question stays parked until it is — reopen or merge the PR (or set delete: true) to resolve it.

If an open issue with Q#<num> already exists, the new report is appended as a comment instead of opening a duplicate. A parked question is still reportable, so follow-up reports are captured on the existing issue.

<player> .report 4231 the answer says "Mercury" but should be "Venus"
<bot>    Reported. Issue #87 filed.

The same report also stages the question's YAML at reports/<id>.yaml for review. The baseline — the question's current content, the "before" side of the diff — is committed to main with [skip ci] (so the import workflow ignores it). The long-lived editing branch is then synced to descend from main, which is what makes a later PR editing → main render the editor's changes as a real before/after diff instead of a file-addition: the baseline has to sit in the PR's merge-base, not merely on main's tip. Editors change the file on editing; the issue body links straight to it.

Keeping editing descended from main is automatic and needs no pull request from the plugin — editors open the editing → main PR themselves whenever they are ready. While editing carries no un-merged edits, each report recreates it from main, so it accumulates every pending baseline and all those questions stay editable. Once an editor makes the first change, editing is left untouched to protect that work; such a question parks its baseline on main, and its issue says the edit link will be posted there automatically. The reset is driven entirely by CI: when the review PR merges, CI deletes editing and, if reported questions are still waiting, recreates it from main at once (now carrying the parked baselines) and comments the edit link on each waiting question's issue. Otherwise the next report recreates it. Waiting questions stay parked until their own edit merges or an admin runs unpark. A branch freshly recreated from main already has its questions' baselines in the merge-base, so a manually opened PR shows correct before/after diffs with no merge plumbing.

bug <text> — file a bot bug

Files an issue against bugRepo. Requires PLAY permission. Tagged bug and triage. No deduplication — every invocation creates a new issue.

<player> .bug bot stops responding after a netsplit
<bot>    Bug filed: https://git.ptp-trivia.me/Inquisitor/Inquisitor/issues/204

idea <text> — submit a feature idea

Files an issue against questionSetsRepo. Requires PLAY permission. Tagged idea. No deduplication.

<player> .idea per-channel difficulty levels
<bot>    Idea filed. Issue #88.

bulkexport [--branch <name>] [--set <name>] [--category <value>] [--text <substr>] [--answer <substr>] [--dry-run] — admin batch export

Exports every question matching the supplied filters to reports/<id>.yaml files on <branch> in questionSetsRepo, in a single Forgejo batch commit. Requires ADMIN permission. --branch is mandatory and at least one filter flag must be provided.

Baselines are first written to main (with [skip ci]), then <branch> is created as a fresh descendant of that main — so the baselines sit in the PR merge-base and any edits made on <branch> show as real before/after diffs in a PR back to main. The files are already present on <branch> via main's history; editors change them in place and open the PR. If <branch> already exists with un-merged edits, the command refuses and asks for a new name (so it never clobbers in-progress work). The baseline/staging step is skipped when --branch main, which instead commits directly to main and applies the questions through CI.

Filters combine with AND. --text and --answer are SQL LIKE substrings (wildcards in user input are escaped). --dry-run prints a count and a three-question preview without contacting Forgejo.

<admin>  .bulkexport --branch geography-cleanup --category Geography --dry-run
<bot>    Would export 47 questions. Preview: #102 'Largest country by area?', #211 ...

<admin>  .bulkexport --branch geography-cleanup --category Geography
<bot>    Exporting 47 questions to 'geography-cleanup'...
<bot>    Exported 47 questions (0 skipped). https://git.ptp-trivia.me/Inquisitor/question-sets/src/branch/geography-cleanup

A file already present on the target branch is skipped — re-running the command won't clobber edits in flight.

createpr <branch> [<title>] — open a review PR

Opens a pull request from <branch> to main in questionSetsRepo, so edits staged on a branch can be reviewed and merged. Requires MANAGE permission. The plugin does not open PRs automatically, so this is how a staged editing (or a bulkexport target branch) gets turned into a reviewable PR from IRC.

<title> is optional free-form text used as the PR title; if omitted, a default (Question edits from <branch>) is used. The command checks the branch exists and has changes against main before calling the API; if a PR from that branch is already open, it replies with the existing PR's URL instead of erroring.

<manager> .createpr editing Fix Venus/Mercury mixups in astronomy set
<bot>     Pull request opened: https://git.ptp-trivia.me/Inquisitor/question-sets/pulls/93

unpark <num> — reactivate a parked question

Sets is_active = 1 for question <num>, so the bot resumes asking it. Requires MANAGE permission.

Filing a report parks the question (is_active = 0) so it stops being asked while under review; the import CI reactivates it when the report's PR merges. unpark is the manual backstop for the case where that PR is never merged — closed unmerged, or the issue is abandoned — which otherwise leaves the question parked indefinitely.

unpark is idempotent: on a question that is already active it still replies Q#<num> reactivated. Only an unknown number gets Question #<num> not found.

<manager> .unpark 4231
<bot>     Q#4231 reactivated.

Permissions

Herodotus does not use Limnoria's native capability system for player commands. Instead it asks Inquisitor:

  • report, bug, idea require Permission.PLAY (registered player)
  • createpr and unpark require Permission.MANAGE
  • bulkexport requires Permission.ADMIN

Permission checks fail closed: any error during the check denies the command. The Inquisitor plugin must be loaded — if it is not, every command replies with Inquisitor plugin is not loaded. and aborts.

The report → edit → import loop

  1. A player runs .report <num> <text>.
  2. Herodotus looks up the question, opens (or comments on) a Forgejo issue, parks the question inactive (is_active = 0) so the bot stops asking it while it is under review, commits the question's current YAML as a baseline on main (with [skip ci]), and syncs editing to descend from main so the baseline lands in the PR merge-base.
  3. An editor edits reports/<id>.yaml on editing (or sets delete: true to retire the question), then opens a PR editing → main whenever they are ready. The plugin creates no PR itself; a branch recreated from main already has the right merge-base, so the diff is correct. Reports arriving after editing has begun park their baseline on main; when the current round merges, CI recreates editing and posts their edit links (see step 5).
  4. The validate-reports.yml workflow lints the YAML on the PR.
  5. On merge, the import-reports.yml workflow runs import_reports.py, which reads the merged YAML and updates the corresponding row in Inquisitor's database in a single transaction. An edit writes the new fields and reactivates the question (is_active = 1), undoing the report-time park so it returns to the asking pool; a delete (delete: true) leaves it inactive, soft-deleting it. The script then comments the result on the PR, removes the imported baseline files from main, deletes the PR's head branch (the disposable editing branch), and closes the PR. Last, the script recreates editing from main when reported questions are still waiting, and comments the edit link on each one's open issue.

The CI workflows live in the question-sets repo and are intentionally outside the plugin codebase — Herodotus only writes the YAML; everything else is git-driven. Templates for those workflows ship in question-sets-template/; see the next section.

Setting up the question-sets repo

Herodotus is one half of a two-repo system. The other half is the question-sets Forgejo repo, which holds two CI workflows and the scripts they call. The plugin pushes YAML into branches of that repo; the CI lints PRs and applies merged edits back to the database.

The full template — kept in lockstep with the plugin's expectations — lives at question-sets-template/ inside this repository. That directory has its own README with detailed walkthrough; the steps below are the short version.

Files to copy

question-sets-template/
├── scripts/
│   ├── validate_reports.py
│   └── import_reports.py
└── .forgejo/
    └── workflows/
        ├── validate-reports.yml
        └── import-reports.yml

One-time bootstrap

  1. Create the repo at <owner>/question-sets on the same Forgejo instance the bot talks to. The path here must match plugins.Herodotus.questionSetsRepo exactly.

  2. Copy scripts/ and .forgejo/ from question-sets-template/ into the repo's root, preserving the directory structure.

  3. Add a placeholder reports/.gitkeep and commit it so Herodotus's first export does not race a missing parent directory.

  4. (Optional) the editing branch is managed automatically. Herodotus creates it from main on the first report, keeps it descended from main, and CI deletes it on each merge and recreates it straight away when reported questions are waiting — you do not need to create it by hand. The bot's token therefore needs push access to main (it is committed to directly for baselines). If you protect main, which is recommended so the validation check gates merges, pick Whitelist restricted push and add the bot's user and the CI token's user, because both commit to main directly. See the template README.

  5. import-reports.yml needs no repo-specific edit. The import step sets REPO: ${{ forgejo.repository }}, so cleanup, the PR comment and the PR close always target the repo the workflow runs in. (Removing that env line makes the script fall back to Inquisitor/question-sets.)

  6. Configure repo secrets under Settings → Actions → Secrets:

    Secret Used by Notes
    DB_HOST both workflows MariaDB host for Inquisitor's DB
    DB_PORT both optional; defaults to 3306
    DB_USER both validate only needs SELECT; import needs UPDATE on questions
    DB_PASS both
    DB_NAME both
    FORGEJO_URL import-reports only the base URL users open in a browser, no trailing slash, because recovery posts edit links built from it
    FORGEJO_TOKEN import-reports only scopes write:issue + write:repository; can be a CI-only token, separate from the bot's
  7. Mint and configure the bot's token with the same write:issue + write:repository scopes, and set it on the bot side via @config plugins.Herodotus.forgejoToken <token>. This is a different value from the CI's FORGEJO_TOKEN.

After this you can run .report <num> <text> from IRC and watch a fresh issue + a reports/<id>.yaml on editing appear in your new repo.

Container choice and actions/checkout

Both workflows run in python:3.11-slim, which has git but not Node.js. They therefore use raw git clone rather than actions/checkout — switching to a node-bearing image lets you use the action again, but the raw-clone form is the supported path. validate-reports.yml clones with --no-single-branch so git checkout "$GITHUB_HEAD_REF" resolves the PR ref; import-reports.yml does a normal full clone because the merged commit is already on main by the time the job starts.

Keeping the template in sync

If you patch the live CI scripts in your question-sets repo, mirror the change back into question-sets-template/ here and commit it alongside any related plugin change. Pinning both halves in one repo is how the plugin and CI stay coupled across releases.

Logging

Failures during Forgejo API calls are always logged to Limnoria's plugin log file (supybot.plugins.Herodotus). When logChannel is configured, the same details are also sent to the named IRC channel — useful for live ops alerts.

The IRC log line format is:

[Herodotus] Forgejo API failure | nick=<nick> net=<network> chan=<channel> text='<user text>' | Q#<id> set=<set>

The Q#<id> and set= fields are present only when the failure is associated with a known question.

Repository layout

Herodotus/
├── __init__.py                 # Plugin entry; reload-safe imports
├── config.py                   # Limnoria registry definitions for all settings
├── plugin.py                   # Commands and Forgejo API client
├── test.py                     # Limnoria test bootstrap (supybot-test hook, not run by pytest)
├── pytest.ini                  # pytest config: collects tests/ only
├── requirements-dev.txt        # Dev dependencies (pytest, PyYAML)
├── tests/                      # pytest suite with supybot/mariadb stubs and Forgejo/DB fakes
├── question-sets-template/     # CI templates for the companion repo
│   ├── scripts/                # validate_reports.py, import_reports.py
│   └── .forgejo/workflows/     # validate-reports.yml, import-reports.yml
├── README.md
└── .planning/                  # Project planning artifacts (GSD workflow)

Development

plugin.py is single-file by design and uses only the standard library, so no virtualenv is required to read or modify it. The class is threaded = True because every command makes blocking HTTPS calls; do not remove that flag without restructuring around irc.replyAsync or similar.

Inquisitor imports are deliberately lazy (inside method bodies, not module top) so that @reload Herodotus and @reload Inquisitor interact correctly — Herodotus picks up Inquisitor's freshly reloaded modules on the next command rather than holding a stale reference.

To run Limnoria's test scaffolding against the plugin:

supybot-test Herodotus

Running the tests

python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt && .venv/bin/pytest

The suite needs no Limnoria, MariaDB or Forgejo: supybot, mariadb, the Inquisitor DB and Forgejo are stubbed or faked in-process. A test that reaches the real urlopen, mariadb.connect, a socket connect or a git subprocess fails by design; other channels are not guarded. test.py is Limnoria's own hook for supybot-test and is not part of the pytest suite.

Versioning

See __init__.py for the current __version__. Milestones and phase summaries are tracked under .planning/.