diff --git a/.github/agents/dev-maintainer.md b/.github/agents/dev-maintainer.md index f1cc2878..073cc543 100644 --- a/.github/agents/dev-maintainer.md +++ b/.github/agents/dev-maintainer.md @@ -31,7 +31,7 @@ Watches the `responsibleai/ASSERT` repository for new pull requests and issues. ## Observation-mode write workflow -The agent runs on a recurring observation loop. For each open PR on every pass: +The agent runs on a recurring observation loop on an always-on host (not the maintainer's workstation — the wall-clock escalation windows below only fire if the loop stays up while the maintainer is away; see the "Where to run the loop" section in `AGENTS.md`). For each open PR on every pass: 1. **Run `audit-pr`** and log the result to `dev-inbox.md`. This always happens. 2. **Check reviewer state.** If the PR has no reviewer requested, or a reviewer has not responded within the escalation windows below, the agent issues exactly one of the two permitted writes: @@ -54,8 +54,10 @@ Read [`.github/CODEOWNERS`](../CODEOWNERS) for the path-to-owner mapping. Then: 1. **Exclude the PR author.** 2. **Exclude any owner whose GitHub user status is set to "busy" / "out of office"** at the time the agent runs. The agent queries the GraphQL `user.status` field for each candidate; owners keep this in sync themselves via their GitHub profile. -3. **Exclude the fallback admin** unless every other eligible owner has been excluded by the rules above. The fallback admin is the reviewer of last resort. -4. From the remaining candidates, prefer the owner who has been pinged least recently for this path. +3. **Exclude the fallback admin** unless every other eligible owner has been excluded by the rules above. The fallback admin is the reviewer of last resort. **Never request the PR author**: if a path's only owner is the author (e.g. the catch-all owner opened the PR), make no request and flag the PR for manual escalation instead. +4. Pick deterministically from the remaining candidates: the owner covering the most changed paths, then alphabetical order. The reference Action (`../workflows/review-escalation.yml`, via `../scripts/escalate_reviews.py`) is **stateless**, so it uses this deterministic order rather than tracking per-path ping history; a stateful host may substitute "least recently pinged for this path." For the 72h second-owner and 7d fallback steps, already-requested owners are excluded and the next is chosen by the same order. + +The escalation cascade is evaluated by severity (7d → 72h → 24h) so the 7-day fallback is always reachable for a requested-but-silent PR, and the fallback step is skipped (logged as a manual escalation) whenever it would otherwise target the author. ### What this agent never does (even in observation mode) diff --git a/.github/scripts/escalate_reviews.py b/.github/scripts/escalate_reviews.py new file mode 100644 index 00000000..2fa9a2af --- /dev/null +++ b/.github/scripts/escalate_reviews.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Deterministic review-request escalation for the maintainer-assist pattern. + +Runs on an always-on host (a scheduled GitHub Action — see +``.github/workflows/review-escalation.yml``) so the wall-clock escalation +windows fire even when the maintainer is away, which is exactly the situation +the escalation is for. This is the deterministic half of the dev-maintainer +agent: CODEOWNERS-based review routing. It does NOT run the LLM audit. + +For each open PR it applies the windows documented in ``AGENTS.md``: + + < 24h observe only + >= 24h, no reviewer requested request one CODEOWNER (narrow write #2) + >= 72h, requested but no response request a second CODEOWNER + >= 7d, still no response request the fallback admin (last resort) + +Routing rules (also from ``AGENTS.md``): + 1. Read effective CODEOWNERS for the PR's changed paths (last match wins). + 2. Exclude the PR author. + 3. Exclude owners whose GitHub user status is "busy" / OOO (best-effort). + 4. Exclude the fallback admin (catch-all owner) unless no one else is left. + 5. Prefer the owner covering the most changed paths; tie-break alphabetically. + +The script shells out to the `gh` CLI for all GitHub access, so it works the +same locally (maintainer's `gh` auth) and in CI (`GH_TOKEN` / `GITHUB_TOKEN`). +Use ``--dry-run`` to print decisions without requesting any reviewers. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +# The catch-all owner in CODEOWNERS is the fallback admin / reviewer of last +# resort; only request them when no other eligible owner remains. +FALLBACK_LOGIN = "changliu2" + +WINDOW_24H = 24 +WINDOW_72H = 72 +WINDOW_7D = 24 * 7 + + +def gh(*args: str, check: bool = True) -> str: + """Run a gh command and return stdout.""" + result = subprocess.run( + ["gh", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + if check and result.returncode != 0: + raise RuntimeError(f"gh {' '.join(args)} failed: {result.stderr.strip()}") + return result.stdout + + +def gh_json(*args: str): + return json.loads(gh(*args) or "null") + + +# ── CODEOWNERS ──────────────────────────────────────────────── + + +@dataclass +class CodeownersRule: + pattern: str + owners: list[str] + + +def parse_codeowners(path: Path) -> list[CodeownersRule]: + rules: list[CodeownersRule] = [] + if not path.exists(): + return rules + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + pattern, owners = parts[0], [o.lstrip("@") for o in parts[1:] if o.startswith("@")] + if owners: + rules.append(CodeownersRule(pattern=pattern, owners=owners)) + return rules + + +def _pattern_matches(pattern: str, file_path: str) -> bool: + """Approximate GitHub CODEOWNERS matching for the patterns this repo uses. + + Supports: `*` (everything), `*.ext` (basename suffix at any depth), and + `dir/` (anything under a directory prefix), plus literal path prefixes. + """ + if pattern == "*": + return True + if pattern.startswith("*."): # e.g. *.md — match by extension at any depth + return file_path.rsplit("/", 1)[-1].endswith(pattern[1:]) + normalized = pattern.lstrip("/") + if normalized.endswith("/"): # directory prefix + return file_path.startswith(normalized) + # Literal file or path prefix. + return file_path == normalized or file_path.startswith(normalized + "/") + + +def effective_owners(file_path: str, rules: list[CodeownersRule]) -> list[str]: + """Return the owners of the LAST matching rule (GitHub semantics).""" + owners: list[str] = [] + for rule in rules: + if _pattern_matches(rule.pattern, file_path): + owners = rule.owners + return owners + + +# ── PR evaluation ───────────────────────────────────────────── + + +@dataclass +class Decision: + pr: int + title: str + author: str + age_hours: float + action: str # "observe" | "request" | "request-second" | "fallback" | "noop" + candidates: list[str] = field(default_factory=list) + chosen: list[str] = field(default_factory=list) + routing_preview: list[str] = field(default_factory=list) + reason: str = "" + + +def _hours_since(iso_ts: str) -> float: + ts = datetime.fromisoformat(iso_ts.replace("Z", "+00:00")) + return (datetime.now(timezone.utc) - ts).total_seconds() / 3600.0 + + +def _is_ooo(login: str) -> bool: + """Best-effort: is the user's GitHub status busy / OOO? Never blocks on error.""" + try: + data = gh_json( + "api", "graphql", "-f", + f'query={{user(login:"{login}"){{status{{indicatesLimitedAvailability message}}}}}}', + ) + status = (((data or {}).get("data") or {}).get("user") or {}).get("status") + return bool(status and status.get("indicatesLimitedAvailability")) + except Exception: + return False + + +def _rank_owners(candidates_by_file: dict[str, list[str]]) -> list[str]: + """Order owners by how many changed files they cover; tie-break alphabetically.""" + coverage: dict[str, int] = {} + for owners in candidates_by_file.values(): + for o in owners: + coverage[o] = coverage.get(o, 0) + 1 + return sorted(coverage, key=lambda o: (-coverage[o], o)) + + +def _safe_fallback(author: str, requested: set[str], reviewed_by: set[str]) -> str | None: + """The fallback admin, or None when requesting them would be wrong. + + Honors routing rule #1 (never request the PR author): if the fallback admin + *is* the author, or has already been requested or has already reviewed, + return None so the caller escalates for manual handling instead of pinging + the author / re-pinging the same person. + """ + if FALLBACK_LOGIN == author: + return None + if FALLBACK_LOGIN in requested or FALLBACK_LOGIN in reviewed_by: + return None + return FALLBACK_LOGIN + + +def evaluate_pr( + repo: str, + pr: dict, + rules: list[CodeownersRule], + min_age_hours: float, +) -> Decision: + number = pr["number"] + author = (pr.get("author") or {}).get("login", "") + age = _hours_since(pr["createdAt"]) + + files = [f["path"] for f in pr.get("files", [])] + per_file = {f: effective_owners(f, rules) for f in files} + + requested = {r.get("login") for r in pr.get("reviewRequests", []) if r.get("login")} + reviewed_by = { + rv.get("author", {}).get("login") + for rv in pr.get("reviews", []) + if rv.get("author") and rv.get("state") in {"APPROVED", "CHANGES_REQUESTED", "COMMENTED"} + } + reviewed_by.discard(author) + + ranked = _rank_owners(per_file) + # Pure routing: who owns this PR (ranked), author excluded. Shown for + # transparency even when everyone is already requested. + routing_preview = [o for o in ranked if o != author] + # Eligible = ranked owners minus author, minus already-requested, minus OOO. + eligible = [ + o for o in ranked + if o != author and o not in requested and not _is_ooo(o) + ] + non_fallback = [o for o in eligible if o != FALLBACK_LOGIN] + # Prefer specific owners; fall back to the catch-all only if nobody else. + pool = non_fallback or eligible + + d = Decision(pr=number, title=pr.get("title", ""), author=author, + age_hours=round(age, 1), action="noop", candidates=pool, + routing_preview=routing_preview) + + if not files: + d.reason = "no changed files" + return d + if age < min_age_hours: + d.action = "observe" + d.reason = f"age {age:.1f}h < {min_age_hours}h threshold" + return d + + has_reviewed = bool(reviewed_by) + if has_reviewed: + d.action = "observe" + d.reason = "a reviewer has already responded" + return d + + # Cascade by severity. `pool` already excludes the author, OOO owners, and + # anyone already requested, so no branch can ever target the author. + if not requested: + # No reviewer ever requested → assign the first eligible owner (the + # documented 24h action), at any age past the first window. + if pool: + d.action, d.chosen = "request", [pool[0]] + d.reason = "no reviewer requested; assigning the first eligible owner" + else: + fb = _safe_fallback(author, requested, reviewed_by) + if fb: + d.action, d.chosen = "fallback", [fb] + d.reason = "no eligible owner; fallback admin (last resort)" + else: + d.action = "manual" + d.reason = "no eligible owner and fallback would be the author — manual escalation needed" + elif age >= WINDOW_7D: + # Requested but silent for 7d+ → fallback admin, the reviewer of last resort. + fb = _safe_fallback(author, requested, reviewed_by) + if fb: + d.action, d.chosen = "fallback", [fb] + d.reason = "7d+ no response; fallback admin (last resort)" + elif pool: + # Fallback is the author or already pinged → widen to another owner. + d.action, d.chosen = "request-second", [pool[0]] + d.reason = "7d+ no response; fallback unavailable, widening to another owner" + else: + d.action = "manual" + d.reason = "7d+ no response; fallback is the author and no other owner — manual escalation needed" + elif age >= WINDOW_72H: + # Requested but silent for 72h+ → add a second *non-fallback* owner from + # the same path. The fallback admin is reserved for the 7d last resort, + # so we do not pull them in early here. + if non_fallback: + d.action, d.chosen = "request-second", [non_fallback[0]] + d.reason = "72h+ requested reviewer non-responsive; adding a second owner" + else: + d.action = "observe" + d.reason = "72h+ but no additional non-fallback owner; awaiting 7d fallback" + else: + d.action = "observe" + d.reason = "reviewer requested; within the response window" + return d + + +def request_reviewers(repo: str, pr: int, logins: list[str]) -> None: + if not logins: + return + args = ["pr", "edit", str(pr), "--repo", repo] + for login in logins: + args += ["--add-reviewer", login] + gh(*args) + + +# ── main ────────────────────────────────────────────────────── + + +def main() -> int: + ap = argparse.ArgumentParser(description="Deterministic CODEOWNERS review escalation.") + ap.add_argument("--repo", default="responsibleai/ASSERT") + ap.add_argument("--pr", type=int, default=None, help="Evaluate one PR (else all open).") + ap.add_argument("--min-age-hours", type=float, default=float(WINDOW_24H), + help="Override the first escalation window (use 0 to test on a fresh PR).") + ap.add_argument("--codeowners", default=".github/CODEOWNERS") + ap.add_argument("--dry-run", action="store_true", help="Print decisions; request no reviewers.") + args = ap.parse_args() + + rules = parse_codeowners(Path(args.codeowners)) + if not rules: + print(f"::warning::no CODEOWNERS rules parsed from {args.codeowners}", file=sys.stderr) + + fields = "number,title,author,createdAt,files,reviewRequests,reviews" + if args.pr is not None: + prs = [gh_json("pr", "view", str(args.pr), "--repo", args.repo, "--json", fields)] + else: + prs = gh_json("pr", "list", "--repo", args.repo, "--state", "open", + "--limit", "100", "--json", fields) or [] + + exit_code = 0 + for pr in prs: + d = evaluate_pr(args.repo, pr, rules, args.min_age_hours) + tag = "DRY-RUN" if args.dry_run else "LIVE" + print(f"[{tag}] PR #{d.pr} ({d.age_hours}h) by @{d.author}: {d.action} " + f"-> {('@' + ', @'.join(d.chosen)) if d.chosen else '(none)'} | {d.reason}") + if d.routing_preview: + print(f" routing (ranked owners): {', '.join('@' + c for c in d.routing_preview)}") + if d.candidates and d.candidates != d.routing_preview: + print(f" eligible now (not yet requested): {', '.join('@' + c for c in d.candidates)}") + if d.action == "manual": + print(f"::warning::PR #{d.pr} needs manual escalation: {d.reason}") + if d.action in {"request", "request-second", "fallback"} and d.chosen and not args.dry_run: + try: + request_reviewers(args.repo, d.pr, d.chosen) + print(f" requested review from {', '.join('@' + c for c in d.chosen)}") + except Exception as exc: # noqa: BLE001 + print(f"::error::failed to request reviewers on #{d.pr}: {exc}", file=sys.stderr) + exit_code = 1 + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/review-escalation.yml b/.github/workflows/review-escalation.yml new file mode 100644 index 00000000..fa85c58a --- /dev/null +++ b/.github/workflows/review-escalation.yml @@ -0,0 +1,62 @@ +name: Review escalation + +# Deterministic CODEOWNERS-based review-request escalation for the +# maintainer-assist pattern. Runs on GitHub's always-on schedule so the 24h/72h +# escalation windows fire even when the maintainer is away — which is exactly +# the situation the escalation is designed for (see AGENTS.md, "Where to run +# the loop"). This is the deterministic half of the dev-maintainer agent +# (review routing); it does not run the LLM audit. + +on: + schedule: + # Every 6 hours. The escalation windows are 24h/72h/7d, so this cadence is + # ample and keeps API/notification volume low. + - cron: "0 */6 * * *" + workflow_dispatch: + inputs: + dry_run: + description: "Print decisions without requesting reviewers" + type: boolean + default: true + min_age_hours: + description: "Override the first escalation window (use 0 to evaluate fresh PRs)" + type: string + default: "24" + +permissions: + contents: read # read .github/CODEOWNERS + pull-requests: write # request reviewers + +concurrency: + group: review-escalation + cancel-in-progress: false + +jobs: + escalate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Escalate stale review requests + env: + GH_TOKEN: ${{ github.token }} + run: | + # Scheduled runs are live; manual runs honor the dry_run input + # (default true) so the workflow is safe to trigger by hand. + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + MIN_AGE="${{ github.event.inputs.min_age_hours }}" + DRY="${{ github.event.inputs.dry_run }}" + else + MIN_AGE="24" + DRY="false" + fi + ARGS="--repo ${{ github.repository }} --min-age-hours ${MIN_AGE}" + if [ "${DRY}" = "true" ]; then + ARGS="${ARGS} --dry-run" + fi + echo "Running: escalate_reviews.py ${ARGS}" + python .github/scripts/escalate_reviews.py ${ARGS} diff --git a/AGENTS.md b/AGENTS.md index 43702d75..bf3e4aa8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,7 +171,7 @@ When I ask for help, prefer concrete file paths, runnable commands, and the YAML # Maintainer assist pattern (Copilot CLI + agents) -This section defines a reusable OSS maintainer-assist pattern: a small set of Copilot CLI agents the repository maintainer runs on their local workstation, plus `.github/CODEOWNERS` routing, to keep the repo healthy when the maintainer can't review every PR within hours. Technical PRs still get an audit pass; stale review requests get re-routed to an available code owner. +This section defines a reusable OSS maintainer-assist pattern: a small set of Copilot CLI agents the repository maintainer runs on a scheduled loop on an always-on host (see [Where to run the loop](#where-to-run-the-loop)), plus `.github/CODEOWNERS` routing, to keep the repo healthy when the maintainer can't review every PR within hours. Technical PRs still get an audit pass; stale review requests get re-routed to an available code owner. It is **not** part of the ASSERT product. Contributors do not need to interact with it. Other OSS maintainers are welcome to fork the pattern. @@ -207,6 +207,20 @@ The dev-maintainer agent enforces this rule on every observation pass: | ≥ 72h, reviewer requested but no response | Request review from a *second* CODEOWNER on the same path (uses narrow write #2 again — GitHub's review-request mechanism notifies the new reviewer directly). | | ≥ 7 days, still no response | Escalate to the fallback admin (repository maintainer) as last resort. | +### Where to run the loop + +The escalation windows above are wall-clock thresholds, so the loop only helps if it runs somewhere that stays up while the maintainer is away — which is the exact situation the escalation is designed for. Running it on the maintainer's own workstation defeats the purpose: if the maintainer is offline (vacation, travel, off-grid), so is their laptop, and the 24h / 72h passes never fire. + +Run the loop on an **always-on host** instead: + +- a small always-on VM (the maintainer's own infrastructure), or +- a scheduled CI job or cron, or +- a scheduled GitHub Action (`on: schedule:`), which needs no separate host at all. + +`.github/CODEOWNERS` (GitHub-native review routing) already covers the baseline case on its own and keeps working regardless of where — or whether — this loop runs. Treat the agent loop as an enhancement layered on top of CODEOWNERS, not a replacement for it. + +This repo ships that enhancement as a reference implementation: the scheduled workflow `.github/workflows/review-escalation.yml` runs `.github/scripts/escalate_reviews.py`, which applies the windows and routing above deterministically (no LLM) on GitHub's own always-on schedule. The LLM `audit-pr` pass remains a separate concern a maintainer can run from any host. + ### Reviewer routing logic When picking a reviewer to request or ping: @@ -214,8 +228,8 @@ When picking a reviewer to request or ping: 1. Read the effective CODEOWNERS list for the PR's changed paths. 2. **Exclude the PR author.** 3. **Exclude any owner whose GitHub status is set to "busy" / "out of office"** at the time the agent runs (the agent reads the GraphQL `user.status` field for each candidate; owners keep this in sync themselves). -4. **Exclude the fallback admin** unless every other co-owner has been excluded by the rules above. The fallback admin is the reviewer of last resort. -5. Pick from the remaining candidates. Prefer admins. If the path has multiple eligible owners, pick the one not recently pinged. +4. **Exclude the fallback admin** unless every other co-owner has been excluded by the rules above. The fallback admin is the reviewer of last resort. **Never request the PR author** — if the only owner of a path is the author (e.g. the catch-all owner authored the PR), the agent makes no request and logs the PR for manual escalation rather than pinging the author. +5. Pick deterministically from the remaining candidates: the owner covering the most changed paths, then alphabetical order. The reference Action (`.github/workflows/review-escalation.yml`) is stateless, so it uses this deterministic order in place of "least recently pinged"; a stateful host may substitute ping-history. For the 72h second-owner and 7d fallback steps, owners already requested are excluded and the next is chosen by the same order. ### Designer agent stays observation-only diff --git a/tests/test_escalate_reviews.py b/tests/test_escalate_reviews.py new file mode 100644 index 00000000..1ca4950b --- /dev/null +++ b/tests/test_escalate_reviews.py @@ -0,0 +1,121 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Unit tests for the deterministic review-escalation routing. + +Covers the governance contract from AGENTS.md / dev-maintainer.md and the three +semantics fixes from the #232 review: the 7d fallback must be reachable for a +requested-but-silent PR, the fallback step must never request the PR author, and +the routing must match the documented exclusion order. +""" + +from __future__ import annotations + +import importlib.util +import sys +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +# The escalation script lives under .github/scripts (not an importable package), +# so load it by file path. +_SCRIPT = Path(__file__).resolve().parents[1] / ".github" / "scripts" / "escalate_reviews.py" +_spec = importlib.util.spec_from_file_location("escalate_reviews", _SCRIPT) +esc = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = esc # needed so @dataclass can resolve the module +_spec.loader.exec_module(esc) + + +def _iso(hours_ago: float) -> str: + return (datetime.now(timezone.utc) - timedelta(hours=hours_ago)).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _rules(mapping: list[tuple[str, list[str]]]) -> list: + return [esc.CodeownersRule(pattern=p, owners=o) for p, o in mapping] + + +def _pr(number, author, paths, age_hours, requested=None, reviewed=None): + return { + "number": number, + "title": f"PR {number}", + "author": {"login": author}, + "createdAt": _iso(age_hours), + "files": [{"path": p} for p in paths], + "reviewRequests": [{"login": r} for r in (requested or [])], + "reviews": [{"author": {"login": a}, "state": "COMMENTED"} for a in (reviewed or [])], + } + + +class EscalationRoutingTest(unittest.TestCase): + def setUp(self): + # Deterministic: no one is OOO. + self._orig_ooo = esc._is_ooo + esc._is_ooo = lambda login: False + # Use a non-Chang fallback so the author-guard cases are explicit. + self._orig_fb = esc.FALLBACK_LOGIN + esc.FALLBACK_LOGIN = "admin" + self.rules = _rules([ + ("*", ["admin"]), # catch-all / fallback + ("src/", ["alice", "bob", "admin"]), # specific owners + ]) + + def tearDown(self): + esc._is_ooo = self._orig_ooo + esc.FALLBACK_LOGIN = self._orig_fb + + def ev(self, pr, min_age=24.0): + return esc.evaluate_pr("o/r", pr, self.rules, min_age) + + # ── bug 2: 7d fallback must be reachable for a requested-but-silent PR ── + def test_7d_fallback_reachable_when_requested_and_silent(self): + pr = _pr(1, "alice", ["src/x.py"], age_hours=200, requested=["bob"]) + d = self.ev(pr) + self.assertEqual(d.action, "fallback", d.reason) + self.assertEqual(d.chosen, ["admin"]) + + # ── bug 3: fallback must never request the PR author ── + def test_fallback_never_requests_author(self): + # Admin (the fallback) authored a PR whose only owner is the catch-all. + pr = _pr(2, "admin", ["README.md"], age_hours=300, requested=[]) + d = self.ev(pr) + self.assertEqual(d.action, "manual", d.reason) + self.assertEqual(d.chosen, []) + self.assertNotIn("admin", d.chosen) + + # ── 72h adds a second non-fallback owner, not the reserved fallback ── + def test_72h_adds_second_nonfallback_owner(self): + pr = _pr(3, "alice", ["src/x.py"], age_hours=80, requested=["bob"]) + d = self.ev(pr) + # bob requested; alice is author; remaining non-fallback owner = none + # (only admin left) → reserve admin for 7d → observe. + self.assertEqual(d.action, "observe", d.reason) + + def test_72h_picks_real_second_owner_when_available(self): + rules = _rules([("*", ["admin"]), ("src/", ["alice", "bob", "carol", "admin"])]) + pr = _pr(4, "alice", ["src/x.py"], age_hours=80, requested=["bob"]) + d = esc.evaluate_pr("o/r", pr, rules, 24.0) + self.assertEqual(d.action, "request-second", d.reason) + self.assertEqual(d.chosen, ["carol"]) # non-fallback, not the author/requested + + # ── 24h first request assigns an owner, excludes author ── + def test_first_request_excludes_author(self): + pr = _pr(5, "alice", ["src/x.py"], age_hours=30, requested=[]) + d = self.ev(pr) + self.assertEqual(d.action, "request") + self.assertNotIn("alice", d.chosen) + self.assertEqual(d.chosen, ["bob"]) # coverage tie → alphabetical, author excluded + + # ── already-reviewed → observe ── + def test_observe_when_reviewer_responded(self): + pr = _pr(6, "alice", ["src/x.py"], age_hours=300, requested=["bob"], reviewed=["bob"]) + d = self.ev(pr) + self.assertEqual(d.action, "observe") + + # ── below the first window → observe ── + def test_below_window_observes(self): + pr = _pr(7, "alice", ["src/x.py"], age_hours=5, requested=[]) + d = self.ev(pr) + self.assertEqual(d.action, "observe") + + +if __name__ == "__main__": + unittest.main()