| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| """CLI tests — exercises the live path (fake gh) and replay path.""" |
| |
| from __future__ import annotations |
| |
| import json |
| import subprocess |
| from collections.abc import Iterator |
| from dataclasses import dataclass |
| |
| import pytest |
| |
| from preflight_audit import cli |
| |
| CANNED_RESPONSE = { |
| "data": { |
| "repository": { |
| "i100": { |
| "number": 100, |
| "state": "OPEN", |
| "closedAt": None, |
| "updatedAt": "2026-05-30T08:00:00Z", |
| "labels": {"nodes": [{"name": "cve allocated"}, {"name": "fix released"}]}, |
| "comments": { |
| "nodes": [ |
| { |
| "author": {"login": "potiuk"}, |
| "createdAt": "2026-05-30T08:00:00Z", |
| "body": "<!-- apache-magpie: status-rollup v1 -->\nentry\n", |
| } |
| ] |
| }, |
| }, |
| "i101": { |
| "number": 101, |
| "state": "OPEN", |
| "closedAt": None, |
| "updatedAt": "2026-05-31T11:00:00Z", |
| "labels": {"nodes": [{"name": "needs triage"}]}, |
| "comments": { |
| "nodes": [ |
| { |
| "author": {"login": "reporter"}, |
| "createdAt": "2026-05-31T11:00:00Z", |
| "body": "this is a real problem\n", |
| } |
| ] |
| }, |
| }, |
| "i102": { |
| "number": 102, |
| "state": "CLOSED", |
| "closedAt": "2026-03-15T10:00:00Z", |
| "updatedAt": "2026-03-15T10:00:00Z", |
| "labels": {"nodes": [{"name": "announced"}]}, |
| "comments": {"nodes": []}, |
| }, |
| } |
| } |
| } |
| |
| |
| @dataclass |
| class FakeResult: |
| returncode: int |
| stdout: str = "" |
| stderr: str = "" |
| |
| |
| class FakeGh: |
| def __init__(self, response: dict | None = None) -> None: |
| self.response = response or CANNED_RESPONSE |
| self.calls: list[list[str]] = [] |
| |
| def __call__(self, cmd, *, capture_output=False, text=False, check=False, **_): |
| self.calls.append(cmd) |
| return FakeResult(returncode=0, stdout=json.dumps(self.response)) |
| |
| |
| @pytest.fixture |
| def fake_gh(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeGh]: |
| f = FakeGh() |
| monkeypatch.setattr(subprocess, "run", f) |
| yield f |
| |
| |
| # --------------------------------------------------------------------------- |
| # Live mode |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_live_mode_invokes_gh_and_prints_table(fake_gh: FakeGh, capsys: pytest.CaptureFixture[str]) -> None: |
| rc = cli.main( |
| [ |
| "classify", |
| "--repo", |
| "owner/repo", |
| "--issues", |
| "100,101,102", |
| "--now", |
| "2026-05-31T12:00:00Z", |
| ] |
| ) |
| assert rc == 0 |
| out = capsys.readouterr().out |
| assert "Total trackers: 3" in out |
| assert "skip-noop:" in out |
| assert "Estimated savings" in out |
| # One gh call to graphql. |
| assert len(fake_gh.calls) == 1 |
| assert fake_gh.calls[0][:3] == ["gh", "api", "graphql"] |
| |
| |
| def test_live_mode_issues_with_hash_prefix(fake_gh: FakeGh, capsys: pytest.CaptureFixture[str]) -> None: |
| rc = cli.main( |
| ["classify", "--repo", "o/r", "--issues", "#100, #101 ,#102", "--now", "2026-05-31T12:00:00Z"] |
| ) |
| assert rc == 0 |
| |
| |
| def test_live_mode_rejects_bad_repo(capsys: pytest.CaptureFixture[str]) -> None: |
| rc = cli.main(["classify", "--repo", "noslash", "--issues", "1"]) |
| assert rc == 2 |
| assert "owner/name" in capsys.readouterr().err |
| |
| |
| def test_live_mode_requires_repo_when_no_load(capsys: pytest.CaptureFixture[str]) -> None: |
| rc = cli.main(["classify"]) |
| assert rc == 2 |
| assert "required" in capsys.readouterr().err |
| |
| |
| def test_live_mode_rejects_empty_issues_list(capsys: pytest.CaptureFixture[str]) -> None: |
| with pytest.raises(SystemExit) as exc: |
| cli.main(["classify", "--repo", "o/r", "--issues", ",,,"]) |
| assert exc.value.code == "error: --issues parsed to empty list" or exc.value.code != 0 |
| |
| |
| # --------------------------------------------------------------------------- |
| # Replay mode |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_replay_mode_loads_fixture(tmp_path, capsys: pytest.CaptureFixture[str]) -> None: |
| fixture = tmp_path / "resp.json" |
| fixture.write_text(json.dumps(CANNED_RESPONSE), encoding="utf-8") |
| rc = cli.main( |
| [ |
| "classify", |
| "--load", |
| str(fixture), |
| "--now", |
| "2026-05-31T12:00:00Z", |
| ] |
| ) |
| assert rc == 0 |
| out = capsys.readouterr().out |
| assert "Total trackers: 3" in out |
| # #100 should be SKIP_NOOP (cve+fix released+skill-last). |
| assert "# 100" in out or " 100" in out |
| |
| |
| def test_replay_mode_json_output(tmp_path, capsys: pytest.CaptureFixture[str]) -> None: |
| fixture = tmp_path / "resp.json" |
| fixture.write_text(json.dumps(CANNED_RESPONSE), encoding="utf-8") |
| rc = cli.main( |
| [ |
| "classify", |
| "--load", |
| str(fixture), |
| "--now", |
| "2026-05-31T12:00:00Z", |
| "--json", |
| ] |
| ) |
| assert rc == 0 |
| out = capsys.readouterr().out |
| parsed = json.loads(out) |
| assert len(parsed) == 3 |
| # Sorted by number. |
| assert [p["number"] for p in parsed] == [100, 101, 102] |
| by_n = {p["number"]: p for p in parsed} |
| assert by_n[100]["decision"] == "skip-noop" |
| assert by_n[102]["decision"] == "skip-noop" |
| # 101 has a non-skill comment 1 hour ago → dispatch-urgent OR dispatch |
| # depending on the 1.0-day cutoff. 11:00 → 12:00 is exactly 1.0h ago. |
| assert by_n[101]["decision"] in {"dispatch", "dispatch-urgent"} |
| |
| |
| def test_extra_bot_logins_flag(tmp_path, capsys: pytest.CaptureFixture[str]) -> None: |
| """The --bot-logins flag adds extra logins to the bot-equivalent set.""" |
| # Reshape #100 so its author is a personal-account bot. |
| response = json.loads(json.dumps(CANNED_RESPONSE)) |
| response["data"]["repository"]["i100"]["comments"]["nodes"][0]["author"]["login"] = "company-bot" |
| response["data"]["repository"]["i100"]["comments"]["nodes"][0]["body"] = "no marker\n" |
| fixture = tmp_path / "resp.json" |
| fixture.write_text(json.dumps(response), encoding="utf-8") |
| |
| # Without the flag: #100 dispatches (no skill marker, not a [bot] login). |
| cli.main(["classify", "--load", str(fixture), "--now", "2026-05-31T12:00:00Z", "--json"]) |
| no_flag_out = capsys.readouterr().out |
| no_flag = {p["number"]: p for p in json.loads(no_flag_out)} |
| assert no_flag[100]["decision"] == "dispatch" |
| |
| # With the flag: #100 is recognised, classifier can fire Rule 7. |
| cli.main( |
| [ |
| "classify", |
| "--load", |
| str(fixture), |
| "--now", |
| "2026-05-31T12:00:00Z", |
| "--json", |
| "--bot-logins", |
| "company-bot", |
| ] |
| ) |
| flag_out = capsys.readouterr().out |
| with_flag = {p["number"]: p for p in json.loads(flag_out)} |
| assert with_flag[100]["decision"] == "skip-noop" |