"""Tests for `.gitea/scripts/lint_mask_pr_atomicity.py` — Tier 2d lint. Structural enforcement of internal#350 Tier 2d: a PR that touches `.gitea/workflows/ci.yml` and modifies `continue-on-error` OR the `all-required` sentinel's `needs:` block must EITHER: - Touch both atomically in the same PR (preferred), OR - Cross-link to the paired PR via `Paired: #NNN` in body OR a commit message. The class this lint exists to prevent: PR#665 (interim continue-on-error: true on platform-build) + PR#668 (sentinel-exempt) were designed-as-a-pair but merged solo — #665 landed at 04:47Z, #668 still open at 05:07Z when the watchdog fired. ~20 min of main red. Test classes (per `feedback_branch_count_before_approving`, every prod branch enumerated): - test_diff_touches_neither_passes — diff is in ci.yml but neither continue-on-error nor all-required.needs is touched. PR is exempt. Exit 0. - test_diff_touches_both_atomically_passes — both touched in the same PR. Atomic. Exit 0. - test_diff_touches_coe_only_no_pair_fails — continue-on-error flipped without sentinel-needs change AND no `Paired: #NNN` reference anywhere. Exit 1. - test_diff_touches_needs_only_no_pair_fails — sentinel `needs:` changed without `continue-on-error` change AND no pair reference. Exit 1. - test_diff_touches_coe_only_pair_in_body — coe changed, no needs change, body has `Paired: #668`. Exit 0. - test_diff_touches_needs_only_pair_in_commit — needs changed, no coe change, commit message includes `Paired: #665`. Exit 0. - test_paired_reference_must_be_numeric — `Paired: #abc` or `Paired: NNNN` (missing `#`) doesn't satisfy the rule. Exit 1. - test_ci_yml_unchanged_skips — no ci.yml in the diff at all (defensive — workflow paths-filter already prevents, but the lint should not crash). Exit 0. The lint receives base SHA + head SHA via env (set by the workflow from the pull_request payload) and uses `git show` to read both sides without a separate clone. Tests stub `subprocess.run` to drive the diff content; the actual git is never invoked. Run: python3 -m pytest tests/test_lint_mask_pr_atomicity.py -v Dependencies: stdlib + PyYAML (the script reads ci.yml via PyYAML AST per `feedback_behavior_based_ast_gates`). No network. No live git. """ from __future__ import annotations import importlib.util import os import subprocess import sys import textwrap from pathlib import Path from unittest import mock import pytest SCRIPT_PATH = ( Path(__file__).resolve().parent.parent / ".gitea" / "scripts" / "lint_mask_pr_atomicity.py" ) # Minimal ci.yml fixture — only the bits the lint actually parses # (a job with continue-on-error + the all-required aggregator). CI_YML_BASE = """ name: CI on: push: branches: [main] pull_request: branches: [main] jobs: platform-build: runs-on: ubuntu-latest continue-on-error: false steps: - run: echo build canvas-build: runs-on: ubuntu-latest continue-on-error: false steps: - run: echo build all-required: runs-on: ubuntu-latest needs: - platform-build - canvas-build if: always() steps: - run: echo agg """ # Same as base but with continue-on-error flipped on platform-build. CI_YML_COE_FLIPPED = CI_YML_BASE.replace( " platform-build:\n runs-on: ubuntu-latest\n continue-on-error: false", " platform-build:\n runs-on: ubuntu-latest\n continue-on-error: true", ) # Same as base but with canvas-build dropped from all-required.needs. CI_YML_NEEDS_CHANGED = CI_YML_BASE.replace( " needs:\n - platform-build\n - canvas-build", " needs:\n - platform-build", ) # Both changed at once. CI_YML_BOTH = CI_YML_COE_FLIPPED.replace( " needs:\n - platform-build\n - canvas-build", " needs:\n - platform-build", ) def _import_lint(monkeypatch): """Import the lint module under a fresh name per test.""" spec = importlib.util.spec_from_file_location( f"lint_mask_pr_atomicity_{os.getpid()}_{id(monkeypatch)}", SCRIPT_PATH, ) m = importlib.util.module_from_spec(spec) spec.loader.exec_module(m) return m def _stub_git(base_yml: str | None, head_yml: str | None, commits: list[str]): """Build a fake `subprocess.run` that emulates git show + log. base_yml / head_yml: contents the lint sees at base/head SHA. Pass `None` to simulate "path didn't exist on that side" (git show returns exit code 128 — file-not-in-tree). commits: list of commit messages on the PR (head's ancestry up to the base merge-base). The lint runs `git log --format=%B base..head` to find Paired: refs. """ def fake_run(cmd, *args, **kwargs): if not isinstance(cmd, list): raise AssertionError(f"unexpected non-list cmd: {cmd!r}") # `git show :` if cmd[:2] == ["git", "show"] and len(cmd) >= 3 and ":" in cmd[2]: sha, path = cmd[2].split(":", 1) if "base" in sha or "BASE" in sha: content = base_yml else: content = head_yml if content is None: return subprocess.CompletedProcess( cmd, returncode=128, stdout="", stderr="fatal: path not in tree" ) return subprocess.CompletedProcess( cmd, returncode=0, stdout=content, stderr="" ) # `git log --format=%B base..head -- .` if cmd[:2] == ["git", "log"]: body = "\n\n--commit-boundary--\n\n".join(commits) return subprocess.CompletedProcess( cmd, returncode=0, stdout=body, stderr="" ) # `git diff --name-only base..head` if cmd[:2] == ["git", "diff"]: # If either side had ci.yml, it's in the diff; else not. paths = [] if (base_yml or "") != (head_yml or ""): paths.append(".gitea/workflows/ci.yml") return subprocess.CompletedProcess( cmd, returncode=0, stdout="\n".join(paths) + "\n", stderr="" ) raise AssertionError(f"unexpected git invocation: {cmd!r}") return fake_run @pytest.fixture() def env(monkeypatch): monkeypatch.setenv("BASE_SHA", "base-sha-1") monkeypatch.setenv("HEAD_SHA", "head-sha-1") monkeypatch.setenv("PR_BODY", "") monkeypatch.setenv("CI_WORKFLOW_PATH", ".gitea/workflows/ci.yml") monkeypatch.setenv("SENTINEL_JOB_KEY", "all-required") return monkeypatch # --------------------------------------------------------------------------- # Diff in ci.yml but neither rule predicate triggered → pass # --------------------------------------------------------------------------- def test_diff_touches_neither_passes(env, monkeypatch, capsys): # Add a comment-only change (no coe flip, no needs change). base = CI_YML_BASE head = "# a harmless comment\n" + CI_YML_BASE monkeypatch.setattr( subprocess, "run", _stub_git(base, head, ["chore: comment"]) ) m = _import_lint(monkeypatch) rc = m.run() assert rc == 0 out = capsys.readouterr().out assert "no atomicity risk" in out.lower() or "ok" in out.lower() # --------------------------------------------------------------------------- # Diff touches BOTH coe and sentinel.needs in the same PR → atomic, pass # --------------------------------------------------------------------------- def test_diff_touches_both_atomically_passes(env, monkeypatch, capsys): monkeypatch.setattr( subprocess, "run", _stub_git(CI_YML_BASE, CI_YML_BOTH, ["fix(ci): atomic flip"]), ) m = _import_lint(monkeypatch) rc = m.run() assert rc == 0 out = capsys.readouterr().out assert "atomic" in out.lower() # --------------------------------------------------------------------------- # Diff touches ONLY continue-on-error, no pair reference → fail # --------------------------------------------------------------------------- def test_diff_touches_coe_only_no_pair_fails(env, monkeypatch, capsys): monkeypatch.setattr( subprocess, "run", _stub_git( CI_YML_BASE, CI_YML_COE_FLIPPED, ["fix(ci): flip coe on platform-build"], ), ) m = _import_lint(monkeypatch) rc = m.run() assert rc == 1 out = capsys.readouterr().out assert "paired" in out.lower() or "atomicity" in out.lower() # Actionable failure: must name what is missing. assert "continue-on-error" in out.lower() # --------------------------------------------------------------------------- # Diff touches ONLY sentinel.needs, no pair reference → fail # --------------------------------------------------------------------------- def test_diff_touches_needs_only_no_pair_fails(env, monkeypatch, capsys): monkeypatch.setattr( subprocess, "run", _stub_git( CI_YML_BASE, CI_YML_NEEDS_CHANGED, ["fix(ci): drop canvas-build from sentinel"], ), ) m = _import_lint(monkeypatch) rc = m.run() assert rc == 1 out = capsys.readouterr().out assert "paired" in out.lower() or "atomicity" in out.lower() assert "needs" in out.lower() or "sentinel" in out.lower() # --------------------------------------------------------------------------- # COE-only flip with `Paired: #668` in PR body → pass # --------------------------------------------------------------------------- def test_diff_touches_coe_only_pair_in_body(env, monkeypatch, capsys): monkeypatch.setenv("PR_BODY", "Interim coe flip. Paired: #668") monkeypatch.setattr( subprocess, "run", _stub_git( CI_YML_BASE, CI_YML_COE_FLIPPED, ["fix(ci): flip coe on platform-build"], ), ) m = _import_lint(monkeypatch) rc = m.run() assert rc == 0 out = capsys.readouterr().out assert "paired" in out.lower() assert "668" in out # --------------------------------------------------------------------------- # Needs-only flip with `Paired: #665` in a commit message → pass # --------------------------------------------------------------------------- def test_diff_touches_needs_only_pair_in_commit(env, monkeypatch, capsys): monkeypatch.setattr( subprocess, "run", _stub_git( CI_YML_BASE, CI_YML_NEEDS_CHANGED, [ "fix(ci): drop canvas-build from sentinel\n\nPaired: #665", ], ), ) m = _import_lint(monkeypatch) rc = m.run() assert rc == 0 out = capsys.readouterr().out assert "paired" in out.lower() assert "665" in out # --------------------------------------------------------------------------- # `Paired: #abc` is not a valid issue/PR ref — fail # --------------------------------------------------------------------------- def test_paired_reference_must_be_numeric(env, monkeypatch, capsys): monkeypatch.setenv("PR_BODY", "Paired: #abc") monkeypatch.setattr( subprocess, "run", _stub_git( CI_YML_BASE, CI_YML_COE_FLIPPED, ["fix(ci): flip coe"], ), ) m = _import_lint(monkeypatch) rc = m.run() assert rc == 1 # --------------------------------------------------------------------------- # Defensive: ci.yml not in diff at all → skip cleanly # --------------------------------------------------------------------------- def test_ci_yml_unchanged_skips(env, monkeypatch, capsys): monkeypatch.setattr( subprocess, "run", _stub_git(CI_YML_BASE, CI_YML_BASE, ["chore: noop"]) ) m = _import_lint(monkeypatch) rc = m.run() assert rc == 0 out = capsys.readouterr().out assert "ci.yml" in out.lower() or "not in" in out.lower() or "skip" in out.lower() # --------------------------------------------------------------------------- # Cross-cutting: file ADDED on head side (no base) — coe inferred as # "newly added with coe=true". Should NOT trigger the lint (it's a new # file, not a flip — Tier 2e covers tracking-issue for new coe=true). # --------------------------------------------------------------------------- def test_ci_yml_newly_added_passes(env, monkeypatch, capsys): monkeypatch.setattr( subprocess, "run", _stub_git(None, CI_YML_COE_FLIPPED, ["feat(ci): add ci.yml"]), ) m = _import_lint(monkeypatch) rc = m.run() assert rc == 0