diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3834b79d6..acde630ef 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -26,6 +26,18 @@ body: - label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing. required: true + - type: input + id: revision + attributes: + label: Odysseus Revision + description: | + From the repository root (on the host when using Docker), run + `git show -s --abbrev=12 --format='%h (%cs)' HEAD` + and paste the output exactly. + placeholder: "1fef4929cf1d (2026-08-11)" + validations: + required: true + - type: dropdown id: install-method attributes: diff --git a/.github/scripts/check-issue-description.js b/.github/scripts/check-issue-description.js index 63162b0d7..2c96de122 100644 --- a/.github/scripts/check-issue-description.js +++ b/.github/scripts/check-issue-description.js @@ -41,6 +41,14 @@ module.exports = async ({ github, context, core }) => { break; case 'bug': { + const revisionText = section('Odysseus Revision'); + if (!/^[0-9a-f]{12} \(\d{4}-\d{2}-\d{2}\)$/i.test(revisionText)) { + failures.push( + '**Odysseus Revision** — paste the 12-character commit SHA and date, ' + + 'for example `1fef4929cf1d (2026-08-11)`', + ); + } + if (!section('Install Method')) { failures.push('**Install Method** — select how you installed Odysseus'); } diff --git a/tests/test_issue_description_check.py b/tests/test_issue_description_check.py index 196f21cfc..ed52e429c 100644 --- a/tests/test_issue_description_check.py +++ b/tests/test_issue_description_check.py @@ -6,10 +6,12 @@ import subprocess from pathlib import Path import pytest +import yaml _REPO = Path(__file__).resolve().parent.parent _CHECKER = _REPO / ".github" / "scripts" / "check-issue-description.js" +_BUG_TEMPLATE = _REPO / ".github" / "ISSUE_TEMPLATE" / "bug_report.yml" _WORKFLOW = _REPO / ".github" / "workflows" / "issue-description-check.yml" pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH") @@ -66,11 +68,114 @@ checkIssueDescription({ github, context, core }) return json.loads(proc.stdout) +def _run_bug_issue(revision): + body = f"""# Odysseus Revision +{revision} + +# Install Method +Docker + +# Operating System +Linux + +# Steps to Reproduce +1. Start Odysseus. + +# Expected Behaviour +The application starts normally. + +# Actual Behaviour +The application exits unexpectedly. +""" + harness = r""" +const checkIssueDescription = require(process.argv[1]); +const body = process.argv[2]; +const calls = []; + +const github = { + rest: { + issues: { + removeLabel: async (params) => calls.push({ method: 'removeLabel', params }), + getLabel: async () => ({}), + addLabels: async (params) => calls.push({ method: 'addLabels', params }), + listComments: async () => ({ data: [] }), + createComment: async (params) => calls.push({ method: 'createComment', params }), + updateComment: async (params) => calls.push({ method: 'updateComment', params }), + deleteComment: async (params) => calls.push({ method: 'deleteComment', params }), + }, + }, +}; +const context = { + payload: { + action: 'opened', + issue: { number: 42, state: 'open', body, labels: [{ name: 'bug' }] }, + }, + repo: { owner: 'odysseus-dev', repo: 'odysseus' }, +}; +const core = { + warning: (message) => calls.push({ method: 'warning', message }), + setFailed: (message) => calls.push({ method: 'setFailed', message }), +}; + +checkIssueDescription({ github, context, core }) + .then(() => process.stdout.write(JSON.stringify(calls))) + .catch((error) => { + console.error(error); + process.exitCode = 1; + }); +""" + proc = subprocess.run( + ["node", "-e", harness, str(_CHECKER), body], + capture_output=True, + text=True, + cwd=str(_REPO), + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + def test_workflow_handles_issue_closures(): workflow = _WORKFLOW.read_text() assert "types: [opened, edited, reopened, closed]" in workflow +def test_bug_template_requires_exact_revision(): + template = yaml.safe_load(_BUG_TEMPLATE.read_text()) + revision = next(item for item in template["body"] if item.get("id") == "revision") + assert revision["type"] == "input" + assert revision["attributes"]["label"] == "Odysseus Revision" + assert "git show -s --abbrev=12 --format='%h (%cs)' HEAD" in revision["attributes"]["description"] + assert revision["attributes"]["placeholder"] == "1fef4929cf1d (2026-08-11)" + assert revision["validations"]["required"] is True + + +def test_bug_checker_accepts_exact_revision(): + calls = _run_bug_issue("1fef4929cf1d (2026-08-11)") + assert not any(call["method"] in {"createComment", "setFailed"} for call in calls) + assert any( + call["method"] == "addLabels" and call["params"]["labels"] == ["ready for review"] + for call in calls + ) + + +@pytest.mark.parametrize( + "revision", + [ + "", + "1fef492", + "1fef4929cf1d", + "1fef4929cf1d (11 August 2026)", + "1fef4929cf1d (2026-08-11) extra", + ], +) +def test_bug_checker_rejects_missing_or_malformed_revision(revision): + calls = _run_bug_issue(revision) + comment = next(call["params"]["body"] for call in calls if call["method"] == "createComment") + assert "**Odysseus Revision**" in comment + assert any(call["method"] == "setFailed" for call in calls) + + @pytest.mark.parametrize("action", ["closed", "edited"]) def test_closed_issue_only_drops_ready_for_review(action): assert _run_closed_issue(action) == [