diff --git a/qb_site/analyzer/AGENTS.md b/qb_site/analyzer/AGENTS.md index e1267ce8..59f25470 100644 --- a/qb_site/analyzer/AGENTS.md +++ b/qb_site/analyzer/AGENTS.md @@ -10,6 +10,10 @@ - Key read-only services: - `queueboard_snapshot.py` — builds and caches the full per-repo queue snapshot payload. - `reviewer_attention.py` / `reviewer_attention_format.py` — per-reviewer queue attention reports and formatting helpers. + - `reviewer_load.py` — `build_reviewer_loads(repository)` / `reviewer_load_for(repository, login)`: per-reviewer + review-load (weighted, matching the assignment engine's capacity gate) as of the latest cached queue snapshot, + plus `format_load_line`. Single authority shared by the `assigned-prs` command and the daily reviewer-attention + digest; read-only (never builds a snapshot), returns `{}`/`None` when no snapshot exists. - `pr_info.py` — `get_pr_queue_info(owner, repo, pr_number)`: returns `PRQueueInfo` for a single PR; prefers the default `QueueSnapshot`, falls back to direct DB queries for merged/closed PRs. - `ci_evaluation.py` — single-PR CI status evaluation against a ruleset's `required_ci_contexts`; use `ci_status_for_pr(pr, rules, repository)` instead of re-implementing context-matching logic. diff --git a/qb_site/analyzer/services/reviewer_load.py b/qb_site/analyzer/services/reviewer_load.py new file mode 100644 index 00000000..1c588ee5 --- /dev/null +++ b/qb_site/analyzer/services/reviewer_load.py @@ -0,0 +1,185 @@ +"""Per-reviewer review-load, as of the latest queue snapshot. + +Single authority for "how loaded is this reviewer in this repo?", shared by the reviewer-facing +surfaces (the ``assigned-prs`` Zulip command and the daily reviewer-attention digest). The load +figure mirrors the capacity accounting the assignment *engine* already computes +(``analyzer.services.reviewer_assignment`` / ``reviewer_assignment_engine``), so the number a +reviewer sees is the same one that gates whether they get auto-assigned: + +- ``current_load`` is the engine's **weighted** load (status-weighted, self-authored PRs excluded), + not a raw PR count. ``maximum_capacity - current_load`` is the reviewer's remaining capacity. +- ``assigned_open`` is the raw count of open PRs they are assigned to, kept alongside for human + context (the gap between it and ``current_load`` is what silently reflects zero-weight PRs). + +This module deliberately does **not** re-derive load math: it reads the cached queue snapshot (the +same one ``pr_info`` uses) and folds ``collect_assignment_statistics`` output against reviewer +capacities. It never *builds* a snapshot — that is the refresh task's job — so callers must treat a +missing snapshot as "no load line" rather than a zero. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Iterable, Mapping + +from analyzer.models import QueueSnapshot +from analyzer.services.queue_rules import default_rule_set_for_repo +from analyzer.services.reviewer_assignment import build_reviewer_catalog, collect_assignment_statistics +from analyzer.services.reviewer_assignment_engine import ReviewerProfile +from core.models import Repository + +# Float slack for the capacity comparison: weighted loads are floats, so treat anything within an +# epsilon of the cap as "no room left" — matching the engine's ``remaining > 0`` availability gate. +_CAPACITY_EPSILON = 1e-9 + + +def normalize_login(login: str | None) -> str: + return (login or "").strip().lower() + + +@dataclass(frozen=True) +class ReviewerLoad: + """A reviewer's load standing in one repository (see module docstring).""" + + repository_id: int + reviewer_login: str # normalized (lowercase) + assigned_open: int + current_load: float + capacity: int + remaining: float + at_capacity: bool + + +def compute_reviewer_loads( + *, + repository_id: int, + assignments: Mapping[str, tuple[list[int], float, int]], + reviewers: Iterable[ReviewerProfile], +) -> dict[str, ReviewerLoad]: + """Pure fold of engine assignment stats + reviewer capacities into per-reviewer loads. + + ``assignments`` is ``AssignmentStatistics.assignments`` (``login -> (open_pr_numbers, + weighted_load, total_assigned)``). Assignment keys are matched to reviewers case-insensitively. + The result is keyed by normalized login and includes *every* reviewer in ``reviewers`` — a + reviewer with nothing assigned gets a zero load (so callers can render "Load: 0 / N"). + """ + weighted_by_login: dict[str, float] = {} + open_count_by_login: dict[str, int] = {} + for login, (open_list, weighted, _total) in assignments.items(): + norm = normalize_login(login) + if not norm: + continue + weighted_by_login[norm] = weighted_by_login.get(norm, 0.0) + float(weighted) + open_count_by_login[norm] = open_count_by_login.get(norm, 0) + len(open_list) + + loads: dict[str, ReviewerLoad] = {} + for reviewer in reviewers: + norm = normalize_login(reviewer.github_login) + if not norm or norm in loads: + continue + current_load = weighted_by_login.get(norm, 0.0) + capacity = int(reviewer.maximum_capacity) + remaining = capacity - current_load + loads[norm] = ReviewerLoad( + repository_id=int(repository_id), + reviewer_login=norm, + assigned_open=open_count_by_login.get(norm, 0), + current_load=current_load, + capacity=capacity, + remaining=remaining, + at_capacity=remaining <= _CAPACITY_EPSILON, + ) + return loads + + +def build_reviewer_loads( + repository: Repository, + *, + snapshot_payload: dict | None = None, + now: datetime | None = None, +) -> dict[str, ReviewerLoad]: + """Per-reviewer load for a repo, keyed by normalized login. + + Reads the latest cached queue snapshot (same resolution as ``pr_info``). Returns ``{}`` when no + snapshot or no reviewers are available — callers should render no load line in that case rather + than fabricate a count. Read-only: never builds a snapshot. + """ + payload = snapshot_payload if snapshot_payload is not None else _latest_snapshot_payload(repository) + if not payload: + return {} + reviewers = build_reviewer_catalog(repository, now=now) + if not reviewers: + return {} + stats = collect_assignment_statistics(payload) + return compute_reviewer_loads( + repository_id=int(repository.id), + assignments=stats.assignments, + reviewers=reviewers, + ) + + +def reviewer_load_for( + repository: Repository, + reviewer_login: str, + *, + snapshot_payload: dict | None = None, + now: datetime | None = None, +) -> ReviewerLoad | None: + """One reviewer's load for a repo, or ``None`` when unavailable.""" + loads = build_reviewer_loads(repository, snapshot_payload=snapshot_payload, now=now) + return loads.get(normalize_login(reviewer_login)) + + +def _latest_snapshot_payload(repository: Repository) -> dict | None: + rule_set = default_rule_set_for_repo(repository) + cache_key = str(rule_set.id) if rule_set else "default" + snapshot = ( + QueueSnapshot.objects.filter(repository=repository, cache_key=cache_key).order_by("-generated_at").only("payload").first() + ) + if snapshot is None or not snapshot.payload: + return None + return snapshot.payload + + +# --- formatting -------------------------------------------------------------- + + +def _fmt_load_number(value: float) -> str: + """Integer when whole (``3``), else one decimal (``4.5``).""" + rounded = round(float(value), 1) + if abs(rounded - round(rounded)) < _CAPACITY_EPSILON: + return str(int(round(rounded))) + return f"{rounded:.1f}" + + +def format_load_line(load: ReviewerLoad, *, include_assigned_count: bool = False) -> str: + """Render the one-line load summary. + + ``Load: 3 / 10 (7 free)`` normally; ``Load: 10 / 10 ⚠ at capacity`` when full (or over). With + ``include_assigned_count`` (the daily digest, which never lists the full roster), append + ``· N assigned``. + + ``at_capacity`` mirrors the engine's strict ``remaining > 0`` assignability gate, so a reviewer + with any real room (e.g. 9.96/10) is *not* full and can still be assigned. Two display rules keep + that honest for such near-cap loads: + + - ``free`` is derived from the shown ``used`` (``capacity - used``), so the two always sum to the + capacity instead of drifting apart under independent rounding. + - a ``used`` that rounds up to the capacity while the reviewer is not actually full is clamped to + ``capacity - 0.1``, so real (if tiny) room never renders as the contradictory ``10 / 10 + (0 free)`` — it shows ``9.9 / 10 (0.1 free)`` instead. + """ + cap = str(load.capacity) + if load.at_capacity: + # Show the true (possibly over-capacity) load next to the marker. + line = f"Load: {_fmt_load_number(load.current_load)} / {cap} ⚠ at capacity" + else: + used_val = round(load.current_load, 1) + if used_val >= load.capacity: + used_val = load.capacity - 0.1 + free_val = round(load.capacity - used_val, 1) + line = f"Load: {_fmt_load_number(used_val)} / {cap} ({_fmt_load_number(free_val)} free)" + if include_assigned_count: + line += f" · {load.assigned_open} assigned" + return line diff --git a/qb_site/analyzer/tasks/reviewer_attention.py b/qb_site/analyzer/tasks/reviewer_attention.py index d82cc7a5..c7c36ec6 100644 --- a/qb_site/analyzer/tasks/reviewer_attention.py +++ b/qb_site/analyzer/tasks/reviewer_attention.py @@ -16,6 +16,7 @@ ) from analyzer.services import build_reviewer_attention_reports from analyzer.services.reviewer_attention import ReviewerAttentionItem, ReviewerAttentionReport +from analyzer.services.reviewer_load import ReviewerLoad, build_reviewer_loads, format_load_line, normalize_login from analyzer.services.reviewer_attention_format import ( format_since_timestamp, render_consecutive_queue_time_since_assignment_line, @@ -292,7 +293,10 @@ def _render_reviewer_message( repo_reports: list[tuple[str, ReviewerAttentionReport]], enforcement_enabled: bool, unassign_outcomes: dict[tuple[int, int, int], str], + loads_by_repo_id: dict[int, dict[str, ReviewerLoad]] | None = None, ) -> str: + loads_by_repo_id = loads_by_repo_id or {} + reviewer_login_norm = normalize_login(reviewer_login) lines: list[str] = [ "### Assigned queue PRs that may need your attention", "", @@ -313,6 +317,10 @@ def _render_reviewer_message( f"{report.stale_nudge_days} consecutive days on queue since assignment; " f"auto-unassign at {report.auto_unassign_days} days." ) + # Load context (this digest never lists the full roster, so include the raw assigned count). + load = loads_by_repo_id.get(int(report.repository_id), {}).get(reviewer_login_norm) + if load is not None: + lines.append(format_load_line(load, include_assigned_count=True)) lines.append("") if new_items: lines.append(f"#### Newly assigned ({len(new_items)})") @@ -648,6 +656,13 @@ def reviewer_attention_daily_task( client_init_error = str(exc) log.warning("analyzer.reviewer_attention_daily: unable to initialize Zulip client: %s", client_init_error) + # Per-repo reviewer load (weighted, as of the latest queue snapshot), only when we will actually + # render/send messages. Computed once per repo and shared across reviewers. + loads_by_repo_id: dict[int, dict[str, ReviewerLoad]] = {} + if delivery_enabled and client is not None: + for repo in repos: + loads_by_repo_id[int(repo.id)] = build_reviewer_loads(repo) + for reviewer_user_id, payload in sorted(reports_by_reviewer.items()): reviewer_login = str(payload["reviewer_login"]) user = users_by_id.get(reviewer_user_id) @@ -730,6 +745,7 @@ def reviewer_attention_daily_task( repo_reports=filtered_repo_reports, enforcement_enabled=enforcement_enabled, unassign_outcomes=unassign_outcomes, + loads_by_repo_id=loads_by_repo_id, ) chunks = _split_message_chunks(content=message, max_chars=MAX_MESSAGE_CHARS) delivery_stats["attempted"] += 1 diff --git a/qb_site/analyzer/tests/services/test_reviewer_load.py b/qb_site/analyzer/tests/services/test_reviewer_load.py new file mode 100644 index 00000000..d626eeaa --- /dev/null +++ b/qb_site/analyzer/tests/services/test_reviewer_load.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from datetime import datetime, timezone as dt_timezone + +from django.test import TestCase + +from analyzer.models import QueueRuleSet, QueueSnapshot +from analyzer.services.reviewer_assignment_engine import ReviewerProfile +from analyzer.services.reviewer_load import ( + ReviewerLoad, + build_reviewer_loads, + compute_reviewer_loads, + format_load_line, + reviewer_load_for, +) +from core.models import Repository, ReviewerPreference, User + + +def _profile(login: str, capacity: int) -> ReviewerProfile: + return ReviewerProfile( + github_login=login, + maximum_capacity=capacity, + auto_assign=True, + temporary_break=False, + preferred_labels=[], + preferred_labels_lower=set(), + free_form="", + conflict_of_interest=[], + conflict_of_interest_lower=set(), + ) + + +class TestComputeReviewerLoads(TestCase): + def test_weighted_load_and_remaining(self) -> None: + loads = compute_reviewer_loads( + repository_id=7, + assignments={"alice": ([1, 2, 3], 3.0, 3)}, + reviewers=[_profile("alice", 10)], + ) + load = loads["alice"] + self.assertEqual(load.repository_id, 7) + self.assertEqual(load.assigned_open, 3) + self.assertEqual(load.current_load, 3.0) + self.assertEqual(load.capacity, 10) + self.assertEqual(load.remaining, 7.0) + self.assertFalse(load.at_capacity) + + def test_assigned_count_can_exceed_weighted_load(self) -> None: + # 5 assigned PRs but only 3.0 counts toward capacity (zero-weight/self-authored PRs). + loads = compute_reviewer_loads( + repository_id=1, + assignments={"alice": ([1, 2, 3, 4, 5], 3.0, 5)}, + reviewers=[_profile("alice", 10)], + ) + self.assertEqual(loads["alice"].assigned_open, 5) + self.assertEqual(loads["alice"].current_load, 3.0) + + def test_case_insensitive_login_matching(self) -> None: + loads = compute_reviewer_loads( + repository_id=1, + assignments={"AliCe": ([9], 1.0, 1)}, + reviewers=[_profile("alice", 4)], + ) + self.assertIn("alice", loads) + self.assertEqual(loads["alice"].assigned_open, 1) + self.assertEqual(loads["alice"].current_load, 1.0) + + def test_reviewer_with_nothing_assigned_is_zero_not_at_capacity(self) -> None: + loads = compute_reviewer_loads(repository_id=1, assignments={}, reviewers=[_profile("alice", 10)]) + load = loads["alice"] + self.assertEqual(load.assigned_open, 0) + self.assertEqual(load.current_load, 0.0) + self.assertEqual(load.remaining, 10.0) + self.assertFalse(load.at_capacity) + + def test_at_capacity_when_load_meets_cap(self) -> None: + loads = compute_reviewer_loads( + repository_id=1, + assignments={"alice": ([1, 2], 2.0, 2)}, + reviewers=[_profile("alice", 2)], + ) + self.assertTrue(loads["alice"].at_capacity) + self.assertEqual(loads["alice"].remaining, 0.0) + + def test_over_capacity_is_at_capacity(self) -> None: + loads = compute_reviewer_loads( + repository_id=1, + assignments={"alice": ([1, 2, 3], 3.0, 3)}, + reviewers=[_profile("alice", 2)], + ) + self.assertTrue(loads["alice"].at_capacity) + self.assertEqual(loads["alice"].remaining, -1.0) + + def test_no_reviewers_yields_empty(self) -> None: + self.assertEqual(compute_reviewer_loads(repository_id=1, assignments={"x": ([1], 1.0, 1)}, reviewers=[]), {}) + + +class TestFormatLoadLine(TestCase): + def _load(self, *, used: float, cap: int, assigned: int) -> ReviewerLoad: + remaining = cap - used + return ReviewerLoad( + repository_id=1, + reviewer_login="alice", + assigned_open=assigned, + current_load=used, + capacity=cap, + remaining=remaining, + at_capacity=remaining <= 1e-9, + ) + + def test_normal_line_shows_free(self) -> None: + self.assertEqual(format_load_line(self._load(used=3.0, cap=10, assigned=5)), "Load: 3 / 10 (7 free)") + + def test_at_capacity_replaces_free(self) -> None: + self.assertEqual(format_load_line(self._load(used=10.0, cap=10, assigned=12)), "Load: 10 / 10 ⚠ at capacity") + + def test_fractional_uses_one_decimal(self) -> None: + self.assertEqual(format_load_line(self._load(used=4.5, cap=10, assigned=5)), "Load: 4.5 / 10 (5.5 free)") + + def test_include_assigned_count_appends_suffix(self) -> None: + self.assertEqual( + format_load_line(self._load(used=9.0, cap=10, assigned=9), include_assigned_count=True), + "Load: 9 / 10 (1 free) · 9 assigned", + ) + + def test_at_capacity_with_count(self) -> None: + self.assertEqual( + format_load_line(self._load(used=10.0, cap=10, assigned=12), include_assigned_count=True), + "Load: 10 / 10 ⚠ at capacity · 12 assigned", + ) + + def test_over_capacity_shows_true_load(self) -> None: + # The engine can push a reviewer past capacity (no look-ahead); show the real figure. + self.assertEqual(format_load_line(self._load(used=10.5, cap=10, assigned=12)), "Load: 10.5 / 10 ⚠ at capacity") + + def test_near_capacity_with_real_room_is_not_rendered_as_full(self) -> None: + # 9.96/10 is NOT at capacity (remaining 0.04 > 0 -> still assignable); it must not render as + # the contradictory "10 / 10 (0 free)". + self.assertEqual(format_load_line(self._load(used=9.96, cap=10, assigned=12)), "Load: 9.9 / 10 (0.1 free)") + + def test_used_and_free_always_sum_to_capacity(self) -> None: + # Independent rounding used to drift (3.6 + 6.3 = 9.9); free is now derived from used. + self.assertEqual(format_load_line(self._load(used=3.65, cap=10, assigned=5)), "Load: 3.6 / 10 (6.4 free)") + + +class TestBuildReviewerLoads(TestCase): + def setUp(self) -> None: + self.repo = Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master") + self.rules = QueueRuleSet.objects.create( + repository=self.repo, + version=1, + require_open=True, + require_not_draft=True, + require_ci_success=False, + required_label_names=[], + forbidden_label_names=[], + is_active=True, + ) + alice = User.objects.create(github_login="alice", zulip_user_id=101) + erin = User.objects.create(github_login="erin", zulip_user_id=102) + ReviewerPreference.objects.create(user=alice, repository=self.repo, maximum_capacity=10) + ReviewerPreference.objects.create(user=erin, repository=self.repo, maximum_capacity=1) + + def _seed_snapshot(self, prs: dict) -> None: + QueueSnapshot.objects.create( + repository=self.repo, + cache_key=str(self.rules.id), + generated_at=datetime(2026, 7, 1, tzinfo=dt_timezone.utc), + payload={"meta": {"generated_at": "2026-07-01T00:00:00+00:00"}, "prs": prs}, + etag="etag", + pr_count=len(prs), + queue_count=0, + ) + + def test_returns_empty_without_snapshot(self) -> None: + self.assertEqual(build_reviewer_loads(self.repo), {}) + + def test_computes_weighted_load_from_snapshot(self) -> None: + self._seed_snapshot( + { + "1": {"assignees": ["alice"], "author": "bob", "pr_status": "AwaitingReview"}, + # Self-authored: counts toward assigned_open but contributes zero weight. + "2": {"assignees": ["alice"], "author": "alice", "pr_status": "AwaitingReview"}, + "3": {"assignees": ["erin"], "author": "frank", "pr_status": "AwaitingReview"}, + } + ) + loads = build_reviewer_loads(self.repo) + + self.assertEqual(loads["alice"].assigned_open, 2) + self.assertEqual(loads["alice"].current_load, 1.0) + self.assertEqual(loads["alice"].capacity, 10) + self.assertFalse(loads["alice"].at_capacity) + + # erin has one weight-1 PR against a capacity of 1 -> at capacity. + self.assertTrue(loads["erin"].at_capacity) + self.assertEqual(loads["erin"].current_load, 1.0) + + def test_reviewer_load_for_convenience(self) -> None: + self._seed_snapshot({"1": {"assignees": ["alice"], "author": "bob", "pr_status": "AwaitingReview"}}) + load = reviewer_load_for(self.repo, "AliCe") # case-insensitive + self.assertIsNotNone(load) + self.assertEqual(load.current_load, 1.0) + self.assertIsNone(reviewer_load_for(self.repo, "nobody")) diff --git a/qb_site/analyzer/tests/tasks/test_reviewer_attention_task.py b/qb_site/analyzer/tests/tasks/test_reviewer_attention_task.py index ce32c39c..d7b32542 100644 --- a/qb_site/analyzer/tests/tasks/test_reviewer_attention_task.py +++ b/qb_site/analyzer/tests/tasks/test_reviewer_attention_task.py @@ -7,10 +7,15 @@ from django.test import TestCase, override_settings from django.utils import timezone -from analyzer.models import ReviewerAttentionAutoUnassignRecord, ReviewerAttentionNotificationRecord +from analyzer.models import ( + QueueRuleSet, + QueueSnapshot, + ReviewerAttentionAutoUnassignRecord, + ReviewerAttentionNotificationRecord, +) from analyzer.services.reviewer_attention import ReviewerAttentionItem, ReviewerAttentionReport from analyzer.tasks.reviewer_attention import reviewer_attention_daily_task -from core.models import Repository, User +from core.models import Repository, ReviewerPreference, User from core.services.github_assignment import AssignmentMutationError from zulip_bot.services.zulip_client import ZulipApiError @@ -199,6 +204,69 @@ def test_sends_one_message_per_reviewer_when_delivery_enabled(self, mock_client_ self.assertIn("`assigned-prs`", kwargs["content"]) self.assertIn("`prefs`", kwargs["content"]) + @override_settings( + ANALYZER_REVIEWER_ATTENTION_ENABLED=True, + ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED=False, + ANALYZER_REVIEWER_ATTENTION_DELIVERY_ENABLED=True, + ) + @patch("analyzer.tasks.reviewer_attention.build_reviewer_attention_reports") + @patch("analyzer.tasks.reviewer_attention.ZulipClient") + def test_message_includes_load_line_from_snapshot(self, mock_client_cls, mock_build_reports) -> None: + rules = QueueRuleSet.objects.create( + repository=self.repo, + version=1, + require_open=True, + require_not_draft=True, + require_ci_success=False, + required_label_names=[], + forbidden_label_names=[], + is_active=True, + ) + ReviewerPreference.objects.create(user=self.user, repository=self.repo, maximum_capacity=10) + QueueSnapshot.objects.create( + repository=self.repo, + cache_key=str(rules.id), + generated_at=datetime.now(dt_timezone.utc), + payload={"prs": {"77": {"assignees": ["alice"], "author": "bob", "pr_status": "AwaitingReview"}}}, + etag="etag", + pr_count=1, + queue_count=0, + ) + assigned_at = datetime.now(dt_timezone.utc) - timedelta(hours=6) + mock_build_reports.return_value = [ + ReviewerAttentionReport( + reviewer_login="alice", + reviewer_user_id=self.user.id, + repository_id=self.repo.id, + notifications_enabled=True, + stale_nudge_days=14, + auto_unassign_days=21, + items=( + ReviewerAttentionItem( + pr_number=101, + pr_title="PR 101", + is_on_queue=True, + last_assigned_at=assigned_at, + queue_anchor_at=None, + days_on_queue_since_assignment=16, + total_queue_seconds=16 * 24 * 60 * 60, + total_queue_days=16, + needs_new_assignment_ping=True, + needs_nudge=False, + needs_auto_unassign=False, + missing_assignment_timestamp=False, + ), + ), + warnings=(), + ) + ] + mock_client = mock_client_cls.return_value + + reviewer_attention_daily_task.apply().get() + + content = mock_client.send_direct_message.call_args.kwargs["content"] + self.assertIn("Load: 1 / 10 (9 free) · 1 assigned", content) + @override_settings( ANALYZER_REVIEWER_ATTENTION_ENABLED=True, ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED=False, diff --git a/qb_site/zulip_bot/commands/assigned_prs.py b/qb_site/zulip_bot/commands/assigned_prs.py index 05ce6017..e2f4c08a 100644 --- a/qb_site/zulip_bot/commands/assigned_prs.py +++ b/qb_site/zulip_bot/commands/assigned_prs.py @@ -14,6 +14,7 @@ sort_by_queue_age, ) from analyzer.services.reviewer_attention import ReviewerAttentionItem, ReviewerAttentionReport, build_reviewer_attention_reports +from analyzer.services.reviewer_load import ReviewerLoad, build_reviewer_loads, format_load_line, normalize_login from core.models import Repository, ReviewerPreference, User from core.utils.zulip_time import format_global_time from syncer.models import PRLabel, PullRequest @@ -49,14 +50,20 @@ def assigned_prs_command(context: CommandContext, args: str) -> CommandResult: if not prefs: return CommandResult(content="You do not currently have any reviewer preferences configured.") + reviewer_login_norm = normalize_login(user.github_login) reports_by_repo_id: dict[int, ReviewerAttentionReport] = {} repo_labels_by_repo_id: dict[int, str] = {} + load_by_repo_id: dict[int, ReviewerLoad] = {} for pref in prefs: repo_labels_by_repo_id[int(pref.repository_id)] = f"{pref.repository.owner}/{pref.repository.name}" reports = build_reviewer_attention_reports(repository=pref.repository) report = next((entry for entry in reports if entry.reviewer_user_id == user.id), None) if report is not None: reports_by_repo_id[int(pref.repository_id)] = report + # Reviewer load as of the latest queue snapshot; absent snapshot -> no load line. + load = build_reviewer_loads(pref.repository).get(reviewer_login_norm) + if load is not None: + load_by_repo_id[int(pref.repository_id)] = load # Batch-fetch display extras (author, CI, labels, timestamps) per repo. extras_by_repo_and_pr: dict[tuple[int, int], _PrExtras] = {} @@ -91,6 +98,7 @@ def assigned_prs_command(context: CommandContext, args: str) -> CommandResult: if int(pref.repository_id) in reports_by_repo_id ], mention_map=mention_map, + load_by_repo_id=load_by_repo_id, ) chunks = _split_message_chunks(content=content, max_chars=MAX_MESSAGE_CHARS) @@ -171,7 +179,9 @@ def _render_assigned_prs_report( reviewer_login: str, reports: list[tuple[str, ReviewerAttentionReport, dict[int, _PrExtras]]], mention_map: dict[str, str], + load_by_repo_id: dict[int, ReviewerLoad] | None = None, ) -> str: + load_by_repo_id = load_by_repo_id or {} lines: list[str] = [] lines.append(f"### Assigned PRs report for `{reviewer_login}`") lines.append("") @@ -182,6 +192,13 @@ def _render_assigned_prs_report( lines.append(f"Generated at {format_global_time(_now_utc_unix())}.") + # Cross-repo heads-up, surfaced only when it's actionable (at capacity somewhere). Counted over + # the repos actually shown below (those with a load figure available). + shown_loads = [load_by_repo_id[report.repository_id] for _, report, _ in reports if report.repository_id in load_by_repo_id] + at_capacity_loads = [load for load in shown_loads if load.at_capacity] + if at_capacity_loads: + lines.append(f"⚠ At capacity in {len(at_capacity_loads)} of {len(shown_loads)} repos.") + any_assigned = False for repo_label, report, extras_by_pr in reports: lines.append("") @@ -189,6 +206,11 @@ def _render_assigned_prs_report( lines.append( f"Thresholds: stale nudge `{report.stale_nudge_days}` days, auto-unassign `{report.auto_unassign_days}` days." ) + load = load_by_repo_id.get(report.repository_id) + if load is not None: + # Weighted load vs capacity; the raw PR count is omitted here since the group headers + # (On Queue / Maintainer Merged / Not On Queue) already sum to it. + lines.append(format_load_line(load)) if report.warnings: lines.append("Warnings:") diff --git a/qb_site/zulip_bot/tests/commands/test_assigned_prs_command.py b/qb_site/zulip_bot/tests/commands/test_assigned_prs_command.py index e8fb9f56..e90075aa 100644 --- a/qb_site/zulip_bot/tests/commands/test_assigned_prs_command.py +++ b/qb_site/zulip_bot/tests/commands/test_assigned_prs_command.py @@ -4,7 +4,7 @@ from django.test import TestCase, override_settings -from analyzer.models import PRQueueWindow, QueueRuleSet +from analyzer.models import PRQueueWindow, QueueRuleSet, QueueSnapshot from analyzer.services.reviewer_attention_format import format_compact_duration from core.models import Repository, ReviewerPreference, User from syncer.models import LabelDef, PRLabel, PullRequest, PRTimelineEvent, PRTimelineEventType @@ -278,6 +278,63 @@ def test_shows_human_friendly_auto_unassign_status(self) -> None: kwargs["content"], ) + def _seed_snapshot(self, repo: Repository, rules: QueueRuleSet, prs: dict) -> None: + QueueSnapshot.objects.create( + repository=repo, + cache_key=str(rules.id), + generated_at=datetime.now(dt_timezone.utc), + payload={"prs": prs}, + etag="etag", + pr_count=len(prs), + queue_count=0, + ) + + def test_shows_load_line_from_snapshot(self) -> None: + user = User.objects.create(github_login="alice", zulip_user_id=101) + repo = Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master") + ReviewerPreference.objects.create(user=user, repository=repo, maximum_capacity=10) + rules = QueueRuleSet.objects.create( + repository=repo, + version=1, + require_open=True, + require_not_draft=True, + require_ci_success=False, + required_label_names=[], + forbidden_label_names=[], + is_active=True, + ) + self._seed_snapshot(repo, rules, {"1": {"assignees": ["alice"], "author": "bob", "pr_status": "AwaitingReview"}}) + + with patch("zulip_bot.commands.assigned_prs.ZulipClient.send_direct_message") as mock_send: + assigned_prs_command(self._context(), "") + + content = mock_send.call_args.kwargs["content"] + self.assertIn("Load: 1 / 10 (9 free)", content) + self.assertNotIn("At capacity in", content) + + def test_shows_at_capacity_summary(self) -> None: + user = User.objects.create(github_login="alice", zulip_user_id=101) + repo = Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master") + ReviewerPreference.objects.create(user=user, repository=repo, maximum_capacity=1) + rules = QueueRuleSet.objects.create( + repository=repo, + version=1, + require_open=True, + require_not_draft=True, + require_ci_success=False, + required_label_names=[], + forbidden_label_names=[], + is_active=True, + ) + self._seed_snapshot(repo, rules, {"1": {"assignees": ["alice"], "author": "bob", "pr_status": "AwaitingReview"}}) + + with patch("zulip_bot.commands.assigned_prs.ZulipClient.send_direct_message") as mock_send: + assigned_prs_command(self._context(), "") + + content = mock_send.call_args.kwargs["content"] + self.assertIn("Load: 1 / 1 ⚠ at capacity", content) + self.assertIn("⚠ At capacity in 1 of 1 repos.", content) + class TestSplitMessageChunks(TestCase): def test_splits_when_message_exceeds_limit(self) -> None: