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.
178 lines
7.5 KiB
Python
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
|