blob: 83676ad87488acf7a9073f2421da87e20bcec1d4 [file]
# 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"