Files
rune/tests/test_drift_issue.py
Nick 82bd50f410 fix(pipeline): send release-drift label as integer IDs, not names (#192)
Quality-gate finding 6: POST /issues 'labels' requires integer IDs in Gitea;
the script sent the label NAME string, so the label never attached. Label
names are now resolved server-side via GET /labels (repo convention per
shared/custodian.py), created when missing, and skipped — issue still files —
when resolution and creation both fail. main() gains an injectable api
client so the flow is testable with a fake API.

Red evidence: 3 fake-client tests failed against the pre-fix code (network
URLError — no injectable seam; name-string payload). Green: 13/13.
2026-09-07 15:00:53 +01:00

178 lines
7.5 KiB
Python

"""Tests for scripts/file_drift_issue.py — maintenance drift reconciliation (#192)."""
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
from typing import Any
import pytest
ROOT = Path(__file__).resolve().parents[1]
def _load_module() -> Any:
spec = importlib.util.spec_from_file_location("file_drift_issue", ROOT / "scripts" / "file_drift_issue.py")
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
fdi = _load_module()
DECISION = {
"converged": False,
"warranted_bump": "minor",
"commits_since_tag": 3,
"sample": ["feat: widget", "fix: race", "docs: notes"],
}
class TestDriftTitle:
def test_plan_title_format(self) -> None:
assert fdi.drift_title("0.55.0", "v0.54.0") == "Release drift: master v0.55.0 vs tag v0.54.0"
def test_unknown_master_version(self) -> None:
assert fdi.drift_title("", "v0.54.0") == "Release drift: master (unknown) vs tag v0.54.0"
class TestDriftBody:
def test_contains_decision_facts_and_advisory(self) -> None:
body = fdi.drift_body(DECISION, "0.55.0", "v0.54.0")
assert "0.55.0" in body
assert "v0.54.0" in body
assert "minor" in body
assert "3" in body
for subject in DECISION["sample"]:
assert subject in body
assert "never auto-releases" in body
assert "docs/release-flow.md" in body
def test_version_behind_tag_anomaly_is_flagged(self) -> None:
# Spec-gate finding 3 (#192): the reconciler must surface the
# version_behind_tag anomaly in the filed issue, not just generic drift.
decision = {**DECISION, "warranted_bump": None, "anomaly": "version_behind_tag"}
body = fdi.drift_body(decision, "0.54.0", "v0.55.0")
assert "version_behind_tag" in body
assert "anomaly" in body.lower()
class TestFindOpenDriftIssue:
def test_matches_exact_title(self) -> None:
issues = [{"number": 7, "title": "Release drift: master v0.55.0 vs tag v0.54.0"}]
assert fdi.find_open_drift_issue(issues, "Release drift: master v0.55.0 vs tag v0.54.0") == issues[0]
def test_ignores_other_titles(self) -> None:
issues = [{"number": 7, "title": "Release drift: master v0.56.0 vs tag v0.54.0"}]
assert fdi.find_open_drift_issue(issues, "Release drift: master v0.55.0 vs tag v0.54.0") is None
def test_ignores_pull_requests(self) -> None:
issues = [{"number": 9, "title": "Release drift: master v0.55.0 vs tag v0.54.0", "pull_request": {}}]
assert fdi.find_open_drift_issue(issues, "Release drift: master v0.55.0 vs tag v0.54.0") is None
def test_empty_list(self) -> None:
assert fdi.find_open_drift_issue([], "Release drift: master v0.55.0 vs tag v0.54.0") is None
class TestDryRunMain:
def test_prints_title_and_body_without_api_calls(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setenv("CONVERGENCE_JSON", json.dumps(DECISION))
monkeypatch.setenv("MASTER_VERSION", "0.55.0")
monkeypatch.setenv("LATEST_TAG", "v0.54.0")
monkeypatch.delenv("GITEA_TOKEN", raising=False)
rc = fdi.main(["--dry-run"])
assert rc == 0
out = capsys.readouterr().out
assert "Release drift: master v0.55.0 vs tag v0.54.0" in out
assert "minor" in out
def test_missing_convergence_json_fails_loudly(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CONVERGENCE_JSON", raising=False)
with pytest.raises(KeyError):
fdi.main(["--dry-run"])
class TestLabelResolution:
"""Spec-gate finding 6 (#192): Gitea POST /issues 'labels' requires integer
IDs, not names. 'release-drift' must be resolved (and optionally created)
server-side; a missing label must not block filing the issue."""
DECISION_JSON = json.dumps(DECISION)
def _run(self, fake_api: Any, monkeypatch: pytest.MonkeyPatch, capsys: Any) -> int:
monkeypatch.setenv("CONVERGENCE_JSON", self.DECISION_JSON)
monkeypatch.setenv("MASTER_VERSION", "0.55.0")
monkeypatch.setenv("LATEST_TAG", "v0.54.0")
monkeypatch.setenv("GITEA_TOKEN", "t")
monkeypatch.setenv("RELEASE_SERVER_URL", "http://forge")
monkeypatch.setenv("RELEASE_REPO", "o/r")
return fdi.main([], api=fake_api)
def test_labels_sent_as_integer_ids(self, monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None:
calls: list[tuple[str, str, Any]] = []
def fake_api(path: str, method: str = "GET", data: Any = None) -> tuple[int, Any]:
calls.append((method, path, data))
if method == "GET" and path.startswith("/issues?"):
return 200, []
if method == "GET" and path.startswith("/labels"):
return 200, [{"id": 42, "name": "release-drift"}]
if method == "POST" and path == "/issues":
return 201, {"number": 5}
raise AssertionError(f"unexpected call: {method} {path}")
rc = self._run(fake_api, monkeypatch, capsys)
assert rc == 0
post = next(c for c in calls if c[0] == "POST" and c[1] == "/issues")
assert post[2]["labels"] == [42]
def test_missing_label_is_created(self, monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None:
calls: list[tuple[str, str, Any]] = []
def fake_api(path: str, method: str = "GET", data: Any = None) -> tuple[int, Any]:
calls.append((method, path, data))
if method == "GET" and path.startswith("/issues?"):
return 200, []
if method == "GET" and path.startswith("/labels"):
return 200, [{"id": 7, "name": "bug"}]
if method == "POST" and path == "/labels":
return 201, {"id": 99, "name": "release-drift"}
if method == "POST" and path == "/issues":
return 201, {"number": 5}
raise AssertionError(f"unexpected call: {method} {path}")
rc = self._run(fake_api, monkeypatch, capsys)
assert rc == 0
post = next(c for c in calls if c[0] == "POST" and c[1] == "/issues")
assert post[2]["labels"] == [99]
create = next(c for c in calls if c[0] == "POST" and c[1] == "/labels")
assert create[2] == {"name": "release-drift", "color": "ff0000"}
def test_unresolvable_label_skips_label_issue_still_files(
self, monkeypatch: pytest.MonkeyPatch, capsys: Any
) -> None:
calls: list[tuple[str, str, Any]] = []
def fake_api(path: str, method: str = "GET", data: Any = None) -> tuple[int, Any]:
calls.append((method, path, data))
if method == "GET" and path.startswith("/issues?"):
return 200, []
if method == "GET" and path.startswith("/labels"):
return 200, []
if method == "POST" and path == "/labels":
return 422, {"_body": "label quota exhausted"}
if method == "POST" and path == "/issues":
return 201, {"number": 5}
raise AssertionError(f"unexpected call: {method} {path}")
rc = self._run(fake_api, monkeypatch, capsys)
assert rc == 0 # the issue still files
post = next(c for c in calls if c[0] == "POST" and c[1] == "/issues")
assert post[2]["labels"] == [] # label skipped, not a name string
out = capsys.readouterr().out
assert "release-drift" in out # the skip is visible in the log