From 3e40eb0ad5a8bd3631512cae1ca8c02a7edb5ae7 Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Wed, 8 Jul 2026 21:25:44 -0400
Subject: [PATCH 01/33] doc: plan reviewer assignment acceptance gate (design
doc 050)
Add a living implementation plan for a pre-assignment acceptance gate:
proposals are offered to reviewers who must accept via a GitHub-login
console before the assignment is executed on GitHub.
Key decisions captured:
- Per-reviewer assignment_acceptance mode (auto/confirm), orthogonal to
auto_assign and notifications; new accounts default confirm, existing
backfilled auto.
- Confirmation prompts are transactional and decoupled from
notifications_enabled (which governs only optional nudges).
- {unassigned, proposed, assigned} state model with legibility invariants;
on-queue-exit policy resolved to invalidate but pluggable via a single
proposal_validity predicate + setting.
- Proposal state surfaced on the board and in pr-info; history retained
indefinitely (low volume, analytical value).
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_014dQnn9K9BnaFA12a2jVNKy
---
...050-reviewer-assignment-acceptance-gate.md | 474 ++++++++++++++++++
1 file changed, 474 insertions(+)
create mode 100644 docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
diff --git a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
new file mode 100644
index 00000000..b31cf4ae
--- /dev/null
+++ b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
@@ -0,0 +1,474 @@
+# Reviewer Assignment Acceptance Gate (Propose → Accept → Assign)
+
+> Status: Living implementation plan (not yet implemented). Drafted from a design
+> discussion; captures decisions, invariants, and a chunked build plan.
+
+## Context
+
+- Reviewers are auto-assigned to PRs, but assignments are frequently ignored/forgotten.
+ A community request (private Zulip thread, Filippo Nuccio + Riccardo Brasca, with input
+ from Dagur Asgeirsson, Rémy Degenne, Yaël Dillies, Jon Eugster, Michael Rothgang,
+ Christian Merten) asks that an assignment be **proposed** to a reviewer, who must
+ **accept** it within a few days before it is actually executed. The goal is that "an
+ assignment really means the assignee knows they're in charge," reducing forgotten and
+ doubled review effort.
+- The existing reviewer pipeline is three decoupled daily Celery stages in `analyzer/`:
+ - `analyzer.refresh_reviewer_assignments` (compute) — `ReviewerAssignmentBuilder`
+ (`analyzer/services/reviewer_assignment.py`) + the pure engine
+ (`analyzer/services/reviewer_assignment_engine.py`) produce
+ `ReviewerAssignmentSnapshot.payload["automatic_assignments"]` = `{pr_number: login}`.
+ Candidate pool = the `Queue` dashboard list minus already-assigned PRs
+ (`_filter_prs_without_active_assignee`) and assignment-forbidden labels. Reviewer
+ selection is topic-label-matched, author/COI-excluded, opt-out-excluded, and
+ capacity-gated (weighted load vs `maximum_capacity`, default 10; see design doc 037).
+ - `analyzer.apply_reviewer_assignments` (apply) — `apply_assignments_for_repo`
+ (`analyzer/services/reviewer_assignment_apply.py`) re-validates each proposal against
+ live data and POSTs the assignee via `GitHubAssignmentClient.assign(...)` +
+ `assign_pr` App token, recording `ReviewerAssignmentApplication`. Gated by
+ `ANALYZER_REVIEWER_ASSIGNMENT_APPLY_ENABLED` (see design doc 046).
+ - `analyzer.reviewer_attention_daily` (attention) — nudges at `stale_nudge_days`
+ (default 14) and auto-unassigns at `auto_unassign_days` (default 21, hard cap 21);
+ per-cycle DM dedupe; queue-re-entry resets the clock (design doc 028).
+- Reviewer config lives in `core.ReviewerPreference` (repo-scoped): `maximum_capacity`,
+ `auto_assign`, `away_until`, `preferred_labels`, `conflict_of_interest`,
+ `notifications_enabled`, `notification_settings` (JSON thresholds). Identity is
+ `core.User` (`github_login` ↔ `zulip_user_id`).
+- Reusable infrastructure already present:
+ - Proactive Zulip DMs (`zulip_bot.services.zulip_client.ZulipClient.send_direct_message`).
+ - GitHub OAuth for identity (`zulip_bot/services/github_oauth.py`,
+ `views.register_github_callback`, `services/registration_linking.py`).
+ - Per-PR opt-outs (`analyzer.ReviewerOptOut`, keyed `(repository, pr_number,
+ reviewer_login)`), driven by timeline assign/unassign events
+ (`syncer/services/pr_sync_service.py::_apply_assignment_opt_outs`).
+ - GitHub App operation tokens (`core/services/github_operation_tokens.py`;
+ operations `assign_pr` / `unassign_pr`).
+
+### Key realization
+
+With a **daily recompute loop already in place**, the pre-assignment gate needs almost no
+bespoke per-PR sequencing. "One at a time, advance to the next reviewer on timeout" falls
+out of *recompute + exclude*: an expired/declined reviewer is excluded next cycle, the
+engine picks the next-best candidate, and a new proposal is created. No hand-written
+state machine.
+
+## Goals / Non-Goals
+
+Goals:
+- Insert an **acceptance gate** between compute and the GitHub assignment for reviewers in
+ `confirm` mode: propose → notify → the reviewer accepts on a web console → *then* assign.
+- Make the mode a **per-reviewer** preference, orthogonal to eligibility and notifications,
+ with defined behavior for every combination and no disruption to existing reviewers.
+- Provide a **reviewer console** (GitHub-login authed) to view and act on proposals and
+ current assignments.
+- Keep all reviewer contact on Zulip DM (and the console); **never** post to the GitHub PR.
+- Surface the "awaiting acceptance" state on the **queueboard** so humans don't double-grab
+ a PR mid-proposal, while the GitHub PR page stays clean.
+
+Non-Goals (this phase):
+- Email channel (no SMTP backend or `User.email` today — deferred; Zulip-first).
+- The "re-confirm ~10 days after `awaiting-author` is removed" second prompt (Filippo's
+ second proposal) — deferred to a later phase; it maps onto the attention sweep's
+ queue-re-entry reset.
+- Michael/Christian's "reviewed X PRs in 30 days → no more auto-assign" throughput cap —
+ orthogonal; a separate capacity input to the engine.
+- "Bot notices missed review comments" (Riccardo) — hard to detect reliably; out of scope.
+- Changing the assignment *algorithm* (037) beyond feeding pending-load, pending-exclusion,
+ and cooldown into the candidate filter.
+- Unifying the proposal DM with the attention-sweep DM into a single per-reviewer digest
+ (nice-to-have; later). Phase 1 only de-dupes the two so a reviewer never gets both a
+ "newly assigned" ping and a "please accept" ping for the same event.
+- Console-only `confirm` (a `confirmation_prompts_enabled` toggle, default true, to suppress
+ even the proposal DM for a self-manager who checks the console manually). Deferred as
+ YAGNI — a `confirm` reviewer with Zulip linked always gets the low-noise digest DM. Add
+ only if a reviewer actually asks.
+
+## Proposed Design
+
+### Pipeline topology
+
+```
+refresh_reviewer_assignments (unchanged) COMPUTE snapshot {pr: login}
+ │
+ propose_reviewer_assignments (NEW; replaces the direct-POST batch for confirm reviewers)
+ │ per {pr, login}: branch on the reviewer's assignment_acceptance mode
+ │ • auto → existing 046 direct-assign path (GitHubAssignmentClient.assign)
+ │ • confirm → create AssignmentProposal(state=proposed,
+ │ expires_at = now + window) [skip if already assigned /
+ │ PR already has an active proposal / opted-out / ineligible]
+ │ • confirm but no reachable channel → fall back to auto (direct-assign)
+ │
+ digest DM per confirm-reviewer → "N proposals expiring , manage here: "
+ │
+ ├── reviewer ACCEPTS on console → re-validate live → GitHubAssignmentClient.assign
+ │ → proposal=accepted, record ReviewerAssignmentApplication, enqueue sync_pr
+ ├── reviewer DECLINES on console → proposal=declined + active ReviewerOptOut (that PR)
+ └── EXPIRES (silent) → proposal=expired (soft cooldown; see below)
+ │
+ next day: recompute excludes opted-out / cooled-down reviewer → engine picks the
+ next-best candidate → propose to them. ← "advance to next" for free
+```
+
+The current `apply_reviewer_assignments` logic **splits**: its batch half becomes the
+`propose` step; its mutation half (`assign` + `ReviewerAssignmentApplication` + `sync_pr`
+enqueue) moves into the **console accept handler**, triggered by a human. The 046
+direct-assign path is **retained** and used verbatim for `auto`-mode reviewers.
+
+### PR assignment state model (keep it comprehensible)
+
+The proposal does **not** add a new orthogonal dimension; it adds **one intermediate value
+to the existing assignment axis**. For an open, on-queue PR:
+
+- **Unassigned** — no GitHub assignee, no active proposal.
+- **Proposed** — no GitHub assignee, one active proposal to reviewer X (expires Y). *[NEW]*
+- **Assigned** — has a GitHub assignee (accepted-via-gate, human-assigned, or `auto`-mode direct).
+
+Invariants that keep this legible (design goal in its own right):
+
+1. A PR is in **exactly one** of {unassigned, proposed, assigned} at any moment — enforced by
+ the partial-unique index (one active proposal per PR).
+2. **"Proposed" is a strict substate of "on-queue / awaiting review"** — *under the default
+ `invalidate` on-queue-exit policy* (see below). With that default it cannot coexist with
+ closed/merged/off-queue (those transitions invalidate the proposal), so it does *not*
+ multiply against the full `PRStatus` enum. This invariant is policy-dependent: the
+ `retain` policy deliberately relaxes it (a proposal may then persist off-queue), so
+ load-accounting and surfacing read "active proposals" independent of queue state and must
+ never *assume* proposed ⇒ on-queue.
+3. The live state is always **reconstructable from durable facts** — GitHub assignees + the
+ single active proposal. No derived state that can silently drift.
+4. A **proposal is never an assignee.** Every surface renders "proposed to X" visibly
+ distinct from "assigned to X"; the two are never conflated.
+5. Terminal proposals (accepted/declined/expired/superseded) are **history, not live state**.
+ They feed only the bounded expire-cooldown lookup; they never change "what state is this
+ PR in now."
+
+**On-queue-exit policy (resolved, but pluggable).** When a PR leaves the review queue before
+acceptance (author pushes → `awaiting-author`, or the PR is closed), the default is to
+**invalidate** the pending proposal (mark `superseded`/`expired`), preserving invariant #2.
+This is intentionally kept open to future change, because opinions may differ:
+
+- A single centralized predicate — `proposal_validity(pr, proposal, *, now)` in
+ `analyzer/services/` — is the **sole authority** on "is this pending proposal still live,
+ and if not, why." It is consulted by all three call sites that need a consistent answer:
+ the expiry/reconcile sweep, sync-time reconciliation (when a human/self-assignee lands),
+ and the console accept-time re-validation. No duplicated "still valid?" logic to drift.
+- The behavior is selected by one setting, `ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT` ∈
+ `{invalidate (default), retain}`, read *inside* that predicate. Flipping to "let it ride"
+ later is a one-setting change; the accept handler and sweeps already route through the same
+ predicate, so no structural rework is required. Centralizing the predicate is good factoring
+ regardless of the policy, so this extensibility adds negligible burden.
+
+### New model: `analyzer.AssignmentProposal`
+
+- `repository` FK → `core.Repository`
+- `pr_number` (int)
+- `reviewer_login` (str) — matches `ReviewerOptOut` / snapshot login keying
+- `snapshot` FK → `ReviewerAssignmentSnapshot` (`on_delete=SET_NULL`, provenance)
+- `state`: `proposed` / `accepted` / `declined` / `expired` / `superseded`
+- `proposed_at`, `expires_at`, `decided_at` (nullable), `notified_at` (nullable)
+- `decided_via`: `console` / `auto_expire` / `sync_superseded` (and future `command`)
+- `created_at` / `updated_at`
+- **Partial unique index on `(repository, pr_number) WHERE state='proposed'`** — enforces
+ at most one active proposal per PR (the "one at a time" invariant) race-safely at the DB
+ level. Creation uses `get_or_create` / conflict-aware insert per the "Concurrent Writers
+ and Unique Keys" rules in `qb_site/AGENTS.md` (never check-then-create).
+- Index on `(repository, reviewer_login, state)` (plus `decided_at`) for the console query
+ and the builder's cooldown lookup, keeping both index-served as history accumulates.
+
+Reuses (no new models needed for these): `ReviewerAssignmentApplication` (accept audit),
+`ReviewerOptOut` (decline enforcement), the DM machinery, the `assign_pr` token, and
+`core.User` identity.
+
+### New config field: `core.ReviewerPreference.assignment_acceptance`
+
+- `CharField(choices=[("auto","auto"),("confirm","confirm")], default="confirm")`.
+- **Data migration backfills all existing rows to `auto`** → grandfathered, zero
+ disruption on day one.
+- New rows inherit the `confirm` default → new reviewers opt into the gate by default.
+- **Invariant:** every `ReviewerPreference` creation path must let the default apply, and
+ `import_reviewer_topics` must set `assignment_acceptance` **only on create**, never in
+ the `update_or_create` update `defaults`, or a re-import would flip existing reviewers.
+- A bulk admin action / management command can set the mode across a selection of
+ reviewers, preserving the ability to flip the whole pool later by community decision.
+
+### Config axes and behavior matrix
+
+Three orthogonal per-reviewer axes (for an eligible reviewer, `auto_assign=true`). Crucially,
+`notifications_enabled` governs only the **optional attention nudges** (nudge / at-threshold /
+newly-assigned FYI DMs). **Confirmation prompts are transactional and are NOT gated by it** —
+they follow `assignment_acceptance=confirm`, because the prompt is the mechanism of the mode
+you opted into. Choosing `confirm` *is* the opt-in to receiving proposal prompts; a reviewer
+who wants true silence chooses `auto` (assigned directly, no clicks).
+
+| `assignment_acceptance` | attention nudges (`notifications_enabled`) | behavior |
+| --- | --- | --- |
+| `auto` | on | Direct-assign on GitHub (046 path) + optional "you were assigned" DM. = today. |
+| `auto` | off | Direct-assign, silently. = today. (Serves "notifications off but still auto-assigned".) |
+| `confirm` | on | Propose → digest DM with console link → accept → assign, **plus** the optional nudges. |
+| `confirm` | off | Propose → digest DM → accept → assign. Still prompted (proposals aren't gated by `notifications_enabled`); just no optional nudges. ← "opt into confirmations while opting out of other notifications." |
+
+`auto_assign=false` → never auto-assigned; the gate is irrelevant. **Fall back to `auto`
+only when the reviewer is genuinely unreachable** — no Zulip link (`core.User.zulip_user_id`
+is null) — *not* merely because `notifications_enabled` is off. This decoupling matches doc
+028's precedent that auto-unassign *enforcement* runs independently of `notifications_enabled`
+(essential actions are not gated by the notification opt-in).
+
+### Reviewer console
+
+- **Auth:** GitHub OAuth → a persistent **reviewer session** (Django session framework;
+ store the resolved `core.User`). Reuses `github_oauth.GitHubOAuthClient` and the existing
+ callback plumbing. The DM contains a **plain, stable console URL** — no token, nothing to
+ expire, bookmarkable. Standard login-redirect (`?next=`) lets a DM deep-link to a PR row.
+- Console access is keyed on the authenticated `github_login` (which is exactly what
+ proposals are keyed on), so console access and notification reach are independent: a
+ reviewer can self-manage on the console even without a Zulip link.
+- **Phase-1 view:**
+ - *Pending proposals*: PR, matched topic labels, "why you", expiry countdown → **Accept**
+ / **Decline**.
+ - *Current assignments*: reuse the existing unassign capability (or read-only if we want
+ Phase 1 minimal).
+ - *Load summary*: "3 accepted + 2 pending / capacity 10" — makes "pending counts toward
+ load" visible.
+- **Accept handler:** re-validate live (PR still open, still unassigned, reviewer still
+ eligible, not opted-out) → `GitHubAssignmentClient.assign` (`assign_pr` token) →
+ `proposal=accepted` → record `ReviewerAssignmentApplication` → enqueue `syncer.sync_pr`.
+- **Decline handler:** `proposal=declined` + upsert active `ReviewerOptOut(repository,
+ pr_number, reviewer_login)`.
+- Every POST **re-validates against live state**; a proposal that is no longer actionable
+ (merged/closed/assigned-elsewhere/expired) renders a clear "no longer available, here's
+ why" instead of an error.
+
+### Decline vs. expire semantics
+
+- **Decline** = explicit "not this PR" → permanent per-PR `ReviewerOptOut` (reuses existing
+ enforcement; the builder already excludes opted-out reviewers).
+- **Expire** (silent timeout) = soft. The proposal row stays `expired`; the builder applies
+ a **cooldown**: skip a reviewer for a PR if they have an `expired` proposal for it within
+ the last `PROPOSAL_EXPIRE_COOLDOWN_DAYS` (default ~14). *Not* a permanent opt-out — a
+ reviewer who was simply away gets another shot later, while the PR still advances to
+ other candidates now. This keeps `ReviewerOptOut` meaning "explicit no".
+
+### Builder / engine integration
+
+Three additions to the candidate/load computation (integration layer in
+`reviewer_assignment.py`, seams in the engine kept pure/unit-testable):
+
+1. **Candidate exclusion:** drop PRs that have an active (`proposed`) `AssignmentProposal`
+ from the candidate pool (don't re-propose the same PR, don't propose it to a second
+ reviewer). Extends `_filter_prs_without_active_assignee`.
+2. **Load contribution:** a reviewer's active `proposed` proposals add to their weighted
+ load (weight `PROPOSAL_PENDING_LOAD_WEIGHT`, default `1.0` — same as `AwaitingReview`; a
+ proposal occupies a slot). Feeds `collect_assignment_statistics` / `build_reviewer_catalog`.
+3. **Cooldown exclusion:** exclude a reviewer for a PR if they have a recent `expired`
+ proposal (per the cooldown above).
+
+### Surfacing: board + `pr-info` (required in Phase 1)
+
+Because a `confirm`-mode PR has **no GitHub assignee during the pending window**, the
+queueboard is the only place the state is visible. Both surfaces draw the assignment-axis
+state from the same `AssignmentProposal` data:
+
+- **Board:** the queue snapshot (`analyzer/services/queueboard_snapshot.py`) distinguishes
+ *unassigned* / *proposed to X (expires …)* / *assigned* on the dashboard/API.
+- **Single PR:** `analyzer.services.pr_info.get_pr_queue_info` → `PRQueueInfo` gains the same
+ state, so the Zulip **`pr-info`** command and the API render it identically.
+
+Nothing is ever written to the GitHub PR page.
+
+**Surface history by audience** (see also the model's terminal states):
+- Default views (board, `pr-info`) show only the **current** assignment state, optionally a
+ compact "(N prior proposals)" hint — casual viewers stay uncluttered.
+- **Full proposal history lives in Django admin** (the `AssignmentProposal` changelist,
+ `ReadOnlyAdmin`) and an optional expanded `pr-info` mode — operators get the depth.
+- Rationale: a visible "asked A (declined), asked B (expired), asked C (pending)" trail makes
+ the effort to place a PR legible, which is the community's actual complaint ("did anyone
+ get asked?"). Because history is append-only and never feeds live-state derivation (except
+ the bounded cooldown), it cannot make "what state is this PR in?" ambiguous.
+
+### History (retained, not expired)
+
+The `AssignmentProposal` terminal rows *are* the proposal history (the *assignment* timeline
+is already durable via `PRTimelineEvent` ASSIGNED/UNASSIGNED, `ReviewerAssignmentApplication`,
+and `ReviewerOptOut`). **Keep proposal history indefinitely — no expiry/cleanup task**, at
+least initially:
+
+- Volume is low and append-only — on the order of tens of terminal rows per PR over its
+ whole lifetime, bounded across the repo. Postgres handles this trivially; there is no
+ storage pressure that would justify pruning.
+- The history has standalone analytical value (accept rate, time-to-accept, decline/expire
+ patterns per reviewer; inputs to future window/capacity tuning and the deferred throughput
+ cap). Deleting it forecloses analyses we may want later.
+- No correctness dependency on pruning: the expire-cooldown is a bounded *query window* over
+ recent `expired` rows, not a deletion policy, so keeping older rows is harmless.
+
+The model must therefore be classified for **backup retention** in `scripts/backup_policy.py`
+(durable history, not a truncate-on-restore table). If volume ever surprises us, a cleanup
+task can be added later without redesign — but it is explicitly out of Phase 1. Unifying
+proposal history with the assignment timeline into one "assignment activity" view is likewise
+a later step.
+
+### Settings / flags (add to BOTH `settings/base.py` and `.env.example`)
+
+- `ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED` (bool; master kill switch for the gate)
+- `ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED` (bool; DM delivery)
+- `ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED` (bool; the GitHub mutation)
+- `ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN` (bool; log-only)
+- `ANALYZER_ASSIGNMENT_PROPOSAL_WINDOW_DAYS` (default 7; per-reviewer overridable via
+ `notification_settings`, honoring "≥7, weekly-aligned")
+- `ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS` (default 14)
+- `ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT` (default 1.0)
+- `ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT` (`invalidate` default / `retain`; read inside
+ the `proposal_validity` predicate — see the state model)
+- Beat entries for `propose_reviewer_assignments` and the expiry sweep (clock-or-period,
+ matching the reviewer-attention pattern). No cleanup task — proposal history is retained
+ indefinitely (see "History"). Rollout follows the 028 discipline: *propose → deliver →
+ assign-on-accept*, each independently toggleable, plus dry-run.
+
+## Subtleties / Invariants
+
+- The snapshot is advisory; **re-validate every assignment at accept time** (mirrors 046).
+- The PR assignment state model (see above) is load-bearing for comprehensibility: exactly
+ one of {unassigned, proposed, assigned}; a **proposal is never an assignee** on any
+ surface; terminal proposals are history, not live state. Under the default `invalidate`
+ policy "proposed" is strictly nested in "awaiting review"; the on-queue-exit behavior is
+ centralized in the single `proposal_validity` predicate and selectable via
+ `ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT`, so a future flip to `retain` needs no rework.
+- **At most one active proposal per PR** (DB partial-unique enforced).
+- A pending proposal **counts toward load** and **excludes the PR from re-proposal**.
+- **No GitHub write until accept**; **never** a GitHub PR-page comment or label for this
+ feature.
+- `auto`-mode reviewers use the **unchanged 046 path**; the gate is a per-reviewer branch,
+ not a repo-wide replacement.
+- **Confirmation prompts are transactional and are NOT gated by `notifications_enabled`**;
+ that flag governs only the optional attention nudges. A `confirm` reviewer falls back to
+ `auto` only when genuinely unreachable (no Zulip link at all) — never merely because
+ nudges are muted.
+- New `ReviewerPreference` default `confirm`; existing rows backfilled `auto`; the importer
+ never overwrites `assignment_acceptance` on update.
+- Concurrent-writer safety everywhere (`get_or_create` / conflict-aware insert / savepoint
+ per `qb_site/AGENTS.md`); sweeps contain per-item `IntegrityError`.
+- Every GitHub mutation enqueues `syncer.sync_pr` for convergence.
+- `sync_pr` reconciliation: if a human/self-assignee lands on a PR with a `proposed`
+ proposal, mark it `superseded` (do not fight the human).
+- De-dupe the proposal "please accept" DM against the attention "newly assigned" ping for
+ the same event.
+- Keep the existing 21-day auto-unassign backstop for *accepted-but-then-stale* PRs; for
+ `confirm` reviewers the clock starts at acceptance.
+
+## Implementation Plan (Chunks)
+
+1. **Config field:** `ReviewerPreference.assignment_acceptance` + data migration
+ (existing → `auto`), admin `list_display`/`list_filter`, importer create-only handling,
+ bulk admin action / management command to set mode. Unit tests for default behavior.
+2. **Model:** `analyzer.AssignmentProposal` + migration (partial-unique index), admin
+ (`ReadOnlyAdmin`), backup-policy coverage classifying it as **durable retained history**
+ (`scripts/backup_policy.py`).
+3. **Builder/engine integration:** candidate exclusion, pending-load contribution, cooldown
+ exclusion; pure-engine seams with unit tests (assignability, scarcity, cooldown).
+4. **Propose service + task + expiry sweep:** `propose_assignments_for_repo` (per-reviewer
+ branch: `auto` → 046 path; `confirm` reachable → proposal; `confirm` unreachable →
+ fallback), `analyzer.propose_reviewer_assignments` task, expiry sweep task, settings/
+ flags/beat. Dry-run + flags. Unit + task tests.
+5. **Notification:** per-reviewer digest DM (reuse `ZulipClient` + a dedupe record or
+ `notified_at`), de-duped vs the attention "newly assigned" ping.
+6. **Console:** GitHub-OAuth reviewer session, list view, accept/decline handlers
+ (accept reuses the 046 mutation path), templates, live re-validation, frontend tests.
+7. **Surfacing:** the {unassigned, proposed, assigned} state in the queue snapshot and in
+ `pr_info`/`PRQueueInfo` (so the dashboard, API, and the Zulip `pr-info` command render it
+ identically, distinct from GitHub assignees); the `AssignmentProposal` admin changelist
+ for full (retained) history. No cleanup task in Phase 1.
+8. **Docs:** update `qb_site/analyzer/AGENTS.md` (task surface), `qb_site/zulip_bot/AGENTS.md`
+ and `qb_site/core/*` notes as needed, and the root pointer; converge this living plan
+ toward a final record once shipped.
+
+## Validation Plan
+
+- tests:
+ - config default: existing rows backfilled to `auto`; a freshly created preference is
+ `confirm`; importer re-run does not flip existing rows.
+ - engine/builder: pending proposal excludes the PR + adds load; declined → opt-out
+ excludes; expired → cooldown excludes then re-allows after the window.
+ - propose task: per-mode branching, `confirm`-unreachable fallback, dedupe/idempotency,
+ dry-run, flag-off no-op, per-repo behavior.
+ - console: accept assigns + records + enqueues sync; decline opt-outs; stale/merged/
+ reassigned proposal renders "no longer available"; session auth required.
+ - state transitions: with `ON_QUEUE_EXIT=invalidate`, a pending proposal is invalidated
+ (superseded/expired) when the PR leaves the queue or is closed; with `retain`, it
+ persists and is acceptable off-queue — both routed through the `proposal_validity`
+ predicate; a human/self-assignee lands → `superseded`; a PR is never simultaneously
+ proposed and assigned.
+ - surfacing: the {unassigned, proposed, assigned} distinction appears in the snapshot
+ payload and in `PRQueueInfo`/`pr-info`, rendered distinctly from GitHub assignees.
+ - history: terminal proposal rows persist (no cleanup); the cooldown query still returns
+ only recent `expired` rows even with old history present.
+- manual:
+ - run `propose_reviewer_assignments --dry-run` against mathlib4 and inspect the would-
+ propose set vs a recent `automatic_assignments` snapshot.
+ - staging: end-to-end — receive a digest DM, log into the console via GitHub, accept,
+ confirm the assignee lands on GitHub and `ReviewerAssignmentApplication` records it,
+ and a second propose run is a no-op.
+- Full validation via `bash scripts/repo_check_compose.sh` (includes backup-policy
+ validation for the new model).
+
+## Alternatives (discarded / deferred)
+
+- **Post-assignment fast-confirm** (assign now, revoke fast if not accepted): cheaper, but
+ only shortens the ghost assignment rather than eliminating it; rejected in favor of the
+ true pre-assignment gate.
+- **Per-PR signed accept links** instead of a console: don't scale to multiple pending
+ proposals and suffer stale-link rot; superseded by the GitHub-login console.
+- **Token-bootstrapped session** for console auth: rejected — an automated notification
+ carrying a fast-expiring link is annoying; GitHub login gives a stable, bookmarkable URL.
+- **Hybrid (propose, then assign anyway if all candidates time out):** deferred; revisit
+ only if PRs sitting unproposed becomes a real problem in practice.
+- **Compute "new vs old" acceptance default from timestamps** rather than a stored column:
+ rejected as fragile; the value is materialized per row at creation.
+
+## Related Decisions
+
+- `020-reviewer-opt-outs-and-timeline-assignments.md`
+- `026-zulip-assign-unassign-and-github-app-tokens.md`
+- `027-github-app-operation-token-services.md`
+- `028-reviewer-queue-nudges-v1-daily-report.md`
+- `037-reviewer-assignment-policy-simulation-and-priority-planning.md`
+- `039-queue-ruleset-default-designation-and-snapshot-cache-keys.md`
+- `046-apply-reviewer-assignments-in-django.md`
+
+## Progress Notes
+
+- 2026-07-08: Initial plan drafted from design discussion. Decisions confirmed:
+ pre-assignment gate; per-reviewer `assignment_acceptance` (`auto`/`confirm`) orthogonal to
+ eligibility and notifications, with a full behavior matrix and `confirm`-unreachable
+ fallback to `auto`; GitHub-login reviewer console as the primary surface (stable URL in
+ the DM); decline → permanent per-PR opt-out, expire → soft cooldown; pending proposals
+ count toward load (weight 1.0) and exclude the PR from re-proposal; board must show the
+ "awaiting acceptance" state (no GitHub PR-page writes); rollout default = new accounts
+ `confirm`, existing accounts backfilled `auto`, with a bulk flip lever retained. Scope =
+ acceptance gate only (Phase 1); re-confirm-on-reentry, email channel, and throughput cap
+ deferred.
+- 2026-07-08: Dropped proposal-history expiry. Volume is low/append-only (~tens of rows per
+ PR lifetime) and the history has standalone analytical value, so `AssignmentProposal` rows
+ are **retained indefinitely** — no cleanup task or retention setting in Phase 1; the model
+ is classified as durable retained history in the backup policy. Cooldown correctness is
+ unaffected (it is a bounded query window, not a deletion policy). A cleanup task can be
+ added later if volume ever surprises us.
+- 2026-07-08: Resolved the on-queue-exit transition as **invalidate** (default) but kept it
+ pluggable: a single centralized `proposal_validity` predicate (consulted by the reconcile
+ sweep, sync-time reconciliation, and console accept-time re-validation) reads a new
+ `ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT` setting (`invalidate`/`retain`). Invariant #2
+ is now stated as policy-dependent so a future flip to `retain` won't contradict it;
+ load-accounting/surfacing read active proposals independent of queue state.
+- 2026-07-08: Added the explicit PR assignment state model ({unassigned, proposed, assigned}
+ as one intermediate value on the existing assignment axis, with five legibility invariants
+ and the "proposed is nested in awaiting-review" constraint) to address a
+ future-comprehensibility concern. Decided to surface proposal state in `pr-info`/
+ `PRQueueInfo` (same source as the board, distinct from GitHub assignees) and to keep
+ proposal history with 90-day retention via a cleanup task — current state in default views,
+ full history in admin. Open transition question recorded: invalidate a pending proposal
+ when the PR leaves the review queue (recommended) vs. let it ride.
+- 2026-07-08: Refined the notification model. Confirmation prompts are recategorized as
+ transactional and decoupled from `notifications_enabled` (which now governs only the
+ optional attention nudges), so a reviewer can opt out of nudges while staying in `confirm`
+ and still receiving proposal prompts — without a new per-reviewer setting. Fallback to
+ `auto` now triggers only when a reviewer is genuinely unreachable (no Zulip link). The
+ console-only `confirm` toggle (`confirmation_prompts_enabled`) is recorded as a deferred
+ YAGNI option.
From 88b57ec40db70fee311db10277679c099b34dd7a Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Wed, 8 Jul 2026 21:45:32 -0400
Subject: [PATCH 02/33] feat: add reviewer assignment_acceptance mode
(auto/confirm)
Chunk 1 of design doc 050 (reviewer assignment acceptance gate).
- ReviewerPreference.assignment_acceptance CharField (choices auto/confirm,
default confirm) + ACCEPTANCE_* constants.
- Migration 0007: AddField (future rows default confirm) + RunPython backfill
flipping all existing rows to auto, grandfathering current reviewers.
- Admin: field in list_display/list_filter + bulk actions to set the mode
(the pool-flip lever).
- Tests: field default, backfill function, and importer no-clobber invariant
(the importer never touches the field, so a re-import cannot flip an
existing reviewer's mode).
Update the living doc: mark Chunk 1 landed with a progress note.
Validation: 5 new tests + full core suite (55) green on Postgres;
makemigrations --check clean; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_014dQnn9K9BnaFA12a2jVNKy
---
...050-reviewer-assignment-acceptance-gate.md | 21 +++--
qb_site/core/admin.py | 14 +++-
...eviewerpreference_assignment_acceptance.py | 30 +++++++
qb_site/core/models/reviewer_preference.py | 18 +++++
.../test_reviewer_preference_acceptance.py | 81 +++++++++++++++++++
5 files changed, 158 insertions(+), 6 deletions(-)
create mode 100644 qb_site/core/migrations/0007_reviewerpreference_assignment_acceptance.py
create mode 100644 qb_site/core/tests/test_reviewer_preference_acceptance.py
diff --git a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
index b31cf4ae..d3c617cb 100644
--- a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
+++ b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
@@ -1,7 +1,7 @@
# Reviewer Assignment Acceptance Gate (Propose → Accept → Assign)
-> Status: Living implementation plan (not yet implemented). Drafted from a design
-> discussion; captures decisions, invariants, and a chunked build plan.
+> Status: Living implementation plan (in progress — Chunk 1 landed). Captures decisions,
+> invariants, and a chunked build plan; Progress Notes track what has shipped.
## Context
@@ -356,9 +356,9 @@ a later step.
## Implementation Plan (Chunks)
-1. **Config field:** `ReviewerPreference.assignment_acceptance` + data migration
- (existing → `auto`), admin `list_display`/`list_filter`, importer create-only handling,
- bulk admin action / management command to set mode. Unit tests for default behavior.
+1. ✅ **(landed)** **Config field:** `ReviewerPreference.assignment_acceptance` + data
+ migration (existing → `auto`), admin `list_display`/`list_filter`, importer create-only
+ handling, bulk admin action to set mode. Unit tests for default behavior.
2. **Model:** `analyzer.AssignmentProposal` + migration (partial-unique index), admin
(`ReadOnlyAdmin`), backup-policy coverage classifying it as **durable retained history**
(`scripts/backup_policy.py`).
@@ -435,6 +435,17 @@ a later step.
## Progress Notes
+- 2026-07-08: **Chunk 1 landed.** Added `ReviewerPreference.assignment_acceptance`
+ (`CharField(max_length=16, choices auto/confirm, default confirm)`) with `ACCEPTANCE_*`
+ constants; migration `core/0007` adds the field (so future rows → `confirm`) plus a
+ `RunPython` backfill flipping all existing rows to `auto` (reverse = noop). Admin gains the
+ column in `list_display`/`list_filter` and two bulk actions
+ (`set_acceptance_confirm`/`set_acceptance_auto`) as the pool-flip lever; a standalone
+ management command was deemed unnecessary. The importer create-only invariant holds **by
+ construction** (it never references the field and does not use `update_or_create`); a
+ regression test pins it. No backup-policy change (column add on the already-covered
+ `core_reviewerpreference` table). Validation: 5 new tests + full `core` suite (55) green on
+ dockerized Postgres, `makemigrations --check` clean, ruff clean.
- 2026-07-08: Initial plan drafted from design discussion. Decisions confirmed:
pre-assignment gate; per-reviewer `assignment_acceptance` (`auto`/`confirm`) orthogonal to
eligibility and notifications, with a full behavior matrix and `confirm`-unreachable
diff --git a/qb_site/core/admin.py b/qb_site/core/admin.py
index 3f4c0398..19b0d7b3 100644
--- a/qb_site/core/admin.py
+++ b/qb_site/core/admin.py
@@ -845,12 +845,13 @@ class ReviewerPreferenceAdmin(admin.ModelAdmin):
"user",
"maximum_capacity",
"auto_assign",
+ "assignment_acceptance",
"notifications_enabled",
"away_until",
"created_at",
"updated_at",
)
- list_filter = ("auto_assign", "notifications_enabled", "repository")
+ list_filter = ("auto_assign", "assignment_acceptance", "notifications_enabled", "repository")
search_fields = (
"user__github_login",
"repository__owner",
@@ -858,6 +859,17 @@ class ReviewerPreferenceAdmin(admin.ModelAdmin):
)
readonly_fields = ("created_at", "updated_at")
raw_id_fields = ("repository", "user")
+ actions = ("set_acceptance_confirm", "set_acceptance_auto")
+
+ @admin.action(description="Set assignment acceptance to 'confirm' (require acceptance)")
+ def set_acceptance_confirm(self, request, queryset):
+ updated = queryset.update(assignment_acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM)
+ self.message_user(request, f"Set {updated} reviewer preference(s) to 'confirm'.")
+
+ @admin.action(description="Set assignment acceptance to 'auto' (direct assign)")
+ def set_acceptance_auto(self, request, queryset):
+ updated = queryset.update(assignment_acceptance=ReviewerPreference.ACCEPTANCE_AUTO)
+ self.message_user(request, f"Set {updated} reviewer preference(s) to 'auto'.")
def get_urls(self): # type: ignore[override]
urls = super().get_urls()
diff --git a/qb_site/core/migrations/0007_reviewerpreference_assignment_acceptance.py b/qb_site/core/migrations/0007_reviewerpreference_assignment_acceptance.py
new file mode 100644
index 00000000..7c5feef7
--- /dev/null
+++ b/qb_site/core/migrations/0007_reviewerpreference_assignment_acceptance.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.2.6 on 2026-07-09 01:38
+
+from django.db import migrations, models
+
+
+def backfill_existing_to_auto(apps, schema_editor):
+ """Grandfather existing reviewers to the legacy direct-assign behavior.
+
+ The AddField default ("confirm") applies to rows created after this migration; every
+ row that already exists at migration time predates the acceptance gate, so it is set to
+ "auto" to keep current reviewers' experience unchanged (design doc 050).
+ """
+ ReviewerPreference = apps.get_model("core", "ReviewerPreference")
+ db_alias = schema_editor.connection.alias
+ ReviewerPreference.objects.using(db_alias).update(assignment_acceptance="auto")
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("core", "0006_repository_assignment_topic_label_pattern"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="reviewerpreference",
+ name="assignment_acceptance",
+ field=models.CharField(choices=[("auto", "auto"), ("confirm", "confirm")], default="confirm", max_length=16),
+ ),
+ migrations.RunPython(backfill_existing_to_auto, migrations.RunPython.noop),
+ ]
diff --git a/qb_site/core/models/reviewer_preference.py b/qb_site/core/models/reviewer_preference.py
index fb5382c0..d7f8a3cf 100644
--- a/qb_site/core/models/reviewer_preference.py
+++ b/qb_site/core/models/reviewer_preference.py
@@ -21,13 +21,31 @@ class ReviewerPreference(TimestampedModel):
- ``conflict_of_interest``: list of GitHub handles this reviewer should not be auto-assigned to.
- ``notifications_enabled``: whether reviewer receives queue nudge notifications.
- ``notification_settings``: extensible JSON settings for notification policy (for example X/Y thresholds).
+ - ``assignment_acceptance``: ``auto`` (direct-assign like today) or ``confirm`` (propose and require
+ the reviewer to accept before the assignment is executed). New rows default to ``confirm``;
+ existing rows are backfilled to ``auto`` (see design doc 050).
"""
+ ACCEPTANCE_AUTO = "auto"
+ ACCEPTANCE_CONFIRM = "confirm"
+ ACCEPTANCE_CHOICES = [
+ (ACCEPTANCE_AUTO, "auto"),
+ (ACCEPTANCE_CONFIRM, "confirm"),
+ ]
+
repository = models.ForeignKey(Repository, on_delete=models.CASCADE, related_name="reviewer_preferences")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reviewer_preferences")
maximum_capacity = models.PositiveIntegerField(default=10)
auto_assign = models.BooleanField(default=True)
+ # Whether automatic assignment goes through the acceptance gate ("confirm") or assigns
+ # directly like the legacy behavior ("auto"). New reviewers default to "confirm"; existing
+ # reviewers are backfilled to "auto" by the accompanying data migration (design doc 050).
+ assignment_acceptance = models.CharField(
+ max_length=16,
+ choices=ACCEPTANCE_CHOICES,
+ default=ACCEPTANCE_CONFIRM,
+ )
away_until = models.DateTimeField(null=True, blank=True)
# Store as a JSON array of strings (label names). Examples: ["t-analysis", "tech debt"].
diff --git a/qb_site/core/tests/test_reviewer_preference_acceptance.py b/qb_site/core/tests/test_reviewer_preference_acceptance.py
new file mode 100644
index 00000000..d9000043
--- /dev/null
+++ b/qb_site/core/tests/test_reviewer_preference_acceptance.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import importlib
+import json
+import io
+from types import SimpleNamespace
+
+from django.apps import apps as global_apps
+from django.db import connection
+from django.test import TestCase
+
+from core.models import Repository, ReviewerPreference, User
+from core.services.reviewer_topics_importer import import_reviewer_topics
+
+REPO = "leanprover-community/mathlib4"
+
+
+def _make_repo() -> Repository:
+ return Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master", is_active=True)
+
+
+class AssignmentAcceptanceDefaultTest(TestCase):
+ """New reviewer accounts default to `confirm` (design doc 050)."""
+
+ def test_field_default_is_confirm(self):
+ field = ReviewerPreference._meta.get_field("assignment_acceptance")
+ self.assertEqual(field.default, ReviewerPreference.ACCEPTANCE_CONFIRM)
+
+ def test_new_preference_defaults_to_confirm(self):
+ repo = _make_repo()
+ user = User.objects.create(github_login="brand-new-reviewer", is_active=True)
+ pref = ReviewerPreference.objects.create(repository=repo, user=user)
+ self.assertEqual(pref.assignment_acceptance, ReviewerPreference.ACCEPTANCE_CONFIRM)
+
+
+class AssignmentAcceptanceBackfillTest(TestCase):
+ """The data migration grandfathers existing reviewers to `auto`."""
+
+ def test_backfill_function_sets_all_rows_to_auto(self):
+ repo = _make_repo()
+ # Rows created here take the model default (`confirm`), standing in for pre-existing rows.
+ for login in ("rev-a", "rev-b"):
+ user = User.objects.create(github_login=login, is_active=True)
+ ReviewerPreference.objects.create(repository=repo, user=user)
+ self.assertTrue(all(p.assignment_acceptance == "confirm" for p in ReviewerPreference.objects.all()))
+
+ migration_mod = importlib.import_module("core.migrations.0007_reviewerpreference_assignment_acceptance")
+ migration_mod.backfill_existing_to_auto(global_apps, SimpleNamespace(connection=connection))
+
+ self.assertTrue(all(p.assignment_acceptance == "auto" for p in ReviewerPreference.objects.all()))
+
+
+class AssignmentAcceptanceImporterTest(TestCase):
+ """The reviewer-topics importer must never overwrite an existing reviewer's acceptance mode."""
+
+ def _import(self, entries: list[dict]) -> None:
+ import_reviewer_topics(repo=REPO, file_obj=io.StringIO(json.dumps(entries)))
+
+ def test_reimport_does_not_flip_existing_acceptance(self):
+ repo = _make_repo()
+ user = User.objects.create(github_login="grandfathered", is_active=True)
+ pref = ReviewerPreference.objects.create(
+ repository=repo,
+ user=user,
+ assignment_acceptance=ReviewerPreference.ACCEPTANCE_AUTO,
+ maximum_capacity=5,
+ )
+
+ # Re-import touches an unrelated field (capacity) but must leave acceptance alone.
+ self._import([{"github_handle": "grandfathered", "maximum_capacity": 9}])
+
+ pref.refresh_from_db()
+ self.assertEqual(pref.maximum_capacity, 9)
+ self.assertEqual(pref.assignment_acceptance, ReviewerPreference.ACCEPTANCE_AUTO)
+
+ def test_import_creates_new_reviewer_as_confirm(self):
+ # A brand-new handle imported after the migration inherits the `confirm` default.
+ self._import([{"github_handle": "freshly-imported", "top_level": ["t-algebra"]}])
+
+ pref = ReviewerPreference.objects.get(user__github_login="freshly-imported")
+ self.assertEqual(pref.assignment_acceptance, ReviewerPreference.ACCEPTANCE_CONFIRM)
From ed1e852cdd737b6bfa7a66cedacd7296e9f81d54 Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Wed, 8 Jul 2026 21:53:07 -0400
Subject: [PATCH 03/33] feat: add AssignmentProposal model for the acceptance
gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Chunk 2 of design doc 050.
- analyzer.AssignmentProposal (migration 0031): repository/pr_number/
reviewer_login/snapshot, state (proposed/accepted/declined/expired/
superseded), expires_at/decided_at/notified_at/decided_via. created_at is
the proposal time (no redundant proposed_at).
- Partial unique constraint enforcing one active (proposed) proposal per PR
— the "one at a time" invariant, race-safe at the DB level.
- ReadOnlyAdmin registration.
- Backup policy: analyzer_assignmentproposal added to BACKUP + TRUNCATE
(retained in real backups, truncated from the sanitized public dump like
the sibling reviewer tables).
- Tests: defaults, one-active-per-PR enforcement, terminal states don't block
re-proposal, distinct-PR proposals allowed.
Update the living doc: mark Chunk 2 landed.
Validation: 4 model tests + full analyzer suite (403) green on Postgres;
backup-policy validator passes; makemigrations --check clean; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_014dQnn9K9BnaFA12a2jVNKy
---
...050-reviewer-assignment-acceptance-gate.md | 23 +++--
qb_site/analyzer/admin.py | 34 ++++++++
.../migrations/0031_assignmentproposal.py | 80 ++++++++++++++++++
qb_site/analyzer/models/__init__.py | 1 +
.../analyzer/models/assignment_proposal.py | 83 +++++++++++++++++++
qb_site/analyzer/tests/models/__init__.py | 0
.../tests/models/test_assignment_proposal.py | 57 +++++++++++++
scripts/backup_policy.py | 4 +
8 files changed, 277 insertions(+), 5 deletions(-)
create mode 100644 qb_site/analyzer/migrations/0031_assignmentproposal.py
create mode 100644 qb_site/analyzer/models/assignment_proposal.py
create mode 100644 qb_site/analyzer/tests/models/__init__.py
create mode 100644 qb_site/analyzer/tests/models/test_assignment_proposal.py
diff --git a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
index d3c617cb..3e4e0a71 100644
--- a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
+++ b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
@@ -1,6 +1,6 @@
# Reviewer Assignment Acceptance Gate (Propose → Accept → Assign)
-> Status: Living implementation plan (in progress — Chunk 1 landed). Captures decisions,
+> Status: Living implementation plan (in progress — Chunks 1–2 landed). Captures decisions,
> invariants, and a chunked build plan; Progress Notes track what has shipped.
## Context
@@ -164,7 +164,8 @@ This is intentionally kept open to future change, because opinions may differ:
- `reviewer_login` (str) — matches `ReviewerOptOut` / snapshot login keying
- `snapshot` FK → `ReviewerAssignmentSnapshot` (`on_delete=SET_NULL`, provenance)
- `state`: `proposed` / `accepted` / `declined` / `expired` / `superseded`
-- `proposed_at`, `expires_at`, `decided_at` (nullable), `notified_at` (nullable)
+- `expires_at`, `decided_at` (nullable), `notified_at` (nullable). `created_at` (from
+ `TimestampedModel`) is the proposal time — no separate `proposed_at`.
- `decided_via`: `console` / `auto_expire` / `sync_superseded` (and future `command`)
- `created_at` / `updated_at`
- **Partial unique index on `(repository, pr_number) WHERE state='proposed'`** — enforces
@@ -359,9 +360,9 @@ a later step.
1. ✅ **(landed)** **Config field:** `ReviewerPreference.assignment_acceptance` + data
migration (existing → `auto`), admin `list_display`/`list_filter`, importer create-only
handling, bulk admin action to set mode. Unit tests for default behavior.
-2. **Model:** `analyzer.AssignmentProposal` + migration (partial-unique index), admin
- (`ReadOnlyAdmin`), backup-policy coverage classifying it as **durable retained history**
- (`scripts/backup_policy.py`).
+2. ✅ **(landed)** **Model:** `analyzer.AssignmentProposal` + migration (partial-unique
+ index), admin (`ReadOnlyAdmin`), backup-policy coverage (BACKUP + TRUNCATE, like the
+ sibling reviewer tables) (`scripts/backup_policy.py`).
3. **Builder/engine integration:** candidate exclusion, pending-load contribution, cooldown
exclusion; pure-engine seams with unit tests (assignability, scarcity, cooldown).
4. **Propose service + task + expiry sweep:** `propose_assignments_for_repo` (per-reviewer
@@ -435,6 +436,18 @@ a later step.
## Progress Notes
+- 2026-07-08: **Chunk 2 landed.** Added `analyzer.AssignmentProposal` (migration
+ `analyzer/0031`) with the `STATE_*`/`DECIDED_VIA_*` choices, the **partial unique
+ constraint** `an_ap_one_active_proposal_per_pr` on `(repository, pr_number) WHERE
+ state='proposed'`, and indexes on `(repository, reviewer_login, state, decided_at)` and
+ `(repository, pr_number, state)`. `created_at` (from `TimestampedModel`) is the proposal
+ time — no redundant `proposed_at` column. Registered a `ReadOnlyAdmin`; added
+ `analyzer_assignmentproposal` to `BACKUP_TABLES` + `TRUNCATE_TABLES` (carries
+ `reviewer_login`, retained in real backups, truncated from the sanitized public dump like
+ its siblings). Validation: 4 model tests (defaults; one-active-per-PR enforced;
+ terminal states don't block re-proposal; distinct-PR proposals allowed) + full `analyzer`
+ suite (403) green on Postgres; backup-policy validator passes; `makemigrations --check`
+ clean; ruff clean.
- 2026-07-08: **Chunk 1 landed.** Added `ReviewerPreference.assignment_acceptance`
(`CharField(max_length=16, choices auto/confirm, default confirm)`) with `ACCEPTANCE_*`
constants; migration `core/0007` adds the field (so future rows → `confirm`) plus a
diff --git a/qb_site/analyzer/admin.py b/qb_site/analyzer/admin.py
index e7263a28..d58bca29 100644
--- a/qb_site/analyzer/admin.py
+++ b/qb_site/analyzer/admin.py
@@ -24,6 +24,7 @@
ReviewerAttentionDailyRun,
ReviewerAttentionNotificationRecord,
ReviewerAttentionAutoUnassignRecord,
+ AssignmentProposal,
)
from analyzer.tasks.queueboard_snapshot import build_queueboard_snapshot
from analyzer.services.reviewer_opt_out_backfill import backfill_reviewer_opt_outs
@@ -434,6 +435,39 @@ class ReviewerAssignmentApplicationAdmin(ReadOnlyAdmin):
)
+@admin.register(AssignmentProposal)
+class AssignmentProposalAdmin(ReadOnlyAdmin):
+ list_display = (
+ "id",
+ "repository",
+ "pr_number",
+ "reviewer_login",
+ "state",
+ "expires_at",
+ "decided_at",
+ "decided_via",
+ "notified_at",
+ "created_at",
+ )
+ list_filter = ("state", "decided_via", "repository")
+ date_hierarchy = "created_at"
+ search_fields = ("repository__owner", "repository__name", "reviewer_login", "pr_number")
+ raw_id_fields = ("repository", "snapshot")
+ readonly_fields = (
+ "repository",
+ "pr_number",
+ "reviewer_login",
+ "snapshot",
+ "state",
+ "expires_at",
+ "decided_at",
+ "notified_at",
+ "decided_via",
+ "created_at",
+ "updated_at",
+ )
+
+
@admin.register(AreaStatsSnapshot)
class AreaStatsSnapshotAdmin(ReadOnlyAdmin):
list_display = ("repository", "cache_key", "generated_at", "area_count")
diff --git a/qb_site/analyzer/migrations/0031_assignmentproposal.py b/qb_site/analyzer/migrations/0031_assignmentproposal.py
new file mode 100644
index 00000000..3cc6b7ed
--- /dev/null
+++ b/qb_site/analyzer/migrations/0031_assignmentproposal.py
@@ -0,0 +1,80 @@
+# Generated by Django 5.2.6 on 2026-07-09 01:49
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("analyzer", "0030_reviewerassignmentapplication"),
+ ("core", "0007_reviewerpreference_assignment_acceptance"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AssignmentProposal",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ ("pr_number", models.PositiveIntegerField()),
+ ("reviewer_login", models.CharField(max_length=255)),
+ (
+ "state",
+ models.CharField(
+ choices=[
+ ("proposed", "Proposed"),
+ ("accepted", "Accepted"),
+ ("declined", "Declined"),
+ ("expired", "Expired"),
+ ("superseded", "Superseded"),
+ ],
+ default="proposed",
+ max_length=16,
+ ),
+ ),
+ ("expires_at", models.DateTimeField()),
+ ("decided_at", models.DateTimeField(blank=True, null=True)),
+ ("notified_at", models.DateTimeField(blank=True, null=True)),
+ (
+ "decided_via",
+ models.CharField(
+ blank=True,
+ choices=[("console", "Console"), ("auto_expire", "Auto-expire"), ("sync_superseded", "Sync superseded")],
+ default="",
+ max_length=32,
+ ),
+ ),
+ (
+ "repository",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, related_name="assignment_proposals", to="core.repository"
+ ),
+ ),
+ (
+ "snapshot",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="proposals",
+ to="analyzer.reviewerassignmentsnapshot",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["repository", "pr_number", "-id"],
+ "indexes": [
+ models.Index(fields=["repository", "reviewer_login", "state", "decided_at"], name="an_ap_reviewer_state_idx"),
+ models.Index(fields=["repository", "pr_number", "state"], name="an_ap_pr_state_idx"),
+ ],
+ "constraints": [
+ models.UniqueConstraint(
+ condition=models.Q(("state", "proposed")),
+ fields=("repository", "pr_number"),
+ name="an_ap_one_active_proposal_per_pr",
+ )
+ ],
+ },
+ ),
+ ]
diff --git a/qb_site/analyzer/models/__init__.py b/qb_site/analyzer/models/__init__.py
index 0f0048ea..7521aff7 100644
--- a/qb_site/analyzer/models/__init__.py
+++ b/qb_site/analyzer/models/__init__.py
@@ -13,6 +13,7 @@
from .reviewer_assignment_application import ReviewerAssignmentApplication # noqa: F401
from .area_stats_snapshot import AreaStatsSnapshot # noqa: F401
from .reviewer_opt_out import ReviewerOptOut # noqa: F401
+from .assignment_proposal import AssignmentProposal # noqa: F401
from .reviewer_attention_run_state import (
ReviewerAttentionAutoUnassignRecord, # noqa: F401
ReviewerAttentionDailyRun, # noqa: F401
diff --git a/qb_site/analyzer/models/assignment_proposal.py b/qb_site/analyzer/models/assignment_proposal.py
new file mode 100644
index 00000000..85c27158
--- /dev/null
+++ b/qb_site/analyzer/models/assignment_proposal.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from django.db import models
+
+from core.models import Repository
+from core.models.base import TimestampedModel
+
+
+class AssignmentProposal(TimestampedModel):
+ """A proposed reviewer assignment awaiting acceptance (design doc 050).
+
+ In ``confirm`` mode a reviewer is *proposed* for a PR rather than assigned directly: the
+ GitHub assignment is executed only once the reviewer accepts on the console. This model is
+ the single active/terminal record of that lifecycle and the source for the board /
+ ``pr-info`` "proposed" state and for proposal history/analytics.
+
+ Invariants (see design doc 050):
+ - At most one active (``proposed``) proposal per PR, enforced by a partial unique
+ constraint. "One at a time; advance to the next candidate on decline/expire" then falls
+ out of the daily recompute + exclude loop.
+ - ``created_at`` (from ``TimestampedModel``) is the proposal time; there is no separate
+ ``proposed_at``.
+ - Terminal rows (accepted/declined/expired/superseded) are retained history, not live
+ state; history is kept indefinitely (no cleanup task).
+ """
+
+ STATE_PROPOSED = "proposed"
+ STATE_ACCEPTED = "accepted"
+ STATE_DECLINED = "declined"
+ STATE_EXPIRED = "expired"
+ STATE_SUPERSEDED = "superseded"
+ STATE_CHOICES = [
+ (STATE_PROPOSED, "Proposed"),
+ (STATE_ACCEPTED, "Accepted"),
+ (STATE_DECLINED, "Declined"),
+ (STATE_EXPIRED, "Expired"),
+ (STATE_SUPERSEDED, "Superseded"),
+ ]
+
+ DECIDED_VIA_CONSOLE = "console"
+ DECIDED_VIA_AUTO_EXPIRE = "auto_expire"
+ DECIDED_VIA_SYNC_SUPERSEDED = "sync_superseded"
+ DECIDED_VIA_CHOICES = [
+ (DECIDED_VIA_CONSOLE, "Console"),
+ (DECIDED_VIA_AUTO_EXPIRE, "Auto-expire"),
+ (DECIDED_VIA_SYNC_SUPERSEDED, "Sync superseded"),
+ ]
+
+ repository = models.ForeignKey(Repository, on_delete=models.CASCADE, related_name="assignment_proposals")
+ pr_number = models.PositiveIntegerField()
+ reviewer_login = models.CharField(max_length=255)
+ snapshot = models.ForeignKey(
+ "analyzer.ReviewerAssignmentSnapshot",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="proposals",
+ )
+ state = models.CharField(max_length=16, choices=STATE_CHOICES, default=STATE_PROPOSED)
+ # The acceptance deadline; a still-`proposed` row past this is expired by the sweep.
+ expires_at = models.DateTimeField()
+ decided_at = models.DateTimeField(null=True, blank=True)
+ notified_at = models.DateTimeField(null=True, blank=True)
+ decided_via = models.CharField(max_length=32, choices=DECIDED_VIA_CHOICES, blank=True, default="")
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ fields=["repository", "pr_number"],
+ condition=models.Q(state="proposed"), # AssignmentProposal.STATE_PROPOSED
+ name="an_ap_one_active_proposal_per_pr",
+ ),
+ ]
+ indexes = [
+ models.Index(fields=["repository", "reviewer_login", "state", "decided_at"], name="an_ap_reviewer_state_idx"),
+ models.Index(fields=["repository", "pr_number", "state"], name="an_ap_pr_state_idx"),
+ ]
+ ordering = ["repository", "pr_number", "-id"]
+
+ def __str__(self) -> str: # pragma: no cover - simple representation
+ return (
+ f"AssignmentProposal(repo={self.repository}, pr={self.pr_number}, reviewer={self.reviewer_login}, state={self.state})"
+ )
diff --git a/qb_site/analyzer/tests/models/__init__.py b/qb_site/analyzer/tests/models/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/qb_site/analyzer/tests/models/test_assignment_proposal.py b/qb_site/analyzer/tests/models/test_assignment_proposal.py
new file mode 100644
index 00000000..5ca3c794
--- /dev/null
+++ b/qb_site/analyzer/tests/models/test_assignment_proposal.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from datetime import timedelta
+
+from django.db import IntegrityError, transaction
+from django.test import TestCase
+from django.utils import timezone
+
+from analyzer.models import AssignmentProposal
+from core.models import Repository
+
+
+class AssignmentProposalModelTest(TestCase):
+ def setUp(self):
+ self.repo = Repository.objects.create(
+ owner="leanprover-community", name="mathlib4", default_branch="master", is_active=True
+ )
+
+ def _make(self, *, pr_number=1, reviewer_login="rev", state=AssignmentProposal.STATE_PROPOSED):
+ return AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=pr_number,
+ reviewer_login=reviewer_login,
+ state=state,
+ expires_at=timezone.now() + timedelta(days=7),
+ )
+
+ def test_defaults(self):
+ p = self._make()
+ self.assertEqual(p.state, AssignmentProposal.STATE_PROPOSED)
+ self.assertEqual(p.decided_via, "")
+ self.assertIsNone(p.decided_at)
+ self.assertIsNone(p.notified_at)
+ self.assertIsNotNone(p.created_at) # created_at is the proposal time
+
+ def test_one_active_proposal_per_pr_enforced(self):
+ self._make(pr_number=100, reviewer_login="alice")
+ # A second *proposed* proposal for the same PR (even a different reviewer) is rejected
+ # by the partial unique constraint — this is the "one at a time" invariant.
+ with self.assertRaises(IntegrityError):
+ with transaction.atomic():
+ self._make(pr_number=100, reviewer_login="bob")
+
+ def test_terminal_states_do_not_block_a_new_proposal(self):
+ # An expired proposal must not block re-proposing (advance to the next candidate),
+ # since the partial unique only covers state='proposed'.
+ self._make(pr_number=200, reviewer_login="alice", state=AssignmentProposal.STATE_EXPIRED)
+ p2 = self._make(pr_number=200, reviewer_login="bob")
+ self.assertEqual(p2.state, AssignmentProposal.STATE_PROPOSED)
+ # Terminal rows accumulate as history for the same PR.
+ self._make(pr_number=200, reviewer_login="alice", state=AssignmentProposal.STATE_DECLINED)
+ self.assertEqual(AssignmentProposal.objects.filter(repository=self.repo, pr_number=200).count(), 3)
+
+ def test_active_proposals_for_different_prs_allowed(self):
+ self._make(pr_number=301, reviewer_login="alice")
+ self._make(pr_number=302, reviewer_login="alice")
+ self.assertEqual(AssignmentProposal.objects.filter(state=AssignmentProposal.STATE_PROPOSED).count(), 2)
diff --git a/scripts/backup_policy.py b/scripts/backup_policy.py
index c3f3eb4d..cd9ecf04 100644
--- a/scripts/backup_policy.py
+++ b/scripts/backup_policy.py
@@ -7,6 +7,7 @@
BACKUP_TABLES: tuple[str, ...] = (
"analyzer_analyzerconvergencesnapshot",
"analyzer_areastatssnapshot",
+ "analyzer_assignmentproposal",
"analyzer_prdependency",
"analyzer_prdependencystate",
"analyzer_prqueuewindow",
@@ -83,6 +84,9 @@
"analyzer_reviewerattentionautounassignrecord",
# Reviewer assignment application audit/idempotency state
"analyzer_reviewerassignmentapplication",
+ # Reviewer assignment proposal lifecycle/history (design doc 050); carries reviewer_login,
+ # retained in real backups but truncated from the sanitized public dump like its siblings.
+ "analyzer_assignmentproposal",
# Operational snapshots/metrics caches
"analyzer_queuesnapshot",
"analyzer_reviewerassignmentsnapshot",
From 8acb4d21b47672c0296040201095fd3f7931831f Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Wed, 8 Jul 2026 22:30:52 -0400
Subject: [PATCH 04/33] feat: make reviewer assignment builder proposal-aware
Chunk 3 of design doc 050. Insert the three proposal-aware additions into the
reviewer assignment builder, all routed through a new shared
`_prepare_assignment_inputs` helper so the builder and the diagnostic
`build_reviewer_assignment_trace` cannot diverge:
- Candidate exclusion: withhold PRs that already have an active (`proposed`)
proposal from the candidate pool (one proposal per PR at a time).
- Pending-load contribution: a reviewer's active proposals add weighted load
via the new pure engine seam `add_pending_proposal_load` (weight
ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT, default 1.0; a proposal
occupies a capacity slot, open-list/total untouched).
- Cooldown exclusion: reviewers with a recently `expired` proposal for a PR
are excluded for it within ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS
(default 14; 0 disables), merged into the per-PR exclusion set alongside
opt-outs.
The integration is data-driven and ungated (no proposals => no-op), matching
the opt-out precedent; the master kill switch and propose task land in Chunk 4.
AreaStatsBuilder is intentionally left unchanged. Adds the two tuning settings
to settings/base.py and .env.example.
Validation: 9 new tests (2 pure-engine, 7 builder/trace) + full analyzer suite
(412) green on Postgres; makemigrations --check clean; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.env.example | 7 +
...050-reviewer-assignment-acceptance-gate.md | 26 ++-
.../analyzer/services/reviewer_assignment.py | 201 +++++++++++++----
.../services/reviewer_assignment_engine.py | 25 +++
.../services/test_reviewer_assignment.py | 202 +++++++++++++++++-
qb_site/qb_site/settings/base.py | 7 +
6 files changed, 423 insertions(+), 45 deletions(-)
diff --git a/.env.example b/.env.example
index 7329f42d..61900981 100644
--- a/.env.example
+++ b/.env.example
@@ -269,6 +269,13 @@ ANALYZER_REVIEWER_ASSIGNMENT_APPLY_DEDUPE_DAYS=7
# default bounds GitHub secondary-rate-limit exposure and drains a cutover backlog
# gradually (capped-over proposals are retried next run).
ANALYZER_REVIEWER_ASSIGNMENT_APPLY_MAX_PER_REPO=25
+# Reviewer assignment acceptance gate (design doc 050) — builder/engine tuning.
+# PENDING_LOAD_WEIGHT: weighted load a pending (proposed) proposal adds to the reviewer
+# (a proposal occupies a slot, like an AwaitingReview PR); it also excludes the PR from
+# re-proposal. EXPIRE_COOLDOWN_DAYS: skip a reviewer for a PR whose proposal they let
+# expire within this many days (a soft cooldown, not a permanent opt-out; 0 disables it).
+ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT=1.0
+ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS=14
# Reviewer attention daily sweep controls.
# ENABLED: run the sweep task at all (computes reports/counters).
ANALYZER_REVIEWER_ATTENTION_ENABLED=0
diff --git a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
index 3e4e0a71..eb7e4c02 100644
--- a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
+++ b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
@@ -1,6 +1,6 @@
# Reviewer Assignment Acceptance Gate (Propose → Accept → Assign)
-> Status: Living implementation plan (in progress — Chunks 1–2 landed). Captures decisions,
+> Status: Living implementation plan (in progress — Chunks 1–3 landed). Captures decisions,
> invariants, and a chunked build plan; Progress Notes track what has shipped.
## Context
@@ -363,8 +363,9 @@ a later step.
2. ✅ **(landed)** **Model:** `analyzer.AssignmentProposal` + migration (partial-unique
index), admin (`ReadOnlyAdmin`), backup-policy coverage (BACKUP + TRUNCATE, like the
sibling reviewer tables) (`scripts/backup_policy.py`).
-3. **Builder/engine integration:** candidate exclusion, pending-load contribution, cooldown
- exclusion; pure-engine seams with unit tests (assignability, scarcity, cooldown).
+3. ✅ **(landed)** **Builder/engine integration:** candidate exclusion, pending-load
+ contribution, cooldown exclusion; pure-engine seams with unit tests (assignability,
+ scarcity, cooldown).
4. **Propose service + task + expiry sweep:** `propose_assignments_for_repo` (per-reviewer
branch: `auto` → 046 path; `confirm` reachable → proposal; `confirm` unreachable →
fallback), `analyzer.propose_reviewer_assignments` task, expiry sweep task, settings/
@@ -436,6 +437,25 @@ a later step.
## Progress Notes
+- 2026-07-08: **Chunk 3 landed.** Made `ReviewerAssignmentBuilder` proposal-aware via three
+ additions, all routed through a new shared `_prepare_assignment_inputs` helper so the builder
+ and the diagnostic `build_reviewer_assignment_trace` cannot diverge: (1) **candidate
+ exclusion** — a repo-wide `state=proposed` query yields the set of PRs to withhold from
+ re-proposal (`_filter_prs_without_active_proposal`); (2) **pending-load contribution** — the
+ same rows feed a per-reviewer load map folded into the engine's weighted load by the new pure
+ seam `reviewer_assignment_engine.add_pending_proposal_load` (weight
+ `ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT`, default 1.0; a proposal occupies a slot,
+ open-list/total untouched); (3) **cooldown exclusion** — reviewers with an `expired` proposal
+ for a PR within `ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS` (default 14, `decided_at`
+ window; 0 disables) are merged into the per-PR `excluded_by_pr` set alongside opt-outs
+ (`_proposal_cooldowns_for_prs` + `_merge_excluded_by_pr`). The integration is data-driven and
+ ungated (no proposals ⇒ no-op), matching the opt-out precedent — the master kill switch and
+ the propose task land in Chunk 4. Added the two tuning settings to `settings/base.py` **and**
+ `.env.example`. Validation: 8 new tests (2 pure-engine: merge-without-mutating,
+ capacity-consumption; 6 builder: active-proposal excludes PR, terminal proposal does not,
+ pending load reroutes to another reviewer, expired within/after cooldown, cooldown-disabled)
+ + full `analyzer` suite (411) green on dockerized Postgres; `makemigrations --check` clean
+ (no model changes); ruff clean.
- 2026-07-08: **Chunk 2 landed.** Added `analyzer.AssignmentProposal` (migration
`analyzer/0031`) with the `STATE_*`/`DECIDED_VIA_*` choices, the **partial unique
constraint** `an_ap_one_active_proposal_per_pr` on `(repository, pr_number) WHERE
diff --git a/qb_site/analyzer/services/reviewer_assignment.py b/qb_site/analyzer/services/reviewer_assignment.py
index 6ba622e0..dcf7d152 100644
--- a/qb_site/analyzer/services/reviewer_assignment.py
+++ b/qb_site/analyzer/services/reviewer_assignment.py
@@ -4,12 +4,20 @@
import json
import random
from dataclasses import dataclass, field
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from typing import Dict, Sequence, Set
+from django.conf import settings
from django.utils.dateparse import parse_datetime
-from analyzer.models import AreaStatsSnapshot, QueueRuleSet, QueueSnapshot, ReviewerAssignmentSnapshot, ReviewerOptOut
+from analyzer.models import (
+ AreaStatsSnapshot,
+ AssignmentProposal,
+ QueueRuleSet,
+ QueueSnapshot,
+ ReviewerAssignmentSnapshot,
+ ReviewerOptOut,
+)
from analyzer.services.queue_rules import default_rule_set_for_repo, rules_for_rule_set
from analyzer.services.queueboard_snapshot import QueueboardSnapshotBuilder
from analyzer.services.reviewer_assignment_engine import (
@@ -19,6 +27,7 @@
ReviewerSuggestionResult,
SimulationInputs,
_normalize_login,
+ add_pending_proposal_load,
rank_prs_for_assignment,
run_assignment_simulation,
suggest_reviewer_for_pr,
@@ -50,6 +59,79 @@ def _opt_outs_for_prs(repository: Repository, pr_numbers: Sequence[int]) -> dict
return opt_outs
+def _active_proposal_rows(repository: Repository) -> list[tuple[int, str]]:
+ """All active (``proposed``) assignment proposals for the repo as ``(pr_number, login)``.
+
+ One repo-wide query feeds both proposal-aware builder additions (design doc 050): the set
+ of PRs to withhold from re-proposal and the per-reviewer pending load. Login casing matches
+ the snapshot / ``ReviewerProfile.github_login`` keying, so the load lookup lines up with the
+ engine's exact-match capacity gate.
+ """
+ return [
+ (int(pr_number), reviewer_login)
+ for pr_number, reviewer_login in AssignmentProposal.objects.filter(
+ repository=repository,
+ state=AssignmentProposal.STATE_PROPOSED,
+ ).values_list("pr_number", "reviewer_login")
+ ]
+
+
+def _pending_proposal_load(rows: Sequence[tuple[int, str]], *, weight: float) -> dict[str, float]:
+ """Weighted pending-proposal load per reviewer login (one slot per active proposal)."""
+ load: dict[str, float] = {}
+ for _pr_number, reviewer_login in rows:
+ load[reviewer_login] = load.get(reviewer_login, 0.0) + weight
+ return load
+
+
+def _filter_prs_without_active_proposal(pr_numbers: Sequence[int], *, prs_with_active_proposal: Set[int]) -> list[int]:
+ """Drop PRs that already have an active proposal.
+
+ Enforces "one proposal per PR at a time": never re-propose a PR that is mid-proposal, and
+ never propose it to a second reviewer while the first is deciding.
+ """
+ return [int(n) for n in pr_numbers if int(n) not in prs_with_active_proposal]
+
+
+def _proposal_cooldowns_for_prs(
+ repository: Repository,
+ pr_numbers: Sequence[int],
+ *,
+ now: datetime,
+ cooldown_days: int,
+) -> dict[int, set[str]]:
+ """Reviewers on soft cooldown per PR from a recently ``expired`` proposal (design doc 050).
+
+ A silent timeout is soft: skip the reviewer for this PR for ``cooldown_days`` after the
+ proposal expired, then let them be a candidate again. Unlike a decline (a permanent
+ ``ReviewerOptOut``), this only advances the PR to other candidates now without foreclosing
+ a reviewer who was merely away. Returns an ``excluded_by_pr`` fragment mergeable with the
+ opt-out exclusions.
+ """
+ if not pr_numbers or cooldown_days <= 0:
+ return {}
+ cutoff = now - timedelta(days=cooldown_days)
+ cooldowns: dict[int, set[str]] = {}
+ rows = AssignmentProposal.objects.filter(
+ repository=repository,
+ pr_number__in=[int(n) for n in pr_numbers],
+ state=AssignmentProposal.STATE_EXPIRED,
+ decided_at__gte=cutoff,
+ ).values_list("pr_number", "reviewer_login")
+ for pr_number, reviewer_login in rows:
+ cooldowns.setdefault(int(pr_number), set()).add(_normalize_login(reviewer_login))
+ return cooldowns
+
+
+def _merge_excluded_by_pr(*maps: dict[int, set[str]]) -> dict[int, set[str]]:
+ """Union several ``{pr_number: {login, ...}}`` exclusion maps into one."""
+ merged: dict[int, set[str]] = {}
+ for mapping in maps:
+ for pr_number, logins in mapping.items():
+ merged.setdefault(int(pr_number), set()).update(logins)
+ return merged
+
+
def _active_reviewer_logins(reviewers: Sequence[ReviewerProfile]) -> set[str]:
return {
_normalize_login(reviewer.github_login) for reviewer in reviewers if reviewer.auto_assign and not reviewer.temporary_break
@@ -306,6 +388,71 @@ def suggest_reviewers_many_with_trace(
return result.suggestions, result.per_pr
+@dataclass
+class _AssignmentInputs:
+ """Proposal-aware candidate/load inputs shared by the builder and the trace."""
+
+ reviewers: list[ReviewerProfile]
+ assignments: Dict[str, tuple[list[int], float, int]]
+ queue_prs: list[int]
+ assignable_queue_prs: list[int]
+ excluded_by_pr: dict[int, set[str]]
+
+
+def _prepare_assignment_inputs(
+ repository: Repository,
+ *,
+ payload: dict,
+ now: datetime,
+ rule_set: QueueRuleSet | None,
+) -> _AssignmentInputs:
+ """Assemble the candidate pool, reviewer load, and exclusions for a build.
+
+ Beyond the legacy filters (active-assignee / assignment-forbidden labels / opt-outs) this
+ folds in the three proposal-aware additions from design doc 050 so the builder and the
+ diagnostic trace agree: pending proposals contribute weighted load, PRs with an active
+ proposal are withheld from re-proposal, and reviewers with a recently expired proposal for a
+ PR are excluded for it (soft cooldown, merged into the per-PR exclusion set alongside
+ opt-outs).
+ """
+ reviewers = build_reviewer_catalog(repository, now=now)
+ assignment_stats = collect_assignment_statistics(payload)
+
+ proposal_weight = float(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT", 1.0))
+ active_proposal_rows = _active_proposal_rows(repository)
+ prs_with_active_proposal = {pr_number for pr_number, _ in active_proposal_rows}
+ assignments = add_pending_proposal_load(
+ assignment_stats.assignments,
+ _pending_proposal_load(active_proposal_rows, weight=proposal_weight),
+ )
+
+ all_prs = payload.get("prs", {})
+ dashboards = payload.get("lists", {}).get("dashboards", {})
+ queue_prs = list(dashboards.get("Queue", []))
+ assignable_queue_prs = _filter_prs_without_active_assignee(queue_prs, all_prs=all_prs, reviewers=reviewers)
+ assignable_queue_prs = _filter_assignment_forbidden_prs(
+ assignable_queue_prs,
+ all_prs=all_prs,
+ forbidden_labels=_assignment_forbidden_labels(repository, rule_set=rule_set),
+ )
+ assignable_queue_prs = _filter_prs_without_active_proposal(
+ assignable_queue_prs, prs_with_active_proposal=prs_with_active_proposal
+ )
+
+ cooldown_days = int(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS", 14))
+ excluded_by_pr = _merge_excluded_by_pr(
+ _opt_outs_for_prs(repository, assignable_queue_prs),
+ _proposal_cooldowns_for_prs(repository, assignable_queue_prs, now=now, cooldown_days=cooldown_days),
+ )
+ return _AssignmentInputs(
+ reviewers=reviewers,
+ assignments=assignments,
+ queue_prs=queue_prs,
+ assignable_queue_prs=assignable_queue_prs,
+ excluded_by_pr=excluded_by_pr,
+ )
+
+
def build_reviewer_assignment_trace(
repository: Repository,
*,
@@ -315,31 +462,18 @@ def build_reviewer_assignment_trace(
) -> dict:
current_time = now or datetime.now(timezone.utc)
payload = queue_snapshot.payload
- reviewers = build_reviewer_catalog(repository, now=current_time)
topic_label_matcher = topic_label_matcher_for_repo(repository)
- assignment_stats = collect_assignment_statistics(payload)
-
- dashboards = payload.get("lists", {}).get("dashboards", {})
- queue_prs = dashboards.get("Queue", [])
- assignable_queue_prs = _filter_prs_without_active_assignee(
- queue_prs,
- all_prs=payload.get("prs", {}),
- reviewers=reviewers,
- )
- assignable_queue_prs = _filter_assignment_forbidden_prs(
- assignable_queue_prs,
- all_prs=payload.get("prs", {}),
- forbidden_labels=_assignment_forbidden_labels(repository),
- )
+ inputs = _prepare_assignment_inputs(repository, payload=payload, now=current_time, rule_set=None)
+ queue_prs = inputs.queue_prs
+ assignable_queue_prs = inputs.assignable_queue_prs
- excluded_by_pr = _opt_outs_for_prs(repository, assignable_queue_prs)
suggestions, per_pr = suggest_reviewers_many_with_trace(
- reviewers=reviewers,
- assignments=assignment_stats.assignments,
+ reviewers=inputs.reviewers,
+ assignments=inputs.assignments,
prs_to_assign=assignable_queue_prs,
all_prs=payload.get("prs", {}),
rng=rng,
- excluded_by_pr=excluded_by_pr,
+ excluded_by_pr=inputs.excluded_by_pr,
topic_label_matcher=topic_label_matcher,
)
@@ -471,29 +605,15 @@ def build(
queue_obj = queue_snapshot or self._get_or_build_queue_snapshot(repository, cache_key=cache_key, rule_set=rule_set)
payload = queue_obj.payload
- reviewers = build_reviewer_catalog(repository, now=current_time)
- assignment_stats = collect_assignment_statistics(payload)
-
- dashboards = payload.get("lists", {}).get("dashboards", {})
- queue_prs = dashboards.get("Queue", [])
- assignable_queue_prs = _filter_prs_without_active_assignee(
- queue_prs,
- all_prs=payload.get("prs", {}),
- reviewers=reviewers,
- )
- assignable_queue_prs = _filter_assignment_forbidden_prs(
- assignable_queue_prs,
- all_prs=payload.get("prs", {}),
- forbidden_labels=_assignment_forbidden_labels(repository, rule_set=rule_set),
- )
+ inputs = _prepare_assignment_inputs(repository, payload=payload, now=current_time, rule_set=rule_set)
automatic_assignments = suggest_reviewers_many(
- reviewers=reviewers,
- assignments=assignment_stats.assignments,
- prs_to_assign=assignable_queue_prs,
+ reviewers=inputs.reviewers,
+ assignments=inputs.assignments,
+ prs_to_assign=inputs.assignable_queue_prs,
all_prs=payload.get("prs", {}),
rng=self.rng,
- excluded_by_pr=_opt_outs_for_prs(repository, assignable_queue_prs),
+ excluded_by_pr=inputs.excluded_by_pr,
topic_label_matcher=topic_label_matcher_for_repo(repository),
)
@@ -647,6 +767,7 @@ def _get_or_build_queue_snapshot(
"ReviewerAssignmentBuilder",
"ReviewerProfile",
"ReviewerSuggestionResult",
+ "add_pending_proposal_load",
"build_reviewer_assignment_trace",
"build_reviewer_catalog",
"collect_assignment_statistics",
diff --git a/qb_site/analyzer/services/reviewer_assignment_engine.py b/qb_site/analyzer/services/reviewer_assignment_engine.py
index fdb31ab2..210e91d9 100644
--- a/qb_site/analyzer/services/reviewer_assignment_engine.py
+++ b/qb_site/analyzer/services/reviewer_assignment_engine.py
@@ -95,6 +95,31 @@ def _current_weight(login: str, assignments: Dict[str, tuple[list[int], float, i
return float(data[1]) if data else 0.0
+def add_pending_proposal_load(
+ assignment_stats: Dict[str, tuple[list[int], float, int]],
+ pending_load_by_login: Dict[str, float],
+) -> Dict[str, tuple[list[int], float, int]]:
+ """Return a copy of ``assignment_stats`` with pending-proposal load folded into weights.
+
+ A reviewer's active (``proposed``) assignment proposals occupy capacity just like assigned
+ PRs (design doc 050): each contributes to their weighted load so the engine's capacity gate
+ (``maximum_capacity - current_weight``) accounts for work already in flight. The open-PR
+ list and total-assigned count are left untouched — a proposal is a load contribution, not an
+ assignee. Keys must match ``ReviewerProfile.github_login`` (the same keying the snapshot
+ uses for assignees), so a login absent from ``assignment_stats`` is created with an empty
+ open list.
+ """
+ merged: Dict[str, tuple[list[int], float, int]] = {
+ login: (list(open_list), float(weight), int(total)) for login, (open_list, weight, total) in assignment_stats.items()
+ }
+ for login, extra in pending_load_by_login.items():
+ if not extra:
+ continue
+ open_list, weight, total = merged.get(login, ([], 0.0, 0))
+ merged[login] = (open_list, weight + float(extra), total)
+ return merged
+
+
def _reviewer_candidate_state(
*,
pr_entry: dict,
diff --git a/qb_site/analyzer/tests/services/test_reviewer_assignment.py b/qb_site/analyzer/tests/services/test_reviewer_assignment.py
index 420ff361..0bf137a2 100644
--- a/qb_site/analyzer/tests/services/test_reviewer_assignment.py
+++ b/qb_site/analyzer/tests/services/test_reviewer_assignment.py
@@ -3,7 +3,7 @@
import random
from datetime import datetime, timedelta, timezone
-from django.test import SimpleTestCase, TestCase
+from django.test import SimpleTestCase, TestCase, override_settings
from analyzer.services.reviewer_assignment import (
AreaStatsBuilder,
@@ -11,6 +11,8 @@
ReviewerAssignmentBuilder,
ReviewerProfile,
_filter_assignment_forbidden_prs,
+ add_pending_proposal_load,
+ build_reviewer_assignment_trace,
collect_assignment_statistics,
compute_area_stats,
rank_prs_for_assignment,
@@ -19,7 +21,7 @@
)
from core.models import Repository, ReviewerPreference, User
from core.services.topic_labels import make_topic_label_matcher
-from analyzer.models import QueueRuleSet, ReviewerOptOut
+from analyzer.models import AssignmentProposal, QueueRuleSet, ReviewerOptOut
from syncer.models.ci_enums import CheckRunConclusion, CheckRunStatus
from syncer.services.pr_sync_service import PRSyncService
from syncer.models import CommitCheckRun, LabelDef, PRLabel, PullRequest
@@ -205,6 +207,50 @@ def test_opt_out_excludes_from_available_pool(self):
self.assertEqual(result.all_available_reviewers, [])
self.assertIsNone(result.suggested)
+ def test_add_pending_proposal_load_merges_without_mutating_input(self):
+ stats = {"alice": ([1, 2], 2.0, 2)}
+ merged = add_pending_proposal_load(stats, {"alice": 1.0, "bob": 1.5})
+
+ # Existing reviewer gains weight; open list / total-assigned untouched.
+ self.assertEqual(merged["alice"], ([1, 2], 3.0, 2))
+ # Reviewer absent from stats is created with an empty open list.
+ self.assertEqual(merged["bob"], ([], 1.5, 0))
+ # A zero contribution is ignored, and the input is not mutated.
+ self.assertEqual(stats["alice"], ([1, 2], 2.0, 2))
+ self.assertEqual(add_pending_proposal_load({}, {"carol": 0.0}), {})
+
+ def test_pending_proposal_load_consumes_reviewer_capacity(self):
+ # A reviewer whose only load is a pending proposal is gated exactly like an assignee.
+ loaded = add_pending_proposal_load({}, {"bob": 1.0})
+ pr_entry = {
+ "labels": [{"name": "t-analysis", "color": "123456"}],
+ "author": "dave",
+ "total_queue_time": {"status": "valid", "value_td": 10},
+ }
+ reviewers = [
+ ReviewerProfile(
+ github_login="bob",
+ maximum_capacity=1,
+ auto_assign=True,
+ temporary_break=False,
+ preferred_labels=["t-analysis"],
+ preferred_labels_lower={"t-analysis"},
+ free_form="",
+ conflict_of_interest=[],
+ conflict_of_interest_lower=set(),
+ )
+ ]
+
+ result = suggest_reviewer_for_pr(
+ pr_number=1,
+ pr_entry=pr_entry,
+ reviewers=reviewers,
+ assignment_stats=loaded,
+ rng=random.Random(0),
+ )
+ self.assertIsNone(result.suggested)
+ self.assertEqual(result.reason, "no-capacity")
+
def test_filter_assignment_forbidden_prs_drops_matching_labels(self):
all_prs = {
1: {"labels": [{"name": "t-analysis"}]},
@@ -938,6 +984,158 @@ def test_build_keeps_pr_assigned_only_to_reviewer_on_break_as_candidate(self):
self.assertIn(18, payload["automatic_assignments"])
+ def _proposal(
+ self,
+ pr_number: int,
+ reviewer_login: str,
+ *,
+ state: str,
+ expires_at: datetime,
+ decided_at: datetime | None = None,
+ ) -> AssignmentProposal:
+ return AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=pr_number,
+ reviewer_login=reviewer_login,
+ state=state,
+ expires_at=expires_at,
+ decided_at=decided_at,
+ )
+
+ def test_build_excludes_pr_with_active_proposal(self):
+ # A PR mid-proposal must not be re-proposed or offered to a second reviewer.
+ self._make_pr(30, labels=("t-analysis",))
+ ReviewerPreference.objects.create(
+ repository=self.repo,
+ user=self.bob,
+ preferred_labels=["t-analysis"],
+ maximum_capacity=3,
+ auto_assign=True,
+ )
+ self._proposal(30, "bob", state=AssignmentProposal.STATE_PROPOSED, expires_at=self.now + timedelta(days=17))
+
+ queue_snapshot = ReviewerAssignmentBuilder().queue_snapshot_builder.build_and_store(self.repo, cache_key="default")
+ payload = ReviewerAssignmentBuilder(rng=random.Random(0)).build(self.repo, queue_snapshot=queue_snapshot)
+
+ self.assertNotIn(30, payload["automatic_assignments"])
+
+ def test_build_does_not_withhold_pr_with_only_terminal_proposal(self):
+ # Terminal proposals are history, not live state: they never withhold the PR.
+ self._make_pr(35, labels=("t-analysis",))
+ ReviewerPreference.objects.create(
+ repository=self.repo,
+ user=self.bob,
+ preferred_labels=["t-analysis"],
+ maximum_capacity=3,
+ auto_assign=True,
+ )
+ past = datetime.now(timezone.utc) - timedelta(days=1)
+ # A prior candidate declined; the row is retained history but must not block re-proposal.
+ self._proposal(35, "carol", state=AssignmentProposal.STATE_DECLINED, expires_at=past, decided_at=past)
+
+ queue_snapshot = ReviewerAssignmentBuilder().queue_snapshot_builder.build_and_store(self.repo, cache_key="default")
+ payload = ReviewerAssignmentBuilder(rng=random.Random(0)).build(self.repo, queue_snapshot=queue_snapshot)
+
+ self.assertEqual(payload["automatic_assignments"].get(35), "bob")
+
+ def test_build_pending_proposal_counts_toward_reviewer_load(self):
+ # bob's only load is a pending proposal for an unrelated PR; at capacity, so the queue
+ # PR routes to carol instead. Demonstrates a proposal occupies a capacity slot.
+ carol = User.objects.create(github_login="carol")
+ self._make_pr(31, labels=("t-analysis",))
+ ReviewerPreference.objects.create(
+ repository=self.repo,
+ user=self.bob,
+ preferred_labels=["t-analysis"],
+ maximum_capacity=1,
+ auto_assign=True,
+ )
+ ReviewerPreference.objects.create(
+ repository=self.repo,
+ user=carol,
+ preferred_labels=["t-analysis"],
+ maximum_capacity=1,
+ auto_assign=True,
+ )
+ self._proposal(999, "bob", state=AssignmentProposal.STATE_PROPOSED, expires_at=self.now + timedelta(days=17))
+
+ queue_snapshot = ReviewerAssignmentBuilder().queue_snapshot_builder.build_and_store(self.repo, cache_key="default")
+ payload = ReviewerAssignmentBuilder(rng=random.Random(0)).build(self.repo, queue_snapshot=queue_snapshot)
+
+ self.assertEqual(payload["automatic_assignments"].get(31), "carol")
+
+ def test_build_expired_proposal_within_cooldown_excludes_reviewer(self):
+ self._make_pr(32, labels=("t-analysis",))
+ ReviewerPreference.objects.create(
+ repository=self.repo,
+ user=self.bob,
+ preferred_labels=["t-analysis"],
+ maximum_capacity=3,
+ auto_assign=True,
+ )
+ recent = datetime.now(timezone.utc) - timedelta(days=5) # inside the 14-day default cooldown
+ self._proposal(32, "bob", state=AssignmentProposal.STATE_EXPIRED, expires_at=recent, decided_at=recent)
+
+ queue_snapshot = ReviewerAssignmentBuilder().queue_snapshot_builder.build_and_store(self.repo, cache_key="default")
+ payload = ReviewerAssignmentBuilder(rng=random.Random(0)).build(self.repo, queue_snapshot=queue_snapshot)
+
+ # bob is the only candidate and is on cooldown -> the PR advances to nobody this cycle.
+ self.assertNotIn(32, payload["automatic_assignments"])
+
+ def test_build_expired_proposal_after_cooldown_reallows_reviewer(self):
+ self._make_pr(34, labels=("t-analysis",))
+ ReviewerPreference.objects.create(
+ repository=self.repo,
+ user=self.bob,
+ preferred_labels=["t-analysis"],
+ maximum_capacity=3,
+ auto_assign=True,
+ )
+ old = datetime.now(timezone.utc) - timedelta(days=20) # past the 14-day default cooldown
+ self._proposal(34, "bob", state=AssignmentProposal.STATE_EXPIRED, expires_at=old, decided_at=old)
+
+ queue_snapshot = ReviewerAssignmentBuilder().queue_snapshot_builder.build_and_store(self.repo, cache_key="default")
+ payload = ReviewerAssignmentBuilder(rng=random.Random(0)).build(self.repo, queue_snapshot=queue_snapshot)
+
+ self.assertEqual(payload["automatic_assignments"].get(34), "bob")
+
+ def test_trace_excludes_pr_with_active_proposal(self):
+ # The diagnostic trace routes through the same helper, so it agrees with the builder.
+ self._make_pr(40, labels=("t-analysis",))
+ ReviewerPreference.objects.create(
+ repository=self.repo,
+ user=self.bob,
+ preferred_labels=["t-analysis"],
+ maximum_capacity=3,
+ auto_assign=True,
+ )
+ self._proposal(40, "bob", state=AssignmentProposal.STATE_PROPOSED, expires_at=self.now + timedelta(days=17))
+
+ queue_snapshot = ReviewerAssignmentBuilder().queue_snapshot_builder.build_and_store(self.repo, cache_key="default")
+ trace = build_reviewer_assignment_trace(self.repo, queue_snapshot=queue_snapshot, rng=random.Random(0))
+
+ self.assertIn(40, queue_snapshot.payload["lists"]["dashboards"]["Queue"])
+ self.assertEqual(trace["meta"]["assignment_candidate_prs"], 0)
+ self.assertNotIn("40", trace["per_pr"])
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS=0)
+ def test_build_cooldown_disabled_reallows_reviewer_immediately(self):
+ self._make_pr(36, labels=("t-analysis",))
+ ReviewerPreference.objects.create(
+ repository=self.repo,
+ user=self.bob,
+ preferred_labels=["t-analysis"],
+ maximum_capacity=3,
+ auto_assign=True,
+ )
+ recent = datetime.now(timezone.utc) - timedelta(days=1)
+ self._proposal(36, "bob", state=AssignmentProposal.STATE_EXPIRED, expires_at=recent, decided_at=recent)
+
+ queue_snapshot = ReviewerAssignmentBuilder().queue_snapshot_builder.build_and_store(self.repo, cache_key="default")
+ payload = ReviewerAssignmentBuilder(rng=random.Random(0)).build(self.repo, queue_snapshot=queue_snapshot)
+
+ self.assertEqual(payload["automatic_assignments"].get(36), "bob")
+
def test_unassignment_event_excludes_reviewer_from_auto_assignments(self):
ReviewerPreference.objects.create(
repository=self.repo,
diff --git a/qb_site/qb_site/settings/base.py b/qb_site/qb_site/settings/base.py
index 2677a91f..f14a8bdb 100644
--- a/qb_site/qb_site/settings/base.py
+++ b/qb_site/qb_site/settings/base.py
@@ -396,6 +396,13 @@ def env_optional_bounded_int(name: str, *, minimum: int, maximum: int) -> int |
# non-zero default bounds GitHub secondary-rate-limit exposure and drains any cutover
# backlog gradually; capped-over proposals are left for the next run.
ANALYZER_REVIEWER_ASSIGNMENT_APPLY_MAX_PER_REPO = int(os.getenv("ANALYZER_REVIEWER_ASSIGNMENT_APPLY_MAX_PER_REPO", 25))
+# Reviewer assignment acceptance gate (design doc 050) — builder/engine tuning.
+# A pending (proposed) proposal contributes this weighted load to the reviewer (a proposal
+# occupies a slot, like an AwaitingReview PR) and excludes the PR from re-proposal.
+ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT = float(os.getenv("ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT", "1.0"))
+# Soft cooldown: skip a reviewer for a PR when a proposal for it expired (silent timeout)
+# within this many days. Not a permanent opt-out (that is an explicit decline). 0 disables it.
+ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS = int(os.getenv("ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS", "14"))
ANALYZER_REVIEWER_ATTENTION_ENABLED = env_bool(os.getenv("ANALYZER_REVIEWER_ATTENTION_ENABLED"), False)
ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED = env_bool(
os.getenv("ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED"),
From 31fdf7633af2e909e133bef24098e92baef64c30 Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Wed, 8 Jul 2026 22:51:34 -0400
Subject: [PATCH 05/33] feat: add reviewer assignment propose pipeline + expiry
sweep
Chunk 4 of design doc 050. Split the 046 apply path into a batch (propose) half
and a reusable mutation half, and build the acceptance-gate propose pipeline.
- Extract assign_reviewer_and_record + latest_default_snapshot +
parse_snapshot_assignments into reviewer_assignment_apply and refactor
apply_assignments_for_repo onto them (behavior-preserving; 21 apply tests green),
so the auto/fallback direct-assign, the legacy apply sweep, and the future console
accept share one GitHub mutation + ReviewerAssignmentApplication audit trail.
- propose_assignments_for_repo + analyzer.propose_reviewer_assignments task +
propose_reviewer_assignments management command. Per snapshot {pr: login}, branch on
ReviewerPreference.assignment_acceptance: auto (and confirm reviewers with no Zulip
link) direct-assign; confirm + Zulip-linked create an AssignmentProposal. Re-validates
live state, dedupes on active proposal / recently-applied, clamps the per-reviewer
acceptance window to >=7 days, applies a GitHub-mutation-only per-repo cap (defers
assigns without starving DB-only proposals), and has a fully side-effect-free dry-run.
- Centralize on-queue-exit/expiry in the single assignment_proposal_validity.proposal_validity
authority (assignee-landed & closed/merged -> superseded; timeout -> expired; open +
off-queue -> superseded under invalidate / live under retain; on_queue=None never
invalidates) and drive analyzer.expire_assignment_proposals from it: ungated essential
maintenance, no GitHub writes, off-queue invalidation only from a fresh snapshot,
idempotent conditional UPDATE ... WHERE state='proposed'.
- Add rollout flags (ENABLED master switch, DELIVERY_ENABLED, ASSIGN_ON_ACCEPT_ENABLED,
DRY_RUN), WINDOW_DAYS, ON_QUEUE_EXIT, and propose/expiry schedules to settings/base.py,
.env.example, and beat. propose supersedes apply -- enable one, not both.
- Update analyzer/AGENTS.md task surface.
Validation: 42 new tests + full analyzer suite (454) green on Postgres;
makemigrations --check clean (no model changes); ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.env.example | 24 ++
...050-reviewer-assignment-acceptance-gate.md | 35 +-
qb_site/analyzer/AGENTS.md | 13 +
.../commands/propose_reviewer_assignments.py | 63 +++
.../services/assignment_proposal_expiry.py | 142 +++++++
.../services/assignment_proposal_validity.py | 140 +++++++
.../services/reviewer_assignment_apply.py | 182 ++++++---
.../services/reviewer_assignment_propose.py | 292 ++++++++++++++
qb_site/analyzer/tasks/__init__.py | 4 +
.../tasks/assignment_proposal_expiry.py | 81 ++++
.../tasks/reviewer_assignment_propose.py | 138 +++++++
.../test_assignment_proposal_expiry.py | 208 ++++++++++
.../test_assignment_proposal_validity.py | 163 ++++++++
.../test_reviewer_assignment_propose.py | 377 ++++++++++++++++++
.../test_reviewer_assignment_propose_task.py | 119 ++++++
qb_site/qb_site/settings/base.py | 58 +++
16 files changed, 1980 insertions(+), 59 deletions(-)
create mode 100644 qb_site/analyzer/management/commands/propose_reviewer_assignments.py
create mode 100644 qb_site/analyzer/services/assignment_proposal_expiry.py
create mode 100644 qb_site/analyzer/services/assignment_proposal_validity.py
create mode 100644 qb_site/analyzer/services/reviewer_assignment_propose.py
create mode 100644 qb_site/analyzer/tasks/assignment_proposal_expiry.py
create mode 100644 qb_site/analyzer/tasks/reviewer_assignment_propose.py
create mode 100644 qb_site/analyzer/tests/services/test_assignment_proposal_expiry.py
create mode 100644 qb_site/analyzer/tests/services/test_assignment_proposal_validity.py
create mode 100644 qb_site/analyzer/tests/services/test_reviewer_assignment_propose.py
create mode 100644 qb_site/analyzer/tests/tasks/test_reviewer_assignment_propose_task.py
diff --git a/.env.example b/.env.example
index 61900981..9671fe0e 100644
--- a/.env.example
+++ b/.env.example
@@ -276,6 +276,30 @@ ANALYZER_REVIEWER_ASSIGNMENT_APPLY_MAX_PER_REPO=25
# expire within this many days (a soft cooldown, not a permanent opt-out; 0 disables it).
ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT=1.0
ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS=14
+# Acceptance-gate rollout flags (design doc 050), each independently toggleable
+# (propose -> deliver -> assign-on-accept). All default off; enable EITHER this gate
+# OR the legacy ANALYZER_REVIEWER_ASSIGNMENT_APPLY_* task, not both (propose supersedes
+# apply: it direct-assigns auto-mode reviewers itself and proposes to confirm-mode ones).
+# ENABLED: propose task creates proposals / direct-assigns. DELIVERY_ENABLED: send the
+# proposal digest DM. ASSIGN_ON_ACCEPT_ENABLED: console accept performs the GitHub assign.
+# DRY_RUN: compute + record would-do outcomes with no side effects.
+ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=0
+ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED=0
+ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=0
+ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=0
+# Acceptance window in days (a proposal expires this long after creation unless accepted);
+# the per-reviewer notification_settings override is clamped to >= 7.
+ANALYZER_ASSIGNMENT_PROPOSAL_WINDOW_DAYS=7
+# On-queue-exit policy for a pending proposal: invalidate (default) | retain.
+ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT=invalidate
+# Propose task schedule (daily; default 00:45 UTC, superseding the legacy apply slot).
+# PERIOD_SECONDS<=0 disables scheduling.
+ANALYZER_ASSIGNMENT_PROPOSE_PERIOD_SECONDS=86400
+# ANALYZER_ASSIGNMENT_PROPOSE_UTC_HOUR=0
+# ANALYZER_ASSIGNMENT_PROPOSE_UTC_MINUTE=45
+# Expiry/reconcile sweep period (essential maintenance; runs regardless of the master switch).
+# Seconds; <=0 disables it.
+ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRY_PERIOD_SECONDS=3600
# Reviewer attention daily sweep controls.
# ENABLED: run the sweep task at all (computes reports/counters).
ANALYZER_REVIEWER_ATTENTION_ENABLED=0
diff --git a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
index eb7e4c02..9edb89a6 100644
--- a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
+++ b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
@@ -1,6 +1,6 @@
# Reviewer Assignment Acceptance Gate (Propose → Accept → Assign)
-> Status: Living implementation plan (in progress — Chunks 1–3 landed). Captures decisions,
+> Status: Living implementation plan (in progress — Chunks 1–4 landed). Captures decisions,
> invariants, and a chunked build plan; Progress Notes track what has shipped.
## Context
@@ -366,10 +366,10 @@ a later step.
3. ✅ **(landed)** **Builder/engine integration:** candidate exclusion, pending-load
contribution, cooldown exclusion; pure-engine seams with unit tests (assignability,
scarcity, cooldown).
-4. **Propose service + task + expiry sweep:** `propose_assignments_for_repo` (per-reviewer
- branch: `auto` → 046 path; `confirm` reachable → proposal; `confirm` unreachable →
- fallback), `analyzer.propose_reviewer_assignments` task, expiry sweep task, settings/
- flags/beat. Dry-run + flags. Unit + task tests.
+4. ✅ **(landed)** **Propose service + task + expiry sweep:** `propose_assignments_for_repo`
+ (per-reviewer branch: `auto` → 046 path; `confirm` reachable → proposal; `confirm`
+ unreachable → fallback), `analyzer.propose_reviewer_assignments` task, expiry sweep task,
+ settings/flags/beat. Dry-run + flags. Unit + task tests.
5. **Notification:** per-reviewer digest DM (reuse `ZulipClient` + a dedupe record or
`notified_at`), de-duped vs the attention "newly assigned" ping.
6. **Console:** GitHub-OAuth reviewer session, list view, accept/decline handlers
@@ -437,6 +437,31 @@ a later step.
## Progress Notes
+- 2026-07-09: **Chunk 4 landed.** Split the batch/mutation halves of the 046 apply path and
+ built the propose pipeline. Extracted the mutation core into
+ `reviewer_assignment_apply.assign_reviewer_and_record` (+ `latest_default_snapshot` /
+ `parse_snapshot_assignments`) and refactored `apply_assignments_for_repo` onto it (its 21 tests
+ still green), so the auto/fallback direct-assign path, the legacy apply sweep, and the future
+ console accept all share one GitHub mutation + `ReviewerAssignmentApplication` audit trail.
+ Added `reviewer_assignment_propose.propose_assignments_for_repo` (per-reviewer branch:
+ `auto`/`confirm`-unreachable → direct-assign; `confirm`+Zulip-linked → create
+ `AssignmentProposal`; re-validation, active-proposal/recently-applied dedupe, per-reviewer
+ acceptance window clamped ≥7, GitHub-mutation-only per-repo cap that defers assigns but never
+ starves DB-only proposals, fully side-effect-free dry-run) and the
+ `analyzer.propose_reviewer_assignments` task + `propose_reviewer_assignments` management command.
+ Centralized on-queue-exit / expiry logic in the single `assignment_proposal_validity.proposal_validity`
+ authority (assignee-landed & closed/merged → superseded; timeout → expired; open+off-queue →
+ superseded under `invalidate`, live under `retain`; `on_queue=None` never invalidates) and drove
+ the `analyzer.expire_assignment_proposals` sweep from it — ungated essential maintenance, no
+ GitHub writes, off-queue invalidation only from a *fresh* snapshot, idempotent conditional
+ `UPDATE ... WHERE state='proposed'`. Added rollout flags (`_ENABLED` master switch,
+ `_DELIVERY_ENABLED`, `_ASSIGN_ON_ACCEPT_ENABLED`, `_DRY_RUN`), `_WINDOW_DAYS`, `_ON_QUEUE_EXIT`,
+ and propose/expiry schedules to `settings/base.py` + `.env.example` + beat (propose supersedes
+ apply — run one, not both). Builder integration stays ungated so proposal rows created here are
+ respected immediately; DM delivery (`notified_at`) and console accept are Chunks 5–6. Updated
+ `analyzer/AGENTS.md` task surface. Validation: 42 new tests (10 `proposal_validity`, 16 propose
+ service, 6 propose task/command, 8 expiry service, 2 expiry task) + full `analyzer` suite (454)
+ green on Postgres; `makemigrations --check` clean (no model changes); ruff clean.
- 2026-07-08: **Chunk 3 landed.** Made `ReviewerAssignmentBuilder` proposal-aware via three
additions, all routed through a new shared `_prepare_assignment_inputs` helper so the builder
and the diagnostic `build_reviewer_assignment_trace` cannot diverge: (1) **candidate
diff --git a/qb_site/analyzer/AGENTS.md b/qb_site/analyzer/AGENTS.md
index 59f25470..868b89d1 100644
--- a/qb_site/analyzer/AGENTS.md
+++ b/qb_site/analyzer/AGENTS.md
@@ -59,6 +59,19 @@ Celery task names (as registered via `@shared_task(name=…)`):
recording outcomes in `ReviewerAssignmentApplication`. Gated by
`ANALYZER_REVIEWER_ASSIGNMENT_APPLY_ENABLED` (+ dry-run). Replaces the legacy
GitHub Actions auto-assign workflow; see design doc 046.
+- `analyzer.propose_reviewer_assignments` — acceptance-gate variant of the apply step
+ (design doc 050). Per snapshot `{pr: login}`, branches on the reviewer's
+ `ReviewerPreference.assignment_acceptance`: `auto` (and `confirm` reviewers with no
+ Zulip link) are direct-assigned via the shared 046 mutation path; `confirm` reviewers
+ with a Zulip link get an `AssignmentProposal` awaiting console acceptance. Gated by
+ `ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED` (+ dry-run). **Supersedes**
+ `analyzer.apply_reviewer_assignments` — enable one or the other, not both. Command:
+ `manage.py propose_reviewer_assignments [--repo o/n] [--dry-run] [--enable]`.
+- `analyzer.expire_assignment_proposals` — essential-maintenance sweep (design doc 050)
+ that expires timed-out proposals and supersedes those whose PR closed/merged, gained a
+ human assignee, or left the review queue (per the `proposal_validity` predicate and
+ `ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT`). Performs no GitHub writes and is
+ intentionally **not** gated by the master switch, so existing proposals keep draining.
- `analyzer.build_area_stats` / `analyzer.refresh_area_stats`
**Reviewer attention tasks**
diff --git a/qb_site/analyzer/management/commands/propose_reviewer_assignments.py b/qb_site/analyzer/management/commands/propose_reviewer_assignments.py
new file mode 100644
index 00000000..13106b71
--- /dev/null
+++ b/qb_site/analyzer/management/commands/propose_reviewer_assignments.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import json
+
+from django.core.management.base import BaseCommand, CommandError
+
+from analyzer.tasks.reviewer_assignment_propose import propose_reviewer_assignments_task
+from core.models import Repository
+
+
+class Command(BaseCommand):
+ help = (
+ "Propose reviewer assignments through the acceptance gate from the latest default-rule-set "
+ "ReviewerAssignmentSnapshot: confirm-mode reviewers get an AssignmentProposal; auto-mode "
+ "(and confirm-mode reviewers with no Zulip link) are direct-assigned. Use --dry-run to "
+ "inspect the would-propose/would-assign set without any side effect."
+ )
+
+ def add_arguments(self, parser):
+ parser.add_argument("--repo", help="Restrict to a single repository in owner/name form", default=None)
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Compute would-do outcomes without creating proposals or POSTing assignees.",
+ )
+ parser.add_argument(
+ "--enable",
+ action="store_true",
+ help="Force enabled=True for this run regardless of ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED.",
+ )
+ parser.add_argument(
+ "--include-inactive",
+ action="store_true",
+ help="Include repositories with is_active=False.",
+ )
+
+ def handle(self, *args, **options):
+ repository_id = None
+ repo_arg = options.get("repo")
+ if repo_arg:
+ if "/" not in repo_arg:
+ raise CommandError("Expected --repo in owner/name form")
+ owner, name = repo_arg.split("/", 1)
+ repo = Repository.objects.filter(owner=owner, name=name).only("id").first()
+ if repo is None:
+ raise CommandError(f"Repository not found: {repo_arg}")
+ repository_id = int(repo.id)
+
+ dry_run = bool(options.get("dry_run"))
+ enable = bool(options.get("enable"))
+ # Explicit flags override; absent them, fall back to settings for BOTH knobs so a configured
+ # ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN safety net is honored when run bare. --enable forces a
+ # real run (dry-run off); --dry-run forces preview.
+ enabled_override = True if enable else (False if dry_run else None)
+ dry_run_override = True if dry_run else (False if enable else None)
+
+ result = propose_reviewer_assignments_task.run(
+ repository_id=repository_id,
+ include_inactive_repositories=bool(options.get("include_inactive")),
+ enabled_override=enabled_override,
+ dry_run_override=dry_run_override,
+ )
+ self.stdout.write(json.dumps(result, indent=2, default=str))
diff --git a/qb_site/analyzer/services/assignment_proposal_expiry.py b/qb_site/analyzer/services/assignment_proposal_expiry.py
new file mode 100644
index 00000000..25c1fce7
--- /dev/null
+++ b/qb_site/analyzer/services/assignment_proposal_expiry.py
@@ -0,0 +1,142 @@
+"""Expire and reconcile pending ``AssignmentProposal`` rows (design doc 050).
+
+Essential maintenance for the acceptance gate, intentionally *not* gated by the master switch so
+that flipping the gate off lets existing proposals drain. For each still-``proposed`` row it asks
+the single ``proposal_validity`` authority whether the proposal is still live, and retires it
+otherwise:
+
+- past its acceptance window -> ``expired`` (seeds the soft re-propose cooldown),
+- PR closed/merged or a human/self-assignee landed, or (``invalidate`` policy) the PR left the
+ review queue -> ``superseded``.
+
+The sweep performs no GitHub writes — it only transitions DB state, so it is cheap and safe to run
+frequently. Off-queue invalidation is applied only when a *fresh* queue snapshot knows about the PR,
+so a missing/stale snapshot can never mass-supersede.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from typing import Any
+
+from django.db import DatabaseError
+
+from analyzer.models import AssignmentProposal, QueueSnapshot
+from analyzer.services.assignment_proposal_validity import ProposalValidity, proposal_validity, resolve_on_queue_exit_policy
+from analyzer.services.queue_rules import default_rule_set_for_repo
+from analyzer.services.reviewer_assignment_engine import _normalize_login
+from core.models import Repository
+from syncer.models import PullRequest
+
+log = logging.getLogger(__name__)
+
+# A queue snapshot older than this is not trusted for off-queue determination (mirrors pr_info).
+SNAPSHOT_FRESH_SECONDS = 7200
+
+
+def _empty_stats() -> dict[str, Any]:
+ return {
+ "active": 0,
+ "expired": 0,
+ "superseded": 0,
+ "still_live": 0,
+ "errored": 0,
+ }
+
+
+def _queue_membership(repository: Repository, *, now: datetime) -> tuple[set[int], set[int], bool]:
+ """Return ``(queue_pr_numbers, known_pr_numbers, fresh)`` from the latest default snapshot.
+
+ ``known_pr_numbers`` are the PRs the snapshot actually described; only for those (and only when
+ ``fresh``) can we assert on-queue membership. Everything else yields ``on_queue=None`` upstream.
+ """
+ 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").first()
+ if snapshot is None or not snapshot.payload:
+ return set(), set(), False
+ generated_at = snapshot.generated_at
+ fresh = generated_at is not None and (now - generated_at).total_seconds() <= SNAPSHOT_FRESH_SECONDS
+ payload = snapshot.payload
+ queue_prs = {int(n) for n in (payload.get("lists", {}).get("dashboards", {}).get("Queue", []) or [])}
+ known_prs = {int(n) for n in (payload.get("prs", {}) or {}).keys()}
+ return queue_prs, known_prs, fresh
+
+
+def expire_and_reconcile_proposals_for_repo(repository: Repository, *, now: datetime) -> dict[str, Any]:
+ """Retire every pending proposal for ``repository`` that is no longer live. Returns a summary."""
+ result: dict[str, Any] = {
+ "repo": f"{repository.owner}/{repository.name}",
+ "repo_id": int(repository.id),
+ "stats": _empty_stats(),
+ }
+ stats = result["stats"]
+
+ proposals = list(AssignmentProposal.objects.filter(repository=repository, state=AssignmentProposal.STATE_PROPOSED))
+ stats["active"] = len(proposals)
+ if not proposals:
+ result["status"] = "ok"
+ return result
+
+ pr_numbers = {int(p.pr_number) for p in proposals}
+ live_by_number = {
+ int(pr.number): pr
+ for pr in PullRequest.objects.filter(repository=repository, number__in=pr_numbers).only("number", "state", "assignees")
+ }
+ queue_prs, known_prs, snapshot_fresh = _queue_membership(repository, now=now)
+ policy = resolve_on_queue_exit_policy()
+
+ for proposal in proposals:
+ try:
+ pr_number = int(proposal.pr_number)
+ live_pr = live_by_number.get(pr_number)
+ pr_state = None if live_pr is None else str(live_pr.state)
+ current_assignees = set() if live_pr is None else {_normalize_login(str(a)) for a in (live_pr.assignees or []) if a}
+ if snapshot_fresh and pr_number in known_prs:
+ on_queue: bool | None = pr_number in queue_prs
+ else:
+ on_queue = None
+
+ validity: ProposalValidity = proposal_validity(
+ proposal,
+ now=now,
+ pr_state=pr_state,
+ current_assignees=current_assignees,
+ on_queue=on_queue,
+ on_queue_exit=policy,
+ )
+ if validity.is_live or validity.terminal_state is None:
+ stats["still_live"] += 1
+ continue
+
+ # Conditional update keeps concurrent sweeps idempotent: only the writer that still sees
+ # PROPOSED transitions the row (auto_now updated_at does not fire on .update()).
+ updated = AssignmentProposal.objects.filter(id=proposal.id, state=AssignmentProposal.STATE_PROPOSED).update(
+ state=validity.terminal_state,
+ decided_at=now,
+ decided_via=validity.decided_via or "",
+ updated_at=now,
+ )
+ if not updated:
+ stats["still_live"] += 1
+ continue
+ if validity.terminal_state == AssignmentProposal.STATE_EXPIRED:
+ stats["expired"] += 1
+ else:
+ stats["superseded"] += 1
+ except DatabaseError:
+ stats["errored"] += 1
+ log.exception(
+ "assignment_proposal_expiry: failed to reconcile proposal id=%s repo=%s/%s pr=%s",
+ proposal.id,
+ repository.owner,
+ repository.name,
+ proposal.pr_number,
+ )
+
+ result["status"] = "ok"
+ return result
+
+
+__all__ = ["expire_and_reconcile_proposals_for_repo", "SNAPSHOT_FRESH_SECONDS"]
diff --git a/qb_site/analyzer/services/assignment_proposal_validity.py b/qb_site/analyzer/services/assignment_proposal_validity.py
new file mode 100644
index 00000000..208e50cf
--- /dev/null
+++ b/qb_site/analyzer/services/assignment_proposal_validity.py
@@ -0,0 +1,140 @@
+"""The single authority on whether a pending ``AssignmentProposal`` is still live.
+
+Design doc 050 deliberately centralizes "is this pending proposal still actionable, and if not,
+why" in one predicate rather than scattering "still valid?" checks. Three call sites consult it so
+they can never drift:
+
+- the expiry/reconcile sweep (``analyzer.expire_assignment_proposals``),
+- sync-time reconciliation (a human/self-assignee landing on a proposed PR),
+- the console accept-time re-validation (Chunk 6).
+
+The on-queue-exit behavior is selectable via ``ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT``
+(``invalidate`` default / ``retain``), read here so a future flip needs no rework elsewhere.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+
+from django.conf import settings
+
+from analyzer.models import AssignmentProposal
+
+ON_QUEUE_EXIT_INVALIDATE = "invalidate"
+ON_QUEUE_EXIT_RETAIN = "retain"
+
+# reason codes
+REASON_LIVE = "live"
+REASON_ALREADY_TERMINAL = "already_terminal"
+REASON_EXPIRED = "expired"
+REASON_PR_ASSIGNED = "pr_assigned"
+REASON_PR_CLOSED = "pr_closed"
+REASON_PR_OFF_QUEUE = "pr_off_queue"
+
+
+@dataclass(frozen=True)
+class ProposalValidity:
+ """Verdict for one pending proposal.
+
+ ``is_live`` is the headline answer. When it is ``False`` and the proposal is not *already*
+ terminal, ``terminal_state``/``decided_via`` say how to retire it; ``reason`` is a stable code
+ for logging and for the console's "no longer available, here's why" rendering.
+ """
+
+ is_live: bool
+ reason: str
+ terminal_state: str | None = None
+ decided_via: str | None = None
+
+
+def resolve_on_queue_exit_policy() -> str:
+ """Return the configured on-queue-exit policy, defaulting to ``invalidate``."""
+ value = str(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT", ON_QUEUE_EXIT_INVALIDATE)).strip().lower()
+ return value if value in (ON_QUEUE_EXIT_INVALIDATE, ON_QUEUE_EXIT_RETAIN) else ON_QUEUE_EXIT_INVALIDATE
+
+
+def proposal_validity(
+ proposal: AssignmentProposal,
+ *,
+ now: datetime,
+ pr_state: str | None,
+ current_assignees: set[str] | None = None,
+ on_queue: bool | None = None,
+ on_queue_exit: str | None = None,
+) -> ProposalValidity:
+ """Decide whether ``proposal`` is still a live pending proposal.
+
+ Inputs are durable facts, mirroring the state model's "reconstructable from GitHub assignees +
+ the single active proposal":
+
+ - ``pr_state``: live PR state (``"open"`` / ``"closed"`` / ``"merged"`` / ``None`` if unknown).
+ - ``current_assignees``: normalized GitHub assignee logins currently on the PR.
+ - ``on_queue``: review-queue membership, or ``None`` when the caller could not determine it
+ reliably (e.g. no fresh queue snapshot) — in that case the off-queue check is skipped rather
+ than guessed, so a stale/missing snapshot never mass-invalidates.
+
+ Precedence (structural invalidation before time before policy):
+
+ 1. Not ``proposed`` -> already terminal (nothing to do).
+ 2. A GitHub assignee has landed -> superseded (don't fight the human/self-assignee).
+ 3. PR closed/merged -> superseded (a closed PR can't be usefully reviewed, regardless of policy).
+ 4. Past ``expires_at`` -> expired (a timeout is an expiry even under ``retain``; ``retain`` is
+ about queue-exit, not the acceptance clock). Expiry is what seeds the soft re-propose cooldown.
+ 5. Open but off-queue, under the ``invalidate`` policy -> superseded.
+ 6. Otherwise -> live.
+ """
+ if proposal.state != AssignmentProposal.STATE_PROPOSED:
+ return ProposalValidity(is_live=False, reason=REASON_ALREADY_TERMINAL)
+
+ policy = (on_queue_exit or resolve_on_queue_exit_policy()).strip().lower()
+ assignees = current_assignees or set()
+
+ if assignees:
+ return ProposalValidity(
+ is_live=False,
+ reason=REASON_PR_ASSIGNED,
+ terminal_state=AssignmentProposal.STATE_SUPERSEDED,
+ decided_via=AssignmentProposal.DECIDED_VIA_SYNC_SUPERSEDED,
+ )
+
+ if pr_state is not None and pr_state.strip().lower() != "open":
+ return ProposalValidity(
+ is_live=False,
+ reason=REASON_PR_CLOSED,
+ terminal_state=AssignmentProposal.STATE_SUPERSEDED,
+ decided_via=AssignmentProposal.DECIDED_VIA_SYNC_SUPERSEDED,
+ )
+
+ if proposal.expires_at is not None and now >= proposal.expires_at:
+ return ProposalValidity(
+ is_live=False,
+ reason=REASON_EXPIRED,
+ terminal_state=AssignmentProposal.STATE_EXPIRED,
+ decided_via=AssignmentProposal.DECIDED_VIA_AUTO_EXPIRE,
+ )
+
+ if on_queue is False and policy == ON_QUEUE_EXIT_INVALIDATE:
+ return ProposalValidity(
+ is_live=False,
+ reason=REASON_PR_OFF_QUEUE,
+ terminal_state=AssignmentProposal.STATE_SUPERSEDED,
+ decided_via=AssignmentProposal.DECIDED_VIA_SYNC_SUPERSEDED,
+ )
+
+ return ProposalValidity(is_live=True, reason=REASON_LIVE)
+
+
+__all__ = [
+ "ON_QUEUE_EXIT_INVALIDATE",
+ "ON_QUEUE_EXIT_RETAIN",
+ "ProposalValidity",
+ "proposal_validity",
+ "resolve_on_queue_exit_policy",
+ "REASON_LIVE",
+ "REASON_ALREADY_TERMINAL",
+ "REASON_EXPIRED",
+ "REASON_PR_ASSIGNED",
+ "REASON_PR_CLOSED",
+ "REASON_PR_OFF_QUEUE",
+]
diff --git a/qb_site/analyzer/services/reviewer_assignment_apply.py b/qb_site/analyzer/services/reviewer_assignment_apply.py
index 5240113a..06e11a00 100644
--- a/qb_site/analyzer/services/reviewer_assignment_apply.py
+++ b/qb_site/analyzer/services/reviewer_assignment_apply.py
@@ -57,6 +57,111 @@ def _current_assignee_logins(live_pr: PullRequest | None) -> set[str]:
return {_normalize_login(str(login)) for login in (live_pr.assignees or []) if login}
+def latest_default_snapshot(repository: Repository) -> tuple[ReviewerAssignmentSnapshot | None, str]:
+ """Return the newest authoritative default-rule-set assignment snapshot + its cache key.
+
+ Both the legacy apply sweep and the acceptance-gate propose step act on this same
+ ``{pr_number: reviewer_login}`` producer output, so they resolve it identically here.
+ """
+ rule_set = default_rule_set_for_repo(repository)
+ cache_key = str(rule_set.id) if rule_set else "default"
+ snapshot = (
+ ReviewerAssignmentSnapshot.objects.filter(repository=repository, cache_key=cache_key)
+ .order_by("-generated_at", "-id")
+ .first()
+ )
+ return snapshot, cache_key
+
+
+def parse_snapshot_assignments(snapshot: ReviewerAssignmentSnapshot) -> list[tuple[int, str]]:
+ """Parse ``payload["automatic_assignments"]`` into a sorted ``[(pr_number, login), ...]`` list."""
+ raw = snapshot.payload.get("automatic_assignments", {}) or {}
+ proposals: list[tuple[int, str]] = []
+ for key, value in raw.items():
+ try:
+ pr_number = int(key)
+ except (TypeError, ValueError):
+ continue
+ login = str(value).strip()
+ if not login:
+ continue
+ proposals.append((pr_number, login))
+ proposals.sort(key=lambda pair: pair[0])
+ return proposals
+
+
+def assign_reviewer_and_record(
+ *,
+ repository: Repository,
+ pr_number: int,
+ login: str,
+ snapshot: ReviewerAssignmentSnapshot | None,
+ run_date,
+ token: str,
+ assignment_client: GitHubAssignmentClient | None = None,
+ sync_enqueuer: SyncEnqueuer = _default_sync_enqueuer,
+) -> tuple[str, GitHubAssignmentClient | None, ReviewerAssignmentApplication | None]:
+ """Execute the 046 direct-assign mutation for one already-validated ``(pr, login)``.
+
+ Idempotently creates the PENDING ``ReviewerAssignmentApplication`` for
+ ``(run_date, repo, pr, reviewer)``; if a row already exists returns
+ ``("already_recorded", assignment_client, None)`` without mutating. Otherwise POSTs the
+ assignee via ``GitHubAssignmentClient`` (built from ``token`` when not supplied), confirms the
+ login actually landed (GitHub silently drops unassignable logins), marks the row
+ APPLIED/FAILED, and enqueues ``syncer.sync_pr`` on success. Returns ``(outcome, client,
+ record)`` with ``outcome`` in {"applied", "failed", "already_recorded"}.
+
+ Shared verbatim by the legacy apply sweep (doc 046), the acceptance-gate propose step's
+ auto/fallback direct-assign path, and the console accept handler (doc 050) so the GitHub
+ mutation and the ``ReviewerAssignmentApplication`` audit trail stay identical across all three.
+ """
+ owner = repository.owner
+ name = repository.name
+ record, created = ReviewerAssignmentApplication.objects.get_or_create(
+ run_date=run_date,
+ repository=repository,
+ pr_number=pr_number,
+ reviewer_login=login,
+ defaults={
+ "snapshot": snapshot,
+ "status": ReviewerAssignmentApplication.STATUS_PENDING,
+ },
+ )
+ if not created:
+ return ("already_recorded", assignment_client, None)
+
+ if assignment_client is None:
+ assignment_client = GitHubAssignmentClient(token=token)
+ login_norm = _normalize_login(login)
+ try:
+ resulting_assignees = assignment_client.assign(owner=owner, repo=name, number=pr_number, github_login=login)
+ except AssignmentMutationError as exc:
+ record.status = ReviewerAssignmentApplication.STATUS_FAILED
+ record.error = str(exc)[:2000]
+ record.save(update_fields=["status", "error", "updated_at"])
+ return ("failed", assignment_client, record)
+
+ # GitHub's "add assignees" endpoint silently ignores logins that are not assignable (e.g. no
+ # repo access): it returns 200 with the login absent rather than erroring. Confirm the login
+ # actually landed before recording success, so we never claim an assignment that did not take.
+ resulting_logins = {_normalize_login(assignee) for assignee in resulting_assignees if assignee}
+ if login_norm not in resulting_logins:
+ record.status = ReviewerAssignmentApplication.STATUS_FAILED
+ record.error = (
+ f"GitHub accepted the request but '{login}' was not in the resulting "
+ f"assignee set {sorted(resulting_logins)} (likely not an assignable user)."
+ )[:2000]
+ record.save(update_fields=["status", "error", "updated_at"])
+ return ("failed", assignment_client, record)
+
+ record.status = ReviewerAssignmentApplication.STATUS_APPLIED
+ record.applied_at = dj_timezone.now()
+ record.error = ""
+ record.save(update_fields=["status", "applied_at", "error", "updated_at"])
+ sync_enqueuer(owner, name, pr_number)
+ return ("applied", assignment_client, record)
+
+
def _empty_stats() -> dict[str, Any]:
return {
"candidates": 0,
@@ -104,15 +209,8 @@ def apply_assignments_for_repo(
"stats": _empty_stats(),
}
- rule_set = default_rule_set_for_repo(repository)
- cache_key = str(rule_set.id) if rule_set else "default"
+ snapshot, cache_key = latest_default_snapshot(repository)
result["cache_key"] = cache_key
-
- snapshot = (
- ReviewerAssignmentSnapshot.objects.filter(repository=repository, cache_key=cache_key)
- .order_by("-generated_at", "-id")
- .first()
- )
if snapshot is None:
result["status"] = "skipped"
result["reason"] = "no_snapshot"
@@ -127,18 +225,7 @@ def apply_assignments_for_repo(
result["snapshot_age_hours"] = round(age_seconds / 3600, 2)
return result
- raw = snapshot.payload.get("automatic_assignments", {}) or {}
- proposals: list[tuple[int, str]] = []
- for key, value in raw.items():
- try:
- pr_number = int(key)
- except (TypeError, ValueError):
- continue
- login = str(value).strip()
- if not login:
- continue
- proposals.append((pr_number, login))
- proposals.sort(key=lambda pair: pair[0])
+ proposals = parse_snapshot_assignments(snapshot)
stats = result["stats"]
stats["candidates"] = len(proposals)
@@ -258,43 +345,30 @@ def _record(
)
break
- record = _record(pr_number, login, ReviewerAssignmentApplication.STATUS_PENDING)
- if record is None:
- continue
- if assignment_client is None:
- assignment_client = GitHubAssignmentClient(token=token)
- try:
- resulting_assignees = assignment_client.assign(owner=owner, repo=name, number=pr_number, github_login=login)
- except AssignmentMutationError as exc:
- stats["failed"] += 1
- record.status = ReviewerAssignmentApplication.STATUS_FAILED
- record.error = str(exc)[:2000]
- record.save(update_fields=["status", "error", "updated_at"])
- continue
- # GitHub's "add assignees" endpoint silently ignores logins that are not
- # assignable (e.g. no repo access): it returns 200 with the login absent from
- # the resulting set rather than erroring. Confirm the login actually landed
- # before recording success, so we never claim an assignment that did not take.
- resulting_logins = {_normalize_login(assignee) for assignee in resulting_assignees if assignee}
- if login_norm not in resulting_logins:
+ outcome, assignment_client, _ = assign_reviewer_and_record(
+ repository=repository,
+ pr_number=pr_number,
+ login=login,
+ snapshot=snapshot,
+ run_date=run_date,
+ token=token,
+ assignment_client=assignment_client,
+ sync_enqueuer=sync_enqueuer,
+ )
+ if outcome == "applied":
+ stats["applied"] += 1
+ elif outcome == "failed":
stats["failed"] += 1
- record.status = ReviewerAssignmentApplication.STATUS_FAILED
- record.error = (
- f"GitHub accepted the request but '{login}' was not in the resulting "
- f"assignee set {sorted(resulting_logins)} (likely not an assignable user)."
- )[:2000]
- record.save(update_fields=["status", "error", "updated_at"])
- continue
- applied_at = dj_timezone.now()
- stats["applied"] += 1
- record.status = ReviewerAssignmentApplication.STATUS_APPLIED
- record.applied_at = applied_at
- record.error = ""
- record.save(update_fields=["status", "applied_at", "error", "updated_at"])
- sync_enqueuer(owner, name, pr_number)
+ else: # already_recorded: another writer created the row for this run first
+ stats["skipped_already_recorded"] += 1
result["status"] = "ok"
return result
-__all__ = ["apply_assignments_for_repo"]
+__all__ = [
+ "apply_assignments_for_repo",
+ "assign_reviewer_and_record",
+ "latest_default_snapshot",
+ "parse_snapshot_assignments",
+]
diff --git a/qb_site/analyzer/services/reviewer_assignment_propose.py b/qb_site/analyzer/services/reviewer_assignment_propose.py
new file mode 100644
index 00000000..f189ed80
--- /dev/null
+++ b/qb_site/analyzer/services/reviewer_assignment_propose.py
@@ -0,0 +1,292 @@
+"""Propose reviewer assignments through the acceptance gate (design doc 050).
+
+This is the batch half that the legacy ``apply_reviewer_assignments`` splits into. It reads the
+authoritative default-rule-set ``ReviewerAssignmentSnapshot`` (the same producer output apply
+consumed), re-validates each ``{pr_number: reviewer_login}`` pair against live state, then branches
+on the reviewer's ``ReviewerPreference.assignment_acceptance`` mode:
+
+- ``auto`` -> direct-assign on GitHub (the verbatim 046 mutation path).
+- ``confirm`` + reachable -> create an ``AssignmentProposal`` awaiting acceptance (no GitHub
+ write; the assignment is executed later by the console accept handler).
+- ``confirm`` + *unreachable* -> fall back to direct-assign. "Unreachable" means the reviewer has
+ no Zulip link at all (``User.zulip_user_id`` is null), never merely that notifications are muted.
+
+The mutation half (assign + ``ReviewerAssignmentApplication`` + ``sync_pr``) is reused verbatim via
+``assign_reviewer_and_record`` so auto/fallback assignments and (Chunk 6) console accepts share one
+implementation. Proposals are DB-only and therefore not counted against the GitHub per-repo cap.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timedelta
+from typing import Any
+
+from django.db import IntegrityError, transaction
+
+from analyzer.models import AssignmentProposal, ReviewerAssignmentApplication
+from analyzer.services.reviewer_assignment import _active_reviewer_logins, _opt_outs_for_prs, build_reviewer_catalog
+from analyzer.services.reviewer_assignment_apply import (
+ SyncEnqueuer,
+ TokenResolver,
+ _current_assignee_logins,
+ _default_sync_enqueuer,
+ assign_reviewer_and_record,
+ latest_default_snapshot,
+ parse_snapshot_assignments,
+)
+from analyzer.services.reviewer_assignment_engine import _normalize_login
+from core.models import Repository, ReviewerPreference
+from core.services.github_assignment import GitHubAssignmentClient
+from core.services.github_operation_tokens import resolve_github_app_operation_token
+from syncer.models import PullRequest
+
+log = logging.getLogger(__name__)
+
+MIN_WINDOW_DAYS = 7
+
+
+def _empty_stats() -> dict[str, Any]:
+ return {
+ "candidates": 0,
+ "proposed": 0,
+ "assigned_auto": 0,
+ "assigned_fallback": 0,
+ "failed": 0,
+ "skipped_already_proposed": 0,
+ "skipped_already_assigned": 0,
+ "skipped_opted_out": 0,
+ "skipped_ineligible": 0,
+ "skipped_recently_applied": 0,
+ "skipped_no_token": 0,
+ "skipped_dry_run": 0,
+ "skipped_disabled": 0,
+ "skipped_already_recorded": 0,
+ "capped": False,
+ "capped_remaining": 0,
+ }
+
+
+def _resolve_window_days(pref: ReviewerPreference, default: int) -> int:
+ """Per-reviewer acceptance window, clamped to >= 7 days (design doc 050, "≥7").
+
+ A reviewer may set ``notification_settings["assignment_proposal_window_days"]``; anything
+ smaller than a week (or non-numeric) is coerced up to the weekly floor.
+ """
+ raw = (pref.notification_settings or {}).get("assignment_proposal_window_days")
+ try:
+ value = int(raw) if raw is not None else int(default)
+ except (TypeError, ValueError):
+ value = int(default)
+ return max(MIN_WINDOW_DAYS, value)
+
+
+def _mode_and_reachability(repository: Repository) -> dict[str, tuple[str, bool, ReviewerPreference]]:
+ """Map normalized reviewer login -> (assignment_acceptance mode, reachable-on-Zulip, preference)."""
+ out: dict[str, tuple[str, bool, ReviewerPreference]] = {}
+ prefs = ReviewerPreference.objects.filter(repository=repository).select_related("user")
+ for pref in prefs:
+ login = getattr(pref.user, "github_login", None)
+ if not login:
+ continue
+ reachable = pref.user.zulip_user_id is not None
+ out[_normalize_login(login)] = (pref.assignment_acceptance, reachable, pref)
+ return out
+
+
+def propose_assignments_for_repo(
+ repository: Repository,
+ *,
+ run_date,
+ now: datetime,
+ enabled: bool,
+ dry_run: bool,
+ window_days: int,
+ dedupe_days: int,
+ max_age_hours: int,
+ max_per_repo: int,
+ token_resolver: TokenResolver = resolve_github_app_operation_token,
+ assignment_client: GitHubAssignmentClient | None = None,
+ sync_enqueuer: SyncEnqueuer = _default_sync_enqueuer,
+) -> dict[str, Any]:
+ """Propose (or, for auto/fallback reviewers, directly assign) the latest snapshot for a repo.
+
+ Returns a concise per-repo summary for task aggregation / admin debugging. ``dry_run`` is fully
+ side-effect-free (no proposal rows, no GitHub writes, no application rows) — it only counts what
+ would happen. When neither ``enabled`` nor ``dry_run`` the caller should not invoke this.
+ """
+ owner = repository.owner
+ name = repository.name
+ result: dict[str, Any] = {
+ "repo": f"{owner}/{name}",
+ "repo_id": int(repository.id),
+ "stats": _empty_stats(),
+ }
+
+ snapshot, cache_key = latest_default_snapshot(repository)
+ result["cache_key"] = cache_key
+ if snapshot is None:
+ result["status"] = "skipped"
+ result["reason"] = "no_snapshot"
+ return result
+
+ age_seconds = (now - snapshot.generated_at).total_seconds()
+ result["snapshot_id"] = int(snapshot.id)
+ result["snapshot_generated_at"] = snapshot.generated_at.isoformat()
+ if max_age_hours > 0 and age_seconds > max_age_hours * 3600:
+ result["status"] = "skipped"
+ result["reason"] = "stale_snapshot"
+ result["snapshot_age_hours"] = round(age_seconds / 3600, 2)
+ return result
+
+ proposals = parse_snapshot_assignments(snapshot)
+ stats = result["stats"]
+ stats["candidates"] = len(proposals)
+ if not proposals:
+ result["status"] = "ok"
+ return result
+
+ pr_numbers = [pr_number for pr_number, _ in proposals]
+ eligible_logins = _active_reviewer_logins(build_reviewer_catalog(repository, now=now))
+ opt_outs = _opt_outs_for_prs(repository, pr_numbers)
+ mode_by_login = _mode_and_reachability(repository)
+ live_by_number = {
+ int(pr.number): pr
+ for pr in PullRequest.objects.filter(repository=repository, number__in=pr_numbers).only("number", "state", "assignees")
+ }
+ # PRs that already carry an active proposal (belt-and-suspenders: the builder already excludes
+ # them from the snapshot, but a concurrent run could have created one in between).
+ prs_with_active_proposal = {
+ int(pr_number)
+ for pr_number in AssignmentProposal.objects.filter(
+ repository=repository,
+ pr_number__in=pr_numbers,
+ state=AssignmentProposal.STATE_PROPOSED,
+ ).values_list("pr_number", flat=True)
+ }
+
+ dedupe_cutoff = now - timedelta(days=dedupe_days) if dedupe_days > 0 else None
+ recently_applied: set[tuple[int, str]] = set()
+ if dedupe_cutoff is not None:
+ rows = ReviewerAssignmentApplication.objects.filter(
+ repository=repository,
+ pr_number__in=pr_numbers,
+ status=ReviewerAssignmentApplication.STATUS_APPLIED,
+ applied_at__gte=dedupe_cutoff,
+ ).values_list("pr_number", "reviewer_login")
+ recently_applied = {(int(pr_number), _normalize_login(login)) for pr_number, login in rows}
+
+ token: str | None = None
+ token_attempted = False
+
+ def _direct_assign(pr_number: int, login: str, *, route: str) -> None:
+ """Run the 046 direct-assign path for an auto/fallback reviewer.
+
+ The per-repo cap bounds GitHub mutations only, so once it is hit we skip further
+ direct-assigns (leaving them unrecorded for the next run) but keep iterating so DB-only
+ proposals later in the batch are still created.
+ """
+ nonlocal token, token_attempted, assignment_client
+ if (pr_number, _normalize_login(login)) in recently_applied:
+ stats["skipped_recently_applied"] += 1
+ return
+ if dry_run:
+ stats["skipped_dry_run"] += 1
+ return
+ if not enabled:
+ stats["skipped_disabled"] += 1
+ return
+ if max_per_repo > 0 and (stats["assigned_auto"] + stats["assigned_fallback"] + stats["failed"]) >= max_per_repo:
+ stats["capped"] = True
+ stats["capped_remaining"] += 1
+ return
+ if not token_attempted:
+ token = token_resolver(operation="assign_pr", owner=owner, repo=name)
+ token_attempted = True
+ if not token:
+ stats["skipped_no_token"] += 1
+ return
+ outcome, assignment_client, _ = assign_reviewer_and_record(
+ repository=repository,
+ pr_number=pr_number,
+ login=login,
+ snapshot=snapshot,
+ run_date=run_date,
+ token=token,
+ assignment_client=assignment_client,
+ sync_enqueuer=sync_enqueuer,
+ )
+ if outcome == "applied":
+ stats["assigned_auto" if route == "auto" else "assigned_fallback"] += 1
+ elif outcome == "failed":
+ stats["failed"] += 1
+ else: # already_recorded
+ stats["skipped_already_recorded"] += 1
+
+ def _create_proposal(pr_number: int, login: str, pref: ReviewerPreference) -> None:
+ if dry_run:
+ stats["skipped_dry_run"] += 1
+ return
+ if not enabled:
+ stats["skipped_disabled"] += 1
+ return
+ expires_at = now + timedelta(days=_resolve_window_days(pref, window_days))
+ try:
+ with transaction.atomic():
+ AssignmentProposal.objects.create(
+ repository=repository,
+ pr_number=pr_number,
+ reviewer_login=login,
+ snapshot=snapshot,
+ state=AssignmentProposal.STATE_PROPOSED,
+ expires_at=expires_at,
+ )
+ stats["proposed"] += 1
+ except IntegrityError:
+ # A concurrent writer created the one-active-proposal-per-PR row first.
+ stats["skipped_already_proposed"] += 1
+
+ for pr_number, login in proposals:
+ login_norm = _normalize_login(login)
+ live_pr = live_by_number.get(pr_number)
+
+ # --- Re-validate the proposal against live state (snapshot may be ~1 day old). ---
+ if login_norm not in eligible_logins:
+ stats["skipped_ineligible"] += 1
+ continue
+ if login_norm in opt_outs.get(pr_number, set()):
+ stats["skipped_opted_out"] += 1
+ continue
+ not_open = live_pr is not None and str(live_pr.state).strip().lower() != "open"
+ current_assignees = _current_assignee_logins(live_pr)
+ if not_open or (current_assignees & eligible_logins) or (login_norm in current_assignees):
+ stats["skipped_already_assigned"] += 1
+ continue
+ if pr_number in prs_with_active_proposal:
+ stats["skipped_already_proposed"] += 1
+ continue
+
+ entry = mode_by_login.get(login_norm)
+ if entry is None:
+ # Login left the reviewer catalog since compute; treat as ineligible (defensive).
+ stats["skipped_ineligible"] += 1
+ continue
+ mode, reachable, pref = entry
+
+ if mode == ReviewerPreference.ACCEPTANCE_AUTO:
+ route = "auto"
+ elif not reachable:
+ route = "fallback"
+ else:
+ route = "propose"
+
+ if route == "propose":
+ _create_proposal(pr_number, login, pref)
+ else:
+ _direct_assign(pr_number, login, route=route)
+
+ result["status"] = "ok"
+ return result
+
+
+__all__ = ["propose_assignments_for_repo"]
diff --git a/qb_site/analyzer/tasks/__init__.py b/qb_site/analyzer/tasks/__init__.py
index fa231af9..66442f83 100644
--- a/qb_site/analyzer/tasks/__init__.py
+++ b/qb_site/analyzer/tasks/__init__.py
@@ -29,6 +29,8 @@
refresh_area_stats_task,
)
from analyzer.tasks.reviewer_assignment_apply import apply_reviewer_assignments_task
+from analyzer.tasks.reviewer_assignment_propose import propose_reviewer_assignments_task
+from analyzer.tasks.assignment_proposal_expiry import expire_assignment_proposals_task
log = logging.getLogger(__name__)
@@ -160,4 +162,6 @@ def process_pr_task(pr_id: int) -> Dict[str, Any]:
"build_area_stats",
"refresh_area_stats_task",
"apply_reviewer_assignments_task",
+ "propose_reviewer_assignments_task",
+ "expire_assignment_proposals_task",
]
diff --git a/qb_site/analyzer/tasks/assignment_proposal_expiry.py b/qb_site/analyzer/tasks/assignment_proposal_expiry.py
new file mode 100644
index 00000000..d104b5dd
--- /dev/null
+++ b/qb_site/analyzer/tasks/assignment_proposal_expiry.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from celery import shared_task
+from django.utils import timezone
+
+from analyzer.services.assignment_proposal_expiry import expire_and_reconcile_proposals_for_repo
+from core.models import Repository
+
+log = logging.getLogger(__name__)
+
+
+@shared_task(name="analyzer.expire_assignment_proposals")
+def expire_assignment_proposals_task(
+ *,
+ repository_id: int | None = None,
+ include_inactive_repositories: bool = False,
+) -> dict[str, Any]:
+ """Expire timed-out proposals and supersede those whose PR left the queue (design doc 050).
+
+ Essential maintenance for the acceptance gate: it is intentionally NOT gated by the master
+ switch, so existing proposals keep draining even after the gate is turned off. Performs no
+ GitHub writes; only transitions ``AssignmentProposal`` state via the ``proposal_validity``
+ authority. Idempotent and safe to run frequently.
+ """
+ repos_qs = Repository.objects.only("id", "owner", "name")
+ if not include_inactive_repositories:
+ repos_qs = repos_qs.filter(is_active=True)
+ if repository_id is not None:
+ repos_qs = repos_qs.filter(id=int(repository_id))
+ repos = list(repos_qs.order_by("owner", "name", "id"))
+
+ if repository_id is not None and not repos:
+ return {"skipped": True, "reason": "repo_not_found_or_inactive", "repository_id": int(repository_id)}
+
+ now = timezone.now()
+ per_repo: list[dict[str, Any]] = []
+ totals: dict[str, int] = {}
+ repos_errored = 0
+
+ for repo in repos:
+ try:
+ repo_result = expire_and_reconcile_proposals_for_repo(repo, now=now)
+ except Exception as exc: # defensive: one repo's failure must not abort the whole sweep
+ repos_errored += 1
+ log.exception("analyzer.expire_assignment_proposals: repo failed repo=%s/%s", repo.owner, repo.name)
+ repo_result = {
+ "repo": f"{repo.owner}/{repo.name}",
+ "repo_id": int(repo.id),
+ "status": "error",
+ "error": str(exc)[:2000],
+ "stats": {},
+ }
+ per_repo.append(repo_result)
+ for key, value in repo_result.get("stats", {}).items():
+ totals[key] = int(totals.get(key, 0)) + int(value)
+
+ result = {
+ "skipped": False,
+ "include_inactive_repositories": bool(include_inactive_repositories),
+ "repository_id": int(repository_id) if repository_id is not None else None,
+ "repos": len(repos),
+ "repos_errored": repos_errored,
+ "run_at": now.isoformat(),
+ "totals": totals,
+ "per_repo": per_repo,
+ }
+ log.info(
+ "analyzer.expire_assignment_proposals: repos=%s errored=%s expired=%s superseded=%s still_live=%s",
+ len(repos),
+ repos_errored,
+ totals.get("expired", 0),
+ totals.get("superseded", 0),
+ totals.get("still_live", 0),
+ )
+ return result
+
+
+__all__ = ["expire_assignment_proposals_task"]
diff --git a/qb_site/analyzer/tasks/reviewer_assignment_propose.py b/qb_site/analyzer/tasks/reviewer_assignment_propose.py
new file mode 100644
index 00000000..c4e60cf7
--- /dev/null
+++ b/qb_site/analyzer/tasks/reviewer_assignment_propose.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from celery import shared_task
+from django.conf import settings
+from django.utils import timezone
+
+from analyzer.services.reviewer_assignment_propose import propose_assignments_for_repo
+from core.models import Repository
+
+log = logging.getLogger(__name__)
+
+
+def _aggregate_key(totals: dict[str, Any], stats: dict[str, Any]) -> None:
+ for key, value in stats.items():
+ if key == "capped":
+ totals["capped"] = totals.get("capped", False) or bool(value)
+ continue
+ totals[key] = int(totals.get(key, 0)) + int(value)
+
+
+@shared_task(name="analyzer.propose_reviewer_assignments")
+def propose_reviewer_assignments_task(
+ *,
+ repository_id: int | None = None,
+ include_inactive_repositories: bool = False,
+ enabled_override: bool | None = None,
+ dry_run_override: bool | None = None,
+) -> dict[str, Any]:
+ """Propose reviewer assignments through the acceptance gate for all active repositories.
+
+ Per ``(pr_number -> reviewer_login)`` from each repo's authoritative default-rule-set snapshot,
+ branch on the reviewer's ``assignment_acceptance`` mode: ``auto`` (or ``confirm`` with no
+ reachable Zulip channel) direct-assigns via the ``assign_pr`` operation token exactly like the
+ legacy apply task; ``confirm`` with a Zulip link creates an ``AssignmentProposal`` awaiting
+ acceptance. Gated by ``ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED`` (+ dry-run); a cheap no-op
+ otherwise. This supersedes ``analyzer.apply_reviewer_assignments`` — run one or the other.
+ """
+ enabled = (
+ bool(enabled_override)
+ if enabled_override is not None
+ else bool(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED", False))
+ )
+ dry_run = (
+ bool(dry_run_override)
+ if dry_run_override is not None
+ else bool(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN", False))
+ )
+
+ if not enabled and not dry_run:
+ return {"skipped": True, "reason": "feature_disabled", "enabled": enabled, "dry_run": dry_run}
+
+ window_days = int(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSAL_WINDOW_DAYS", 7))
+ # Reuse the apply task's mutation-safety knobs for the direct-assign (auto/fallback) path so the
+ # two rollout modes bound GitHub exposure identically.
+ dedupe_days = int(getattr(settings, "ANALYZER_REVIEWER_ASSIGNMENT_APPLY_DEDUPE_DAYS", 7))
+ max_age_hours = int(getattr(settings, "ANALYZER_REVIEWER_ASSIGNMENT_APPLY_MAX_AGE_HOURS", 48))
+ max_per_repo = int(getattr(settings, "ANALYZER_REVIEWER_ASSIGNMENT_APPLY_MAX_PER_REPO", 25))
+
+ repos_qs = Repository.objects.only("id", "owner", "name")
+ if not include_inactive_repositories:
+ repos_qs = repos_qs.filter(is_active=True)
+ if repository_id is not None:
+ repos_qs = repos_qs.filter(id=int(repository_id))
+ repos = list(repos_qs.order_by("owner", "name", "id"))
+
+ if repository_id is not None and not repos:
+ return {"skipped": True, "reason": "repo_not_found_or_inactive", "repository_id": int(repository_id)}
+
+ now = timezone.now()
+ run_date = now.date()
+ per_repo: list[dict[str, Any]] = []
+ totals: dict[str, Any] = {}
+ repos_errored = 0
+
+ for repo in repos:
+ try:
+ repo_result = propose_assignments_for_repo(
+ repo,
+ run_date=run_date,
+ now=now,
+ enabled=enabled,
+ dry_run=dry_run,
+ window_days=window_days,
+ dedupe_days=dedupe_days,
+ max_age_hours=max_age_hours,
+ max_per_repo=max_per_repo,
+ )
+ except Exception as exc: # defensive: one repo's failure must not abort the whole sweep
+ repos_errored += 1
+ log.exception(
+ "analyzer.propose_reviewer_assignments: repo failed repo=%s/%s",
+ repo.owner,
+ repo.name,
+ )
+ repo_result = {
+ "repo": f"{repo.owner}/{repo.name}",
+ "repo_id": int(repo.id),
+ "status": "error",
+ "error": str(exc)[:2000],
+ "stats": {},
+ }
+ per_repo.append(repo_result)
+ _aggregate_key(totals, repo_result.get("stats", {}))
+
+ result = {
+ "skipped": False,
+ "enabled": enabled,
+ "dry_run": dry_run,
+ "include_inactive_repositories": bool(include_inactive_repositories),
+ "repository_id": int(repository_id) if repository_id is not None else None,
+ "repos": len(repos),
+ "repos_errored": repos_errored,
+ "run_date": run_date.isoformat(),
+ "window_days": window_days,
+ "dedupe_days": dedupe_days,
+ "max_age_hours": max_age_hours,
+ "max_per_repo": max_per_repo,
+ "totals": totals,
+ "per_repo": per_repo,
+ }
+
+ log.info(
+ "analyzer.propose_reviewer_assignments: repos=%s errored=%s proposed=%s assigned=%s failed=%s dry_run=%s enabled=%s",
+ len(repos),
+ repos_errored,
+ totals.get("proposed", 0),
+ totals.get("assigned_auto", 0) + totals.get("assigned_fallback", 0),
+ totals.get("failed", 0),
+ dry_run,
+ enabled,
+ )
+ return result
+
+
+__all__ = ["propose_reviewer_assignments_task"]
diff --git a/qb_site/analyzer/tests/services/test_assignment_proposal_expiry.py b/qb_site/analyzer/tests/services/test_assignment_proposal_expiry.py
new file mode 100644
index 00000000..13a5cdfe
--- /dev/null
+++ b/qb_site/analyzer/tests/services/test_assignment_proposal_expiry.py
@@ -0,0 +1,208 @@
+from __future__ import annotations
+
+from datetime import timedelta
+
+from django.test import TestCase, override_settings
+from django.utils import timezone
+
+from analyzer.models import AssignmentProposal, QueueRuleSet, QueueSnapshot
+from analyzer.services.assignment_proposal_expiry import expire_and_reconcile_proposals_for_repo
+from analyzer.tasks.assignment_proposal_expiry import expire_assignment_proposals_task
+from core.models import Repository
+from syncer.models import PullRequest
+from syncer.models.pull_request import PullRequestState
+
+
+class ExpireAndReconcileProposalsTests(TestCase):
+ def setUp(self) -> None:
+ self.repo = Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master")
+ self.rule_set = QueueRuleSet.objects.create(repository=self.repo, version=1, is_default=True, is_active=True)
+ self.cache_key = str(self.rule_set.id)
+ self.now = timezone.now()
+
+ def _proposal(self, pr_number, *, login="bob", state=AssignmentProposal.STATE_PROPOSED, expires_in_days=7):
+ return AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=pr_number,
+ reviewer_login=login,
+ state=state,
+ expires_at=self.now + timedelta(days=expires_in_days),
+ )
+
+ def _make_pr(self, number, *, state=PullRequestState.OPEN, assignees=None):
+ return PullRequest.objects.create(
+ repository=self.repo,
+ number=number,
+ state=state,
+ is_draft=False,
+ gh_created_at=self.now,
+ gh_updated_at=self.now,
+ base_ref_name="master",
+ head_ref_name=f"branch-{number}",
+ head_repo_owner_login="leanprover-community",
+ head_repo_name="mathlib4",
+ title=f"PR {number}",
+ body="b",
+ additions=1,
+ deletions=0,
+ changed_files_count=1,
+ assignees=list(assignees or []),
+ )
+
+ def _make_queue_snapshot(self, *, queue, known, generated_at=None):
+ QueueSnapshot.objects.create(
+ repository=self.repo,
+ cache_key=self.cache_key,
+ generated_at=generated_at or self.now,
+ payload={"lists": {"dashboards": {"Queue": list(queue)}}, "prs": {str(n): {} for n in known}},
+ etag="etag",
+ pr_count=len(known),
+ queue_count=len(queue),
+ )
+
+ def _run(self):
+ return expire_and_reconcile_proposals_for_repo(self.repo, now=self.now)
+
+ # ---- transitions ---------------------------------------------------
+
+ def test_time_expired_proposal_becomes_expired(self) -> None:
+ proposal = self._proposal(101, expires_in_days=-1)
+ self._make_pr(101)
+
+ result = self._run()
+
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_EXPIRED)
+ self.assertEqual(proposal.decided_via, AssignmentProposal.DECIDED_VIA_AUTO_EXPIRE)
+ self.assertEqual(proposal.decided_at, self.now)
+ self.assertEqual(result["stats"]["expired"], 1)
+
+ def test_closed_pr_supersedes_proposal(self) -> None:
+ proposal = self._proposal(102)
+ self._make_pr(102, state=PullRequestState.CLOSED)
+
+ result = self._run()
+
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_SUPERSEDED)
+ self.assertEqual(proposal.decided_via, AssignmentProposal.DECIDED_VIA_SYNC_SUPERSEDED)
+ self.assertEqual(result["stats"]["superseded"], 1)
+
+ def test_human_assignee_supersedes_proposal(self) -> None:
+ proposal = self._proposal(103)
+ self._make_pr(103, assignees=["human"])
+
+ result = self._run()
+
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_SUPERSEDED)
+ self.assertEqual(result["stats"]["superseded"], 1)
+
+ def test_live_proposal_untouched(self) -> None:
+ proposal = self._proposal(104, expires_in_days=7)
+ self._make_pr(104)
+ self._make_queue_snapshot(queue=[104], known=[104])
+
+ result = self._run()
+
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_PROPOSED)
+ self.assertIsNone(proposal.decided_at)
+ self.assertEqual(result["stats"]["still_live"], 1)
+
+ def test_off_queue_invalidate_supersedes_with_fresh_snapshot(self) -> None:
+ proposal = self._proposal(105)
+ self._make_pr(105)
+ # PR is known to the fresh snapshot but not on the Queue -> off-queue.
+ self._make_queue_snapshot(queue=[999], known=[105, 999])
+
+ result = self._run()
+
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_SUPERSEDED)
+ self.assertEqual(result["stats"]["superseded"], 1)
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT="retain")
+ def test_off_queue_retain_keeps_proposal_live(self) -> None:
+ proposal = self._proposal(106)
+ self._make_pr(106)
+ self._make_queue_snapshot(queue=[999], known=[106, 999])
+
+ result = self._run()
+
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_PROPOSED)
+ self.assertEqual(result["stats"]["still_live"], 1)
+
+ def test_off_queue_ignored_when_snapshot_stale(self) -> None:
+ # A stale snapshot must not be trusted for off-queue invalidation.
+ proposal = self._proposal(107)
+ self._make_pr(107)
+ self._make_queue_snapshot(queue=[999], known=[107, 999], generated_at=self.now - timedelta(days=3))
+
+ result = self._run()
+
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_PROPOSED)
+ self.assertEqual(result["stats"]["still_live"], 1)
+
+ def test_terminal_proposals_are_not_touched(self) -> None:
+ # Only 'proposed' rows are considered; terminal history is left alone.
+ declined = self._proposal(108, state=AssignmentProposal.STATE_DECLINED, expires_in_days=-5)
+ self._make_pr(108, state=PullRequestState.CLOSED)
+
+ result = self._run()
+
+ declined.refresh_from_db()
+ self.assertEqual(declined.state, AssignmentProposal.STATE_DECLINED)
+ self.assertEqual(result["stats"]["active"], 0)
+
+
+class ExpireAssignmentProposalsTaskTests(TestCase):
+ def setUp(self) -> None:
+ self.repo = Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master")
+ self.now = timezone.now()
+
+ def _make_pr(self, number, *, state=PullRequestState.OPEN):
+ return PullRequest.objects.create(
+ repository=self.repo,
+ number=number,
+ state=state,
+ is_draft=False,
+ gh_created_at=self.now,
+ gh_updated_at=self.now,
+ base_ref_name="master",
+ head_ref_name=f"branch-{number}",
+ head_repo_owner_login="leanprover-community",
+ head_repo_name="mathlib4",
+ title=f"PR {number}",
+ body="b",
+ additions=1,
+ deletions=0,
+ changed_files_count=1,
+ assignees=[],
+ )
+
+ def test_task_expires_across_active_repos(self) -> None:
+ # Ungated: the sweep runs regardless of the master switch.
+ AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=101,
+ reviewer_login="bob",
+ state=AssignmentProposal.STATE_PROPOSED,
+ expires_at=self.now - timedelta(days=1),
+ )
+ self._make_pr(101)
+
+ res = expire_assignment_proposals_task.apply().get()
+
+ self.assertFalse(res["skipped"])
+ self.assertEqual(res["totals"]["expired"], 1)
+ proposal = AssignmentProposal.objects.get(repository=self.repo, pr_number=101)
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_EXPIRED)
+
+ def test_task_repo_filter_miss(self) -> None:
+ res = expire_assignment_proposals_task.apply(kwargs={"repository_id": 999999}).get()
+
+ self.assertTrue(res["skipped"])
+ self.assertEqual(res["reason"], "repo_not_found_or_inactive")
diff --git a/qb_site/analyzer/tests/services/test_assignment_proposal_validity.py b/qb_site/analyzer/tests/services/test_assignment_proposal_validity.py
new file mode 100644
index 00000000..405977ff
--- /dev/null
+++ b/qb_site/analyzer/tests/services/test_assignment_proposal_validity.py
@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+from django.test import SimpleTestCase
+
+from analyzer.models import AssignmentProposal
+from analyzer.services.assignment_proposal_validity import (
+ ON_QUEUE_EXIT_INVALIDATE,
+ ON_QUEUE_EXIT_RETAIN,
+ REASON_ALREADY_TERMINAL,
+ REASON_EXPIRED,
+ REASON_LIVE,
+ REASON_PR_ASSIGNED,
+ REASON_PR_CLOSED,
+ REASON_PR_OFF_QUEUE,
+ proposal_validity,
+)
+
+
+class ProposalValidityTests(SimpleTestCase):
+ def setUp(self) -> None:
+ self.now = datetime(2026, 1, 15, tzinfo=timezone.utc)
+
+ def _proposal(self, *, state=AssignmentProposal.STATE_PROPOSED, expires_in_days: float = 7) -> AssignmentProposal:
+ # Unsaved instance — proposal_validity is a pure predicate over durable facts.
+ return AssignmentProposal(
+ pr_number=1,
+ reviewer_login="alice",
+ state=state,
+ expires_at=self.now + timedelta(days=expires_in_days),
+ )
+
+ def test_live_when_open_on_queue_unexpired_unassigned(self) -> None:
+ v = proposal_validity(
+ self._proposal(),
+ now=self.now,
+ pr_state="open",
+ current_assignees=set(),
+ on_queue=True,
+ on_queue_exit=ON_QUEUE_EXIT_INVALIDATE,
+ )
+ self.assertTrue(v.is_live)
+ self.assertEqual(v.reason, REASON_LIVE)
+ self.assertIsNone(v.terminal_state)
+
+ def test_expired_when_past_window(self) -> None:
+ v = proposal_validity(
+ self._proposal(expires_in_days=-1),
+ now=self.now,
+ pr_state="open",
+ current_assignees=set(),
+ on_queue=True,
+ on_queue_exit=ON_QUEUE_EXIT_INVALIDATE,
+ )
+ self.assertFalse(v.is_live)
+ self.assertEqual(v.reason, REASON_EXPIRED)
+ self.assertEqual(v.terminal_state, AssignmentProposal.STATE_EXPIRED)
+ self.assertEqual(v.decided_via, AssignmentProposal.DECIDED_VIA_AUTO_EXPIRE)
+
+ def test_superseded_when_closed(self) -> None:
+ for state in ("closed", "merged"):
+ v = proposal_validity(
+ self._proposal(),
+ now=self.now,
+ pr_state=state,
+ current_assignees=set(),
+ on_queue=True,
+ on_queue_exit=ON_QUEUE_EXIT_INVALIDATE,
+ )
+ self.assertFalse(v.is_live, state)
+ self.assertEqual(v.reason, REASON_PR_CLOSED)
+ self.assertEqual(v.terminal_state, AssignmentProposal.STATE_SUPERSEDED)
+ self.assertEqual(v.decided_via, AssignmentProposal.DECIDED_VIA_SYNC_SUPERSEDED)
+
+ def test_superseded_when_assignee_landed(self) -> None:
+ # A human/self-assignee landing supersedes even an unexpired, on-queue proposal.
+ v = proposal_validity(
+ self._proposal(),
+ now=self.now,
+ pr_state="open",
+ current_assignees={"human"},
+ on_queue=True,
+ on_queue_exit=ON_QUEUE_EXIT_INVALIDATE,
+ )
+ self.assertFalse(v.is_live)
+ self.assertEqual(v.reason, REASON_PR_ASSIGNED)
+ self.assertEqual(v.terminal_state, AssignmentProposal.STATE_SUPERSEDED)
+
+ def test_assignee_takes_precedence_over_expiry(self) -> None:
+ v = proposal_validity(
+ self._proposal(expires_in_days=-5),
+ now=self.now,
+ pr_state="open",
+ current_assignees={"human"},
+ on_queue=True,
+ )
+ self.assertEqual(v.reason, REASON_PR_ASSIGNED)
+
+ def test_closed_takes_precedence_over_expiry(self) -> None:
+ v = proposal_validity(
+ self._proposal(expires_in_days=-5),
+ now=self.now,
+ pr_state="closed",
+ current_assignees=set(),
+ on_queue=None,
+ )
+ self.assertEqual(v.reason, REASON_PR_CLOSED)
+
+ def test_off_queue_invalidate_supersedes(self) -> None:
+ v = proposal_validity(
+ self._proposal(),
+ now=self.now,
+ pr_state="open",
+ current_assignees=set(),
+ on_queue=False,
+ on_queue_exit=ON_QUEUE_EXIT_INVALIDATE,
+ )
+ self.assertFalse(v.is_live)
+ self.assertEqual(v.reason, REASON_PR_OFF_QUEUE)
+ self.assertEqual(v.terminal_state, AssignmentProposal.STATE_SUPERSEDED)
+
+ def test_off_queue_retain_stays_live(self) -> None:
+ v = proposal_validity(
+ self._proposal(),
+ now=self.now,
+ pr_state="open",
+ current_assignees=set(),
+ on_queue=False,
+ on_queue_exit=ON_QUEUE_EXIT_RETAIN,
+ )
+ self.assertTrue(v.is_live)
+ self.assertEqual(v.reason, REASON_LIVE)
+
+ def test_unknown_queue_membership_does_not_invalidate(self) -> None:
+ # on_queue=None (no fresh snapshot) must not supersede an otherwise-live proposal.
+ v = proposal_validity(
+ self._proposal(),
+ now=self.now,
+ pr_state="open",
+ current_assignees=set(),
+ on_queue=None,
+ on_queue_exit=ON_QUEUE_EXIT_INVALIDATE,
+ )
+ self.assertTrue(v.is_live)
+
+ def test_terminal_proposal_is_not_live_and_needs_no_transition(self) -> None:
+ for state in (
+ AssignmentProposal.STATE_ACCEPTED,
+ AssignmentProposal.STATE_DECLINED,
+ AssignmentProposal.STATE_EXPIRED,
+ AssignmentProposal.STATE_SUPERSEDED,
+ ):
+ v = proposal_validity(
+ self._proposal(state=state, expires_in_days=-10),
+ now=self.now,
+ pr_state="closed",
+ current_assignees={"human"},
+ on_queue=False,
+ )
+ self.assertFalse(v.is_live, state)
+ self.assertEqual(v.reason, REASON_ALREADY_TERMINAL)
+ self.assertIsNone(v.terminal_state)
diff --git a/qb_site/analyzer/tests/services/test_reviewer_assignment_propose.py b/qb_site/analyzer/tests/services/test_reviewer_assignment_propose.py
new file mode 100644
index 00000000..f8b425cc
--- /dev/null
+++ b/qb_site/analyzer/tests/services/test_reviewer_assignment_propose.py
@@ -0,0 +1,377 @@
+from __future__ import annotations
+
+from datetime import timedelta
+from unittest.mock import MagicMock
+
+from django.test import TestCase
+from django.utils import timezone
+
+from analyzer.models import (
+ AssignmentProposal,
+ QueueRuleSet,
+ ReviewerAssignmentApplication,
+ ReviewerAssignmentSnapshot,
+ ReviewerOptOut,
+)
+from analyzer.services.reviewer_assignment_propose import propose_assignments_for_repo
+from core.models import Repository, ReviewerPreference, User
+from syncer.models import PullRequest
+from syncer.models.pull_request import PullRequestState
+
+
+class ProposeAssignmentsForRepoTests(TestCase):
+ def setUp(self) -> None:
+ self.repo = Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master")
+ self.rule_set = QueueRuleSet.objects.create(repository=self.repo, version=1, is_default=True, is_active=True)
+ self.cache_key = str(self.rule_set.id)
+ self.now = timezone.now()
+ self.run_date = self.now.date()
+ self._zulip_seq = 1000
+
+ # ---- helpers -------------------------------------------------------
+
+ def _make_reviewer(
+ self,
+ login: str,
+ *,
+ acceptance: str = ReviewerPreference.ACCEPTANCE_CONFIRM,
+ reachable: bool = True,
+ auto_assign: bool = True,
+ ) -> User:
+ zulip_user_id = None
+ if reachable:
+ self._zulip_seq += 1
+ zulip_user_id = self._zulip_seq
+ user = User.objects.create(github_login=login, zulip_user_id=zulip_user_id)
+ ReviewerPreference.objects.create(
+ repository=self.repo,
+ user=user,
+ auto_assign=auto_assign,
+ assignment_acceptance=acceptance,
+ )
+ return user
+
+ def _make_pr(self, number: int, *, assignees=None, state=PullRequestState.OPEN) -> PullRequest:
+ return PullRequest.objects.create(
+ repository=self.repo,
+ number=number,
+ state=state,
+ is_draft=False,
+ gh_created_at=self.now,
+ gh_updated_at=self.now,
+ base_ref_name="master",
+ head_ref_name=f"branch-{number}",
+ head_repo_owner_login="leanprover-community",
+ head_repo_name="mathlib4",
+ title=f"PR {number}",
+ body="b",
+ additions=1,
+ deletions=0,
+ changed_files_count=1,
+ assignees=list(assignees or []),
+ )
+
+ def _make_snapshot(self, assignments: dict[int, str], *, cache_key=None, generated_at=None) -> ReviewerAssignmentSnapshot:
+ return ReviewerAssignmentSnapshot.objects.create(
+ repository=self.repo,
+ cache_key=cache_key or self.cache_key,
+ generated_at=generated_at or self.now,
+ payload={"meta": {}, "automatic_assignments": {str(k): v for k, v in assignments.items()}},
+ etag="etag",
+ assignment_count=len(assignments),
+ )
+
+ def _propose(
+ self,
+ *,
+ enabled=True,
+ dry_run=False,
+ window_days=7,
+ max_per_repo=0,
+ dedupe_days=7,
+ max_age_hours=48,
+ client=None,
+ token="tok",
+ ):
+ if client is None:
+ client = MagicMock()
+ client.assign.side_effect = lambda **kwargs: (kwargs["github_login"],)
+ sync = MagicMock()
+ result = propose_assignments_for_repo(
+ self.repo,
+ run_date=self.run_date,
+ now=self.now,
+ enabled=enabled,
+ dry_run=dry_run,
+ window_days=window_days,
+ dedupe_days=dedupe_days,
+ max_age_hours=max_age_hours,
+ max_per_repo=max_per_repo,
+ token_resolver=lambda **kwargs: token,
+ assignment_client=client,
+ sync_enqueuer=sync,
+ )
+ return result, client, sync
+
+ # ---- per-mode branching -------------------------------------------
+
+ def test_auto_mode_direct_assigns(self) -> None:
+ self._make_reviewer("alice", acceptance=ReviewerPreference.ACCEPTANCE_AUTO, reachable=False)
+ self._make_snapshot({101: "alice"})
+ self._make_pr(101)
+
+ result, client, sync = self._propose()
+
+ self.assertEqual(result["stats"]["assigned_auto"], 1)
+ self.assertEqual(result["stats"]["proposed"], 0)
+ client.assign.assert_called_once_with(owner="leanprover-community", repo="mathlib4", number=101, github_login="alice")
+ sync.assert_called_once_with("leanprover-community", "mathlib4", 101)
+ self.assertFalse(AssignmentProposal.objects.filter(repository=self.repo, pr_number=101).exists())
+ record = ReviewerAssignmentApplication.objects.get(repository=self.repo, pr_number=101, reviewer_login="alice")
+ self.assertEqual(record.status, ReviewerAssignmentApplication.STATUS_APPLIED)
+
+ def test_confirm_reachable_creates_proposal(self) -> None:
+ self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ snapshot = self._make_snapshot({102: "bob"})
+ self._make_pr(102)
+
+ result, client, sync = self._propose(window_days=7)
+
+ self.assertEqual(result["stats"]["proposed"], 1)
+ self.assertEqual(result["stats"]["assigned_auto"], 0)
+ client.assign.assert_not_called()
+ sync.assert_not_called()
+ proposal = AssignmentProposal.objects.get(repository=self.repo, pr_number=102)
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_PROPOSED)
+ self.assertEqual(proposal.reviewer_login, "bob")
+ self.assertEqual(proposal.snapshot_id, snapshot.id)
+ self.assertEqual(proposal.expires_at, self.now + timedelta(days=7))
+ # A proposal is never a GitHub assignment: no application row.
+ self.assertFalse(ReviewerAssignmentApplication.objects.filter(repository=self.repo, pr_number=102).exists())
+
+ def test_confirm_unreachable_falls_back_to_direct_assign(self) -> None:
+ self._make_reviewer("carol", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=False)
+ self._make_snapshot({103: "carol"})
+ self._make_pr(103)
+
+ result, client, sync = self._propose()
+
+ self.assertEqual(result["stats"]["assigned_fallback"], 1)
+ self.assertEqual(result["stats"]["proposed"], 0)
+ client.assign.assert_called_once()
+ self.assertFalse(AssignmentProposal.objects.filter(repository=self.repo, pr_number=103).exists())
+
+ def test_per_reviewer_window_override_clamped_to_seven(self) -> None:
+ user = self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ pref = ReviewerPreference.objects.get(repository=self.repo, user=user)
+ pref.notification_settings = {"assignment_proposal_window_days": 3} # below the >=7 floor
+ pref.save(update_fields=["notification_settings"])
+ self._make_snapshot({104: "bob"})
+ self._make_pr(104)
+
+ self._propose(window_days=7)
+
+ proposal = AssignmentProposal.objects.get(repository=self.repo, pr_number=104)
+ self.assertEqual(proposal.expires_at, self.now + timedelta(days=7))
+
+ def test_per_reviewer_window_override_larger_is_honored(self) -> None:
+ user = self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ pref = ReviewerPreference.objects.get(repository=self.repo, user=user)
+ pref.notification_settings = {"assignment_proposal_window_days": 14}
+ pref.save(update_fields=["notification_settings"])
+ self._make_snapshot({105: "bob"})
+ self._make_pr(105)
+
+ self._propose(window_days=7)
+
+ proposal = AssignmentProposal.objects.get(repository=self.repo, pr_number=105)
+ self.assertEqual(proposal.expires_at, self.now + timedelta(days=14))
+
+ # ---- dry-run / gating ---------------------------------------------
+
+ def test_dry_run_has_no_side_effects(self) -> None:
+ self._make_reviewer("alice", acceptance=ReviewerPreference.ACCEPTANCE_AUTO, reachable=False)
+ self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ self._make_snapshot({101: "alice", 102: "bob"})
+ self._make_pr(101)
+ self._make_pr(102)
+
+ result, client, sync = self._propose(dry_run=True)
+
+ self.assertEqual(result["stats"]["skipped_dry_run"], 2)
+ self.assertEqual(result["stats"]["proposed"], 0)
+ self.assertEqual(result["stats"]["assigned_auto"], 0)
+ client.assign.assert_not_called()
+ sync.assert_not_called()
+ self.assertFalse(AssignmentProposal.objects.filter(repository=self.repo).exists())
+ self.assertFalse(ReviewerAssignmentApplication.objects.filter(repository=self.repo).exists())
+
+ def test_disabled_creates_nothing(self) -> None:
+ self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ self._make_snapshot({102: "bob"})
+ self._make_pr(102)
+
+ result, _client, _sync = self._propose(enabled=False, dry_run=False)
+
+ self.assertEqual(result["stats"]["skipped_disabled"], 1)
+ self.assertFalse(AssignmentProposal.objects.filter(repository=self.repo).exists())
+
+ def test_no_token_skips_direct_assign_but_proposals_still_created(self) -> None:
+ # A missing assign token blocks direct-assign, but DB-only proposals need no token.
+ self._make_reviewer("alice", acceptance=ReviewerPreference.ACCEPTANCE_AUTO, reachable=False)
+ self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ self._make_snapshot({101: "alice", 102: "bob"})
+ self._make_pr(101)
+ self._make_pr(102)
+
+ result, client, _sync = self._propose(token=None)
+
+ self.assertEqual(result["stats"]["skipped_no_token"], 1)
+ self.assertEqual(result["stats"]["proposed"], 1)
+ client.assign.assert_not_called()
+ self.assertTrue(AssignmentProposal.objects.filter(repository=self.repo, pr_number=102).exists())
+
+ # ---- re-validation / idempotency ----------------------------------
+
+ def test_skips_pr_with_existing_active_proposal(self) -> None:
+ self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ self._make_snapshot({102: "bob"})
+ self._make_pr(102)
+ AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=102,
+ reviewer_login="dave", # a different, earlier candidate
+ state=AssignmentProposal.STATE_PROPOSED,
+ expires_at=self.now + timedelta(days=7),
+ )
+
+ result, _client, _sync = self._propose()
+
+ self.assertEqual(result["stats"]["skipped_already_proposed"], 1)
+ self.assertEqual(result["stats"]["proposed"], 0)
+ # Still exactly one active proposal for the PR (the pre-existing one).
+ self.assertEqual(
+ AssignmentProposal.objects.filter(
+ repository=self.repo, pr_number=102, state=AssignmentProposal.STATE_PROPOSED
+ ).count(),
+ 1,
+ )
+
+ def test_skips_already_assigned_and_opted_out_and_ineligible(self) -> None:
+ self._make_reviewer("alice", acceptance=ReviewerPreference.ACCEPTANCE_AUTO, reachable=False)
+ self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ self._make_snapshot({101: "alice", 102: "bob", 103: "carol"})
+ self._make_pr(101, assignees=["alice"]) # already assigned
+ self._make_pr(102)
+ ReviewerOptOut.objects.create(
+ repository=self.repo, pr_number=102, reviewer_login="bob", active=True, opted_out_at=self.now
+ ) # opted out
+ self._make_pr(103) # carol has no ReviewerPreference -> ineligible
+
+ result, client, _sync = self._propose()
+
+ self.assertEqual(result["stats"]["skipped_already_assigned"], 1)
+ self.assertEqual(result["stats"]["skipped_opted_out"], 1)
+ self.assertEqual(result["stats"]["skipped_ineligible"], 1)
+ self.assertEqual(result["stats"]["proposed"], 0)
+ client.assign.assert_not_called()
+
+ def test_recently_applied_skips_direct_assign(self) -> None:
+ self._make_reviewer("alice", acceptance=ReviewerPreference.ACCEPTANCE_AUTO, reachable=False)
+ self._make_snapshot({101: "alice"})
+ self._make_pr(101)
+ ReviewerAssignmentApplication.objects.create(
+ run_date=self.run_date - timedelta(days=1),
+ repository=self.repo,
+ pr_number=101,
+ reviewer_login="alice",
+ status=ReviewerAssignmentApplication.STATUS_APPLIED,
+ applied_at=self.now - timedelta(days=1),
+ )
+
+ result, client, _sync = self._propose(dedupe_days=7)
+
+ self.assertEqual(result["stats"]["skipped_recently_applied"], 1)
+ client.assign.assert_not_called()
+
+ def test_already_recorded_same_day_application_is_skipped(self) -> None:
+ # A same-day FAILED application is not "recently applied" (that only counts APPLIED), so the
+ # direct-assign helper's get_or_create finds the existing row and reports already_recorded.
+ self._make_reviewer("alice", acceptance=ReviewerPreference.ACCEPTANCE_AUTO, reachable=False)
+ self._make_snapshot({101: "alice"})
+ self._make_pr(101)
+ ReviewerAssignmentApplication.objects.create(
+ run_date=self.run_date,
+ repository=self.repo,
+ pr_number=101,
+ reviewer_login="alice",
+ status=ReviewerAssignmentApplication.STATUS_FAILED,
+ )
+
+ result, client, _sync = self._propose()
+
+ self.assertEqual(result["stats"]["skipped_already_recorded"], 1)
+ self.assertEqual(result["stats"]["assigned_auto"], 0)
+ client.assign.assert_not_called()
+
+ def test_per_repo_cap_bounds_direct_assigns_but_not_proposals(self) -> None:
+ # PRs are processed in ascending number order: 101/102 auto, 103 confirm.
+ self._make_reviewer("alice", acceptance=ReviewerPreference.ACCEPTANCE_AUTO, reachable=False)
+ self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ self._make_snapshot({101: "alice", 102: "alice", 103: "bob"})
+ self._make_pr(101)
+ self._make_pr(102)
+ self._make_pr(103)
+
+ result, client, _sync = self._propose(max_per_repo=1)
+
+ self.assertEqual(result["stats"]["assigned_auto"], 1)
+ self.assertTrue(result["stats"]["capped"])
+ self.assertEqual(result["stats"]["capped_remaining"], 1)
+ self.assertEqual(client.assign.call_count, 1)
+ # The cap defers only GitHub mutations; the confirm-mode proposal is still created.
+ self.assertEqual(result["stats"]["proposed"], 1)
+ self.assertTrue(AssignmentProposal.objects.filter(repository=self.repo, pr_number=103).exists())
+
+ def test_rerun_is_idempotent(self) -> None:
+ self._make_reviewer("alice", acceptance=ReviewerPreference.ACCEPTANCE_AUTO, reachable=False)
+ self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ self._make_snapshot({101: "alice", 102: "bob"})
+ self._make_pr(101)
+ self._make_pr(102)
+
+ first, _c1, _s1 = self._propose()
+ self.assertEqual(first["stats"]["assigned_auto"], 1)
+ self.assertEqual(first["stats"]["proposed"], 1)
+
+ second, c2, s2 = self._propose()
+ # PR 101 was applied within the dedupe window (recently-applied wins over the same-day
+ # already-recorded guard); PR 102 already has an active proposal.
+ self.assertEqual(second["stats"]["assigned_auto"], 0)
+ self.assertEqual(second["stats"]["proposed"], 0)
+ self.assertEqual(second["stats"]["skipped_recently_applied"], 1)
+ self.assertEqual(second["stats"]["skipped_already_proposed"], 1)
+ c2.assign.assert_not_called()
+ s2.assert_not_called()
+ self.assertEqual(AssignmentProposal.objects.filter(repository=self.repo, pr_number=102, state="proposed").count(), 1)
+
+ def test_no_snapshot_skipped(self) -> None:
+ self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ self._make_snapshot({102: "bob"}, cache_key="default") # wrong cache key
+ self._make_pr(102)
+
+ result, _client, _sync = self._propose()
+
+ self.assertEqual(result["status"], "skipped")
+ self.assertEqual(result["reason"], "no_snapshot")
+
+ def test_stale_snapshot_skipped(self) -> None:
+ self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True)
+ self._make_snapshot({102: "bob"}, generated_at=self.now - timedelta(days=3))
+ self._make_pr(102)
+
+ result, _client, _sync = self._propose(max_age_hours=48)
+
+ self.assertEqual(result["status"], "skipped")
+ self.assertEqual(result["reason"], "stale_snapshot")
+ self.assertFalse(AssignmentProposal.objects.filter(repository=self.repo).exists())
diff --git a/qb_site/analyzer/tests/tasks/test_reviewer_assignment_propose_task.py b/qb_site/analyzer/tests/tasks/test_reviewer_assignment_propose_task.py
new file mode 100644
index 00000000..6f29cfa7
--- /dev/null
+++ b/qb_site/analyzer/tests/tasks/test_reviewer_assignment_propose_task.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+from io import StringIO
+from unittest.mock import patch
+
+from django.core.management import call_command
+from django.test import TestCase, override_settings
+from django.utils import timezone
+
+from analyzer.models import AssignmentProposal, QueueRuleSet, ReviewerAssignmentSnapshot
+from analyzer.tasks.reviewer_assignment_propose import propose_reviewer_assignments_task
+from core.models import Repository, ReviewerPreference, User
+from syncer.models import PullRequest
+from syncer.models.pull_request import PullRequestState
+
+
+class ProposeReviewerAssignmentsTaskTests(TestCase):
+ def setUp(self) -> None:
+ self.repo = Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master")
+
+ def _seed_confirm_reviewer_snapshot(self) -> None:
+ rule_set = QueueRuleSet.objects.create(repository=self.repo, version=1, is_default=True, is_active=True)
+ now = timezone.now()
+ user = User.objects.create(github_login="bob", zulip_user_id=4242)
+ ReviewerPreference.objects.create(
+ repository=self.repo, user=user, auto_assign=True, assignment_acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM
+ )
+ ReviewerAssignmentSnapshot.objects.create(
+ repository=self.repo,
+ cache_key=str(rule_set.id),
+ generated_at=now,
+ payload={"meta": {}, "automatic_assignments": {"101": "bob"}},
+ etag="etag",
+ assignment_count=1,
+ )
+ PullRequest.objects.create(
+ repository=self.repo,
+ number=101,
+ state=PullRequestState.OPEN,
+ is_draft=False,
+ gh_created_at=now,
+ gh_updated_at=now,
+ base_ref_name="master",
+ head_ref_name="branch",
+ head_repo_owner_login="leanprover-community",
+ head_repo_name="mathlib4",
+ title="PR 101",
+ body="b",
+ additions=1,
+ deletions=0,
+ changed_files_count=1,
+ assignees=[],
+ )
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=False, ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=False)
+ def test_skips_when_disabled(self) -> None:
+ res = propose_reviewer_assignments_task.apply().get()
+
+ self.assertTrue(res["skipped"])
+ self.assertEqual(res["reason"], "feature_disabled")
+ self.assertFalse(AssignmentProposal.objects.exists())
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=True, ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=False)
+ def test_enabled_creates_proposal(self) -> None:
+ self._seed_confirm_reviewer_snapshot()
+
+ res = propose_reviewer_assignments_task.apply().get()
+
+ self.assertFalse(res["skipped"])
+ self.assertEqual(res["repos"], 1)
+ self.assertEqual(res["totals"]["proposed"], 1)
+ self.assertTrue(AssignmentProposal.objects.filter(repository=self.repo, pr_number=101, reviewer_login="bob").exists())
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=False, ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=True)
+ def test_dry_run_no_side_effects(self) -> None:
+ self._seed_confirm_reviewer_snapshot()
+
+ res = propose_reviewer_assignments_task.apply().get()
+
+ self.assertFalse(res["skipped"])
+ self.assertTrue(res["dry_run"])
+ self.assertEqual(res["totals"]["skipped_dry_run"], 1)
+ self.assertEqual(res["totals"]["proposed"], 0)
+ self.assertFalse(AssignmentProposal.objects.exists())
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=True)
+ def test_repo_filter_miss_returns_not_found(self) -> None:
+ res = propose_reviewer_assignments_task.apply(kwargs={"repository_id": 999999}).get()
+
+ self.assertTrue(res["skipped"])
+ self.assertEqual(res["reason"], "repo_not_found_or_inactive")
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=True, ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=False)
+ def test_one_repo_failure_does_not_abort_sweep(self) -> None:
+ other = Repository.objects.create(owner="leanprover-community", name="other", default_branch="master")
+
+ def fake_propose(repo, **kwargs):
+ if repo.id == self.repo.id:
+ raise RuntimeError("boom")
+ return {"repo": f"{repo.owner}/{repo.name}", "repo_id": repo.id, "status": "ok", "stats": {"proposed": 1}}
+
+ with patch("analyzer.tasks.reviewer_assignment_propose.propose_assignments_for_repo", side_effect=fake_propose):
+ res = propose_reviewer_assignments_task.apply().get()
+
+ self.assertFalse(res["skipped"])
+ self.assertEqual(res["repos"], 2)
+ self.assertEqual(res["repos_errored"], 1)
+ self.assertEqual(res["totals"].get("proposed"), 1)
+ statuses = {r["repo"]: r["status"] for r in res["per_repo"]}
+ self.assertEqual(statuses[f"{self.repo.owner}/{self.repo.name}"], "error")
+ self.assertEqual(statuses[f"{other.owner}/{other.name}"], "ok")
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=False, ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=True)
+ def test_command_honors_dry_run_when_run_bare(self) -> None:
+ self._seed_confirm_reviewer_snapshot()
+
+ call_command("propose_reviewer_assignments", stdout=StringIO())
+
+ self.assertFalse(AssignmentProposal.objects.exists())
diff --git a/qb_site/qb_site/settings/base.py b/qb_site/qb_site/settings/base.py
index f14a8bdb..a3ab0bbe 100644
--- a/qb_site/qb_site/settings/base.py
+++ b/qb_site/qb_site/settings/base.py
@@ -403,6 +403,45 @@ def env_optional_bounded_int(name: str, *, minimum: int, maximum: int) -> int |
# Soft cooldown: skip a reviewer for a PR when a proposal for it expired (silent timeout)
# within this many days. Not a permanent opt-out (that is an explicit decline). 0 disables it.
ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS = int(os.getenv("ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS", "14"))
+# Acceptance-gate rollout flags (design doc 050), each independently toggleable like doc 028's
+# propose -> deliver -> assign-on-accept discipline. All default off so the gate is inert until
+# an operator opts in.
+# ENABLED master kill switch: the propose task creates proposals / direct-assigns.
+# DELIVERY_ENABLED send the per-reviewer proposal digest DM (consumed in Chunk 5).
+# ASSIGN_ON_ACCEPT_ENABLED the console accept handler performs the GitHub assign (Chunk 6).
+# DRY_RUN propose computes + records would-do outcomes without any side effect.
+# Enable EITHER this gate OR the legacy ANALYZER_REVIEWER_ASSIGNMENT_APPLY_* task, not both:
+# propose supersedes apply (it direct-assigns auto-mode reviewers itself and proposes to the rest).
+ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED = env_bool(os.getenv("ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED"), False)
+ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED = env_bool(os.getenv("ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED"), False)
+ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED = env_bool(
+ os.getenv("ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED"), False
+)
+ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN = env_bool(os.getenv("ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN"), False)
+# Acceptance window: a proposal expires this many days after creation unless accepted. The
+# per-reviewer override in ReviewerPreference.notification_settings is clamped to >= 7.
+ANALYZER_ASSIGNMENT_PROPOSAL_WINDOW_DAYS = int(os.getenv("ANALYZER_ASSIGNMENT_PROPOSAL_WINDOW_DAYS", "7"))
+# On-queue-exit policy read inside the proposal_validity predicate: "invalidate" (default) marks a
+# pending proposal superseded when its PR leaves the review queue; "retain" lets it ride.
+ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT = os.getenv("ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT", "invalidate").strip().lower()
+# Propose task schedule (daily, shortly after the compute refresh at 00:30 and in place of the
+# legacy apply at 00:45). PERIOD_SECONDS <= 0 disables scheduling; the task is also gated by
+# ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED (+ dry-run), so scheduling it while off is a cheap no-op.
+ANALYZER_ASSIGNMENT_PROPOSE_PERIOD_SECONDS = int(os.getenv("ANALYZER_ASSIGNMENT_PROPOSE_PERIOD_SECONDS", 86400))
+ANALYZER_ASSIGNMENT_PROPOSE_UTC_HOUR = env_optional_bounded_int(
+ "ANALYZER_ASSIGNMENT_PROPOSE_UTC_HOUR",
+ minimum=0,
+ maximum=23,
+)
+ANALYZER_ASSIGNMENT_PROPOSE_UTC_MINUTE = env_optional_bounded_int(
+ "ANALYZER_ASSIGNMENT_PROPOSE_UTC_MINUTE",
+ minimum=0,
+ maximum=59,
+)
+# Expiry/reconcile sweep schedule. This is essential maintenance (expire timed-out proposals,
+# supersede those whose PR left the queue) and is intentionally NOT gated by the master switch,
+# so flipping the gate off lets existing proposals drain. PERIOD_SECONDS <= 0 disables it.
+ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRY_PERIOD_SECONDS = int(os.getenv("ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRY_PERIOD_SECONDS", 3600))
ANALYZER_REVIEWER_ATTENTION_ENABLED = env_bool(os.getenv("ANALYZER_REVIEWER_ATTENTION_ENABLED"), False)
ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED = env_bool(
os.getenv("ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED"),
@@ -650,6 +689,25 @@ def env_optional_bounded_int(name: str, *, minimum: int, maximum: int) -> int |
),
),
}
+# Propose reviewer assignments through the acceptance gate (design doc 050), daily at a fixed UTC
+# clock time (default 00:45, just after the compute refresh — same slot as the legacy apply, which
+# it supersedes). Beat fires unconditionally; ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED (+ dry-run)
+# gates activity inside the task, so scheduling it while off is a cheap no-op (feature_disabled).
+if ANALYZER_ASSIGNMENT_PROPOSE_PERIOD_SECONDS > 0:
+ CELERY_BEAT_SCHEDULE["propose_reviewer_assignments"] = {
+ "task": "analyzer.propose_reviewer_assignments",
+ "schedule": crontab(
+ hour=ANALYZER_ASSIGNMENT_PROPOSE_UTC_HOUR if ANALYZER_ASSIGNMENT_PROPOSE_UTC_HOUR is not None else 0,
+ minute=ANALYZER_ASSIGNMENT_PROPOSE_UTC_MINUTE if ANALYZER_ASSIGNMENT_PROPOSE_UTC_MINUTE is not None else 45,
+ ),
+ }
+# Expiry/reconcile sweep: expire timed-out proposals and supersede those whose PR left the queue.
+# Essential maintenance, so it runs on a simple period regardless of the master switch.
+if ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRY_PERIOD_SECONDS > 0:
+ CELERY_BEAT_SCHEDULE["expire_assignment_proposals"] = {
+ "task": "analyzer.expire_assignment_proposals",
+ "schedule": ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRY_PERIOD_SECONDS,
+ }
reviewer_attention_schedule = None
if (ANALYZER_REVIEWER_ATTENTION_UTC_HOUR is not None) or (ANALYZER_REVIEWER_ATTENTION_UTC_MINUTE is not None):
reviewer_attention_schedule = crontab(
From f8f63fb31bea168d6225227126b84aac8cf64a8c Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Wed, 8 Jul 2026 23:58:06 -0400
Subject: [PATCH 06/33] feat: add GitHub-OAuth reviewer console (accept/decline
proposals)
Chunk 6 of design doc 050 (built before Chunk 5 so the notification DM links to a
live surface). New console/ Django app at /console/ where a confirm-mode reviewer
signs in with GitHub and accepts/declines the assignment proposals made to them.
- Centralized site base URL: new canonical QUEUEBOARD_BASE_URL; legacy
ZULIP_PREFS_URL_BASE falls back to it; core.services.site_urls is the single
resolver (prefs/registration links inherit the fallback for free).
- Shared OAuth-state primitive in core (core.services.oauth_state): Fernet
encrypt+integrity+TTL over a JSON dict, plus a token-less console helper carrying
a CSRF nonce + next. Refactored zulip_bot.registration_oauth_state to delegate to
it (public API unchanged). Zulip-agnostic core.services.github_identity maps the
OAuth identity to a core.User without touching Zulip fields.
- console app: login/oauth_callback/logout (stable bookmarkable URL, ?next=, nonce
CSRF), a list view (pending proposals + matched labels + expiry + per-repo load),
and POST accept/decline. Both re-validate via the single proposal_validity
authority and render "no longer available" (retiring the stale row) when a
proposal is no longer actionable; a reviewer can only act on their own proposals.
Accept (gated by ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED) reuses
the verbatim 046 mutation (assign_reviewer_and_record) then marks accepted; when
off it leaves the proposal pending. Decline marks declined + upserts a
ReviewerOptOut.
- Fix a pre-existing red test from Chunk 1: zulip_bot prefs-form field coverage now
accounts for assignment_acceptance (admin-managed, not self-serve).
- Correct stale OAuth path in design doc 050; add console/AGENTS.md + CLAUDE.md;
list the app in qb_site/AGENTS.md.
Validation: 25 new tests + console/core/zulip_bot suites (387) green on Postgres;
makemigrations --check clean (no models); ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.env.example | 9 +-
...050-reviewer-assignment-acceptance-gate.md | 63 ++-
qb_site/AGENTS.md | 1 +
qb_site/console/AGENTS.md | 43 ++
qb_site/console/CLAUDE.md | 1 +
qb_site/console/__init__.py | 0
qb_site/console/apps.py | 9 +
qb_site/console/session.py | 49 +++
qb_site/console/tests/__init__.py | 0
qb_site/console/tests/test_views.py | 229 +++++++++++
qb_site/console/urls.py | 18 +
qb_site/console/views.py | 371 ++++++++++++++++++
qb_site/core/services/github_identity.py | 63 +++
qb_site/core/services/oauth_state.py | 129 ++++++
qb_site/core/services/site_urls.py | 33 ++
.../core/tests/test_console_oauth_helpers.py | 91 +++++
qb_site/qb_site/settings/base.py | 12 +-
qb_site/qb_site/urls.py | 1 +
qb_site/templates/console/base.html | 41 ++
qb_site/templates/console/decided.html | 13 +
qb_site/templates/console/error.html | 7 +
qb_site/templates/console/home.html | 43 ++
qb_site/templates/console/login.html | 8 +
qb_site/templates/console/unavailable.html | 7 +
qb_site/zulip_bot/forms.py | 4 +
.../services/registration_oauth_state.py | 60 +--
.../tests/test_registration_oauth_state.py | 4 +-
27 files changed, 1255 insertions(+), 54 deletions(-)
create mode 100644 qb_site/console/AGENTS.md
create mode 100644 qb_site/console/CLAUDE.md
create mode 100644 qb_site/console/__init__.py
create mode 100644 qb_site/console/apps.py
create mode 100644 qb_site/console/session.py
create mode 100644 qb_site/console/tests/__init__.py
create mode 100644 qb_site/console/tests/test_views.py
create mode 100644 qb_site/console/urls.py
create mode 100644 qb_site/console/views.py
create mode 100644 qb_site/core/services/github_identity.py
create mode 100644 qb_site/core/services/oauth_state.py
create mode 100644 qb_site/core/services/site_urls.py
create mode 100644 qb_site/core/tests/test_console_oauth_helpers.py
create mode 100644 qb_site/templates/console/base.html
create mode 100644 qb_site/templates/console/decided.html
create mode 100644 qb_site/templates/console/error.html
create mode 100644 qb_site/templates/console/home.html
create mode 100644 qb_site/templates/console/login.html
create mode 100644 qb_site/templates/console/unavailable.html
diff --git a/.env.example b/.env.example
index 9671fe0e..10958b5f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,12 @@
# Queueboard Django environment configuration
# Copy to `.env` (or use docker-compose env_file) and adjust values as needed.
+# Canonical public base URL of THIS Django site (scheme://host, no trailing path), e.g.
+# https://queueboard.example.com. Feature deep-links (reviewer console, Zulip prefs/registration)
+# fall back to it, so set this one variable instead of several. NOTE: not the Zulip chat server
+# (that is ZULIP_BASE_URL). Leave empty only in local dev.
+QUEUEBOARD_BASE_URL=
+
# Zulip bot (outgoing webhook + API access)
ZULIP_WEBHOOK_TOKEN=
ZULIP_BASE_URL=
@@ -9,7 +15,8 @@ ZULIP_BOT_API_KEY=
# User credentials for Zulip endpoints that reject bot auth (required for membership checks).
ZULIP_USER_EMAIL=
ZULIP_USER_API_KEY=
-# Public base URL used when generating prefs links (e.g. https://queueboard.example.com).
+# Legacy per-feature base for prefs/registration links; when empty it falls back to
+# QUEUEBOARD_BASE_URL. Set QUEUEBOARD_BASE_URL instead for new deployments.
ZULIP_PREFS_URL_BASE=
# Optional dedicated encryption/signing secret for prefs links; falls back to DJANGO_SECRET_KEY when empty.
ZULIP_PREFS_TOKEN_SECRET=
diff --git a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
index 9edb89a6..f9984595 100644
--- a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
+++ b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
@@ -1,7 +1,7 @@
# Reviewer Assignment Acceptance Gate (Propose → Accept → Assign)
-> Status: Living implementation plan (in progress — Chunks 1–4 landed). Captures decisions,
-> invariants, and a chunked build plan; Progress Notes track what has shipped.
+> Status: Living implementation plan (in progress — Chunks 1–4 and 6 landed; Chunk 5 next).
+> Captures decisions, invariants, and a chunked build plan; Progress Notes track what has shipped.
## Context
@@ -35,8 +35,10 @@
`core.User` (`github_login` ↔ `zulip_user_id`).
- Reusable infrastructure already present:
- Proactive Zulip DMs (`zulip_bot.services.zulip_client.ZulipClient.send_direct_message`).
- - GitHub OAuth for identity (`zulip_bot/services/github_oauth.py`,
- `views.register_github_callback`, `services/registration_linking.py`).
+ - GitHub OAuth for identity (`core/services/github_oauth.py::GitHubOAuthClient`; the
+ registration flow's callback/state helpers live in `zulip_bot/` —
+ `views.register_github_callback`, `services/registration_linking.py`,
+ `services/registration_oauth_state.py`).
- Per-PR opt-outs (`analyzer.ReviewerOptOut`, keyed `(repository, pr_number,
reviewer_login)`), driven by timeline assign/unassign events
(`syncer/services/pr_sync_service.py::_apply_assignment_opt_outs`).
@@ -216,9 +218,16 @@ is null) — *not* merely because `notifications_enabled` is off. This decouplin
### Reviewer console
- **Auth:** GitHub OAuth → a persistent **reviewer session** (Django session framework;
- store the resolved `core.User`). Reuses `github_oauth.GitHubOAuthClient` and the existing
- callback plumbing. The DM contains a **plain, stable console URL** — no token, nothing to
- expire, bookmarkable. Standard login-redirect (`?next=`) lets a DM deep-link to a PR row.
+ store the resolved `core.User`). Reuses `core.services.github_oauth.GitHubOAuthClient`. The
+ console gets its **own token-less OAuth-state helper** in `core` (Fernet-signed CSRF + `next`
+ path) rather than the registration flow's token-bound state helper. The DM contains a **plain,
+ stable console URL** — no token, nothing to expire, bookmarkable — built from the centralized
+ `QUEUEBOARD_BASE_URL` (legacy `ZULIP_PREFS_URL_BASE` falls back to it). Standard login-redirect
+ (`?next=`) lets a DM deep-link to a PR row.
+- **Location (resolved):** a dedicated `console/` Django app (own urls/views/templates/session),
+ not folded into `zulip_bot/`. Build order is **console before notification** (Chunk 6 → 5) so the
+ DM links to a live surface. Console access is keyed on the authenticated `github_login`, matched
+ case-insensitively against `AssignmentProposal.reviewer_login`.
- Console access is keyed on the authenticated `github_login` (which is exactly what
proposals are keyed on), so console access and notification reach are independent: a
reviewer can self-manage on the console even without a Zulip link.
@@ -370,10 +379,12 @@ a later step.
(per-reviewer branch: `auto` → 046 path; `confirm` reachable → proposal; `confirm`
unreachable → fallback), `analyzer.propose_reviewer_assignments` task, expiry sweep task,
settings/flags/beat. Dry-run + flags. Unit + task tests.
-5. **Notification:** per-reviewer digest DM (reuse `ZulipClient` + a dedupe record or
- `notified_at`), de-duped vs the attention "newly assigned" ping.
-6. **Console:** GitHub-OAuth reviewer session, list view, accept/decline handlers
- (accept reuses the 046 mutation path), templates, live re-validation, frontend tests.
+5. **Notification** *(built after Chunk 6 — the DM links to the console)*: per-reviewer digest DM
+ (reuse `ZulipClient` + a dedupe record or `notified_at`), de-duped vs the attention "newly
+ assigned" ping.
+6. ✅ **(landed)** **Console** *(built before Chunk 5)*: dedicated `console/` app — GitHub-OAuth
+ reviewer session, list view, accept/decline handlers (accept reuses the 046 mutation path),
+ templates, live re-validation, tests.
7. **Surfacing:** the {unassigned, proposed, assigned} state in the queue snapshot and in
`pr_info`/`PRQueueInfo` (so the dashboard, API, and the Zulip `pr-info` command render it
identically, distinct from GitHub assignees); the `AssignmentProposal` admin changelist
@@ -437,6 +448,36 @@ a later step.
## Progress Notes
+- 2026-07-09: **Chunk 6 landed (built before Chunk 5).** New `console/` Django app at `/console/`
+ — a GitHub-OAuth, session-backed reviewer console. Decisions (via checkpoint): dedicated app
+ (not folded into `zulip_bot`); OAuth consolidated into `core`; console before notification so
+ the DM links to a live surface. Pieces:
+ - **Centralized site URL:** new canonical `QUEUEBOARD_BASE_URL`; legacy `ZULIP_PREFS_URL_BASE`
+ now falls back to it; `core.services.site_urls.{resolve_site_base_url,build_site_url}` is the
+ single resolver (existing prefs/registration links inherit the fallback for free).
+ - **Shared OAuth-state primitive in `core`:** `core.services.oauth_state` (Fernet encrypt +
+ integrity + TTL over a JSON dict) with a token-less console helper (`ConsoleOAuthStateClaims`
+ carrying a CSRF `nonce` + `next`). Refactored `zulip_bot.registration_oauth_state` to delegate
+ to it (public API unchanged; registration suite green). `GitHubOAuthClient` was *already* in
+ `core` (design-doc path corrected). Zulip-agnostic
+ `core.services.github_identity.resolve_or_create_user_from_identity` maps the OAuth identity to
+ a `core.User` without touching Zulip fields.
+ - **Console app:** `login`/`oauth_callback`/`logout` (stable bookmarkable URL, `?next=`, nonce
+ CSRF), a list view (pending proposals with matched topic labels + expiry, a per-repo load
+ summary), and POST `accept`/`decline`. Both handlers re-validate via the single
+ `proposal_validity` authority and render `unavailable.html` (retiring the stale row) when a
+ proposal is no longer actionable; a reviewer can only act on proposals matching their
+ `github_login` (case-insensitive). **Accept** (gated by
+ `ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED`) reuses the verbatim 046 mutation via
+ `assign_reviewer_and_record`, then marks the proposal `accepted`; when the flag is off it
+ leaves the proposal pending rather than record an unfulfillable acceptance. **Decline** marks
+ `declined` and upserts an active `ReviewerOptOut`.
+ - Fixed a **pre-existing** red test from Chunk 1: `zulip_bot` prefs-form field-coverage now
+ accounts for `assignment_acceptance` (deliberately admin-managed, not self-serve in the form).
+ - Added `console/AGENTS.md` (+ `CLAUDE.md`) and listed the app in `qb_site/AGENTS.md`.
+ Validation: 25 new tests (console views + OAuth/site-url/identity helpers) + `console`/`core`/
+ `zulip_bot` suites (387) green on Postgres; `makemigrations --check` clean (no models); ruff
+ clean. No GitHub writes exercised (mocked) — a staging end-to-end accept remains a manual step.
- 2026-07-09: **Chunk 4 landed.** Split the batch/mutation halves of the 046 apply path and
built the propose pipeline. Extracted the mutation core into
`reviewer_assignment_apply.assign_reviewer_and_record` (+ `latest_default_snapshot` /
diff --git a/qb_site/AGENTS.md b/qb_site/AGENTS.md
index 5c93cab4..e2941a4c 100644
--- a/qb_site/AGENTS.md
+++ b/qb_site/AGENTS.md
@@ -8,6 +8,7 @@
- `analyzer`: derived queue/revision/dependency state and snapshots.
- `api`: DRF views/serializers for queueboard surfaces.
- `zulip_bot`: Zulip webhook/command integration and policies.
+ - `console`: GitHub-OAuth reviewer console for accepting/declining assignment proposals (design doc 050).
- Keep new modules inside the owning app (`models/`, `services/`, `tasks/`, `management/commands/`, `tests/`).
- App-specific guidance:
- `qb_site/api/AGENTS.md` for public API endpoints, common patterns, and authentication notes.
diff --git a/qb_site/console/AGENTS.md b/qb_site/console/AGENTS.md
new file mode 100644
index 00000000..c90b36bf
--- /dev/null
+++ b/qb_site/console/AGENTS.md
@@ -0,0 +1,43 @@
+# Reviewer Console Guidelines
+
+## Scope
+- `qb_site/console/` is the GitHub-OAuth authenticated **reviewer console** (design decision 050):
+ a `confirm`-mode reviewer signs in and accepts/declines the assignment proposals made to them.
+- It is a plain server-rendered Django app (no DRF, no models of its own). State lives in
+ `analyzer.AssignmentProposal` / `analyzer.ReviewerOptOut`; identity is `core.User`.
+- Mounted at `/console/` (`qb_site/qb_site/urls.py`). Templates in `qb_site/templates/console/`.
+
+## Auth model
+- **Not** Django admin auth. A reviewer needs no Django account — only a GitHub identity.
+- Login flow (`views.login` → `views.oauth_callback`): token-less, bookmarkable URL → GitHub OAuth
+ → resolve the `core.User` → store its id in the Django session (`console.session`).
+ - OAuth client: `core.services.github_oauth.GitHubOAuthClient` (shared with registration).
+ - CSRF: a random nonce is stored in the session and echoed in the Fernet-signed `state`
+ (`core.services.oauth_state.issue_console_oauth_state`); the callback requires them to match.
+ - Identity → user: `core.services.github_identity.resolve_or_create_user_from_identity`
+ (Zulip-agnostic; never touches `zulip_user_id`).
+- Console access is keyed on the authenticated `github_login`, matched **case-insensitively**
+ against `AssignmentProposal.reviewer_login`. A reviewer can only act on their own proposals.
+
+## Accept / decline (the load-bearing handlers)
+- Both `POST`, both re-validate live state via the single `analyzer.services.assignment_proposal_validity.proposal_validity`
+ authority before acting; a no-longer-actionable proposal renders `unavailable.html` (and the
+ stale row is retired to its terminal state) instead of erroring.
+- **Accept**: gated by `ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED`. When on, reuses
+ the verbatim 046 mutation path (`analyzer.services.reviewer_assignment_apply.assign_reviewer_and_record`:
+ GitHub assign + `ReviewerAssignmentApplication` + `syncer.sync_pr`), then marks the proposal
+ `accepted` (conditional `UPDATE ... WHERE state='proposed'`). When off, it leaves the proposal
+ pending and tells the reviewer — never records an acceptance it cannot fulfil.
+- **Decline**: marks the proposal `declined` and upserts an active `ReviewerOptOut` (permanent
+ per-PR "no", enforced by the builder). No GitHub write.
+
+## Base URL
+- Absolute links (the DM console URL, the OAuth `redirect_uri`) come from
+ `core.services.site_urls.build_site_url`, which resolves `QUEUEBOARD_BASE_URL` and falls back to
+ the legacy `ZULIP_PREFS_URL_BASE`. Do not read either setting directly.
+
+## Testing
+- `docker compose exec -T web env DJANGO_SETTINGS_MODULE=qb_site.settings.ci python qb_site/manage.py test console`
+- View tests mock `console.views.GitHubOAuthClient` and `console.views.assign_reviewer_and_record`
+ and seed the session directly; no real GitHub calls. Canonical full run stays
+ `bash scripts/repo_check_compose.sh`.
diff --git a/qb_site/console/CLAUDE.md b/qb_site/console/CLAUDE.md
new file mode 100644
index 00000000..43c994c2
--- /dev/null
+++ b/qb_site/console/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/qb_site/console/__init__.py b/qb_site/console/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/qb_site/console/apps.py b/qb_site/console/apps.py
new file mode 100644
index 00000000..92c401aa
--- /dev/null
+++ b/qb_site/console/apps.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+from django.apps import AppConfig
+
+
+class ConsoleConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "console"
+ verbose_name = "Reviewer Console"
diff --git a/qb_site/console/session.py b/qb_site/console/session.py
new file mode 100644
index 00000000..a842a81f
--- /dev/null
+++ b/qb_site/console/session.py
@@ -0,0 +1,49 @@
+"""Reviewer-session helpers for the console (design doc 050).
+
+A thin wrapper over the Django session: the console stores the resolved ``core.User`` id after a
+successful GitHub OAuth login and reads it back on each request. Kept separate from Django admin
+auth (``request.user``) — a reviewer needs no Django account, only a GitHub identity.
+"""
+
+from __future__ import annotations
+
+from django.http import HttpRequest
+
+from core.models import User
+
+SESSION_USER_KEY = "console_reviewer_user_id"
+SESSION_NONCE_KEY = "console_oauth_nonce"
+
+
+def set_reviewer(request: HttpRequest, user: User) -> None:
+ request.session[SESSION_USER_KEY] = int(user.id)
+
+
+def get_reviewer(request: HttpRequest) -> User | None:
+ user_id = request.session.get(SESSION_USER_KEY)
+ if not user_id:
+ return None
+ return User.objects.filter(id=int(user_id), is_active=True).first()
+
+
+def clear_reviewer(request: HttpRequest) -> None:
+ request.session.pop(SESSION_USER_KEY, None)
+
+
+def set_oauth_nonce(request: HttpRequest, nonce: str) -> None:
+ request.session[SESSION_NONCE_KEY] = nonce
+
+
+def pop_oauth_nonce(request: HttpRequest) -> str | None:
+ return request.session.pop(SESSION_NONCE_KEY, None)
+
+
+__all__ = [
+ "SESSION_USER_KEY",
+ "SESSION_NONCE_KEY",
+ "set_reviewer",
+ "get_reviewer",
+ "clear_reviewer",
+ "set_oauth_nonce",
+ "pop_oauth_nonce",
+]
diff --git a/qb_site/console/tests/__init__.py b/qb_site/console/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/qb_site/console/tests/test_views.py b/qb_site/console/tests/test_views.py
new file mode 100644
index 00000000..3158edff
--- /dev/null
+++ b/qb_site/console/tests/test_views.py
@@ -0,0 +1,229 @@
+from __future__ import annotations
+
+from datetime import timedelta
+from unittest.mock import MagicMock, patch
+
+from django.test import TestCase, override_settings
+from django.urls import reverse
+from django.utils import timezone
+
+from analyzer.models import AssignmentProposal, ReviewerOptOut
+from console.session import SESSION_NONCE_KEY, SESSION_USER_KEY
+from core.models import Repository, ReviewerPreference, User
+from core.services.github_oauth import GitHubOAuthError, GitHubUserIdentity
+from core.services.oauth_state import ConsoleOAuthStateClaims, issue_console_oauth_state
+from syncer.models import PullRequest
+from syncer.models.pull_request import PullRequestState
+
+
+class ConsoleViewTests(TestCase):
+ def setUp(self) -> None:
+ self.repo = Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master")
+ self.now = timezone.now()
+ self.reviewer = User.objects.create(github_login="bob", github_node_id="node-bob", zulip_user_id=7001)
+ ReviewerPreference.objects.create(
+ repository=self.repo,
+ user=self.reviewer,
+ preferred_labels=["t-analysis"],
+ maximum_capacity=5,
+ assignment_acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM,
+ )
+
+ # ---- helpers -------------------------------------------------------
+
+ def _login_session(self, user: User | None = None) -> None:
+ session = self.client.session
+ session[SESSION_USER_KEY] = int((user or self.reviewer).id)
+ session.save()
+
+ def _make_pr(self, number: int, *, state=PullRequestState.OPEN, assignees=None) -> PullRequest:
+ return PullRequest.objects.create(
+ repository=self.repo,
+ number=number,
+ state=state,
+ is_draft=False,
+ gh_created_at=self.now,
+ gh_updated_at=self.now,
+ base_ref_name="master",
+ head_ref_name=f"branch-{number}",
+ head_repo_owner_login="leanprover-community",
+ head_repo_name="mathlib4",
+ title=f"PR {number}",
+ body="b",
+ additions=1,
+ deletions=0,
+ changed_files_count=1,
+ assignees=list(assignees or []),
+ )
+
+ def _proposal(self, number: int, *, login="bob", state=AssignmentProposal.STATE_PROPOSED) -> AssignmentProposal:
+ return AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=number,
+ reviewer_login=login,
+ state=state,
+ expires_at=self.now + timedelta(days=7),
+ )
+
+ # ---- auth / session ------------------------------------------------
+
+ def test_home_without_session_shows_login(self) -> None:
+ resp = self.client.get(reverse("console:home"))
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "Sign in with GitHub")
+
+ def test_login_redirects_to_github(self) -> None:
+ fake = MagicMock()
+ fake.build_authorize_url.return_value = "https://github.com/login/oauth/authorize?state=xyz"
+ with patch("console.views.GitHubOAuthClient", return_value=fake):
+ resp = self.client.get(reverse("console:login"))
+ self.assertEqual(resp.status_code, 302)
+ self.assertEqual(resp["Location"], "https://github.com/login/oauth/authorize?state=xyz")
+ # A CSRF nonce was stashed in the session.
+ self.assertTrue(self.client.session.get(SESSION_NONCE_KEY))
+
+ def test_login_when_oauth_unconfigured(self) -> None:
+ with patch("console.views.GitHubOAuthClient", side_effect=GitHubOAuthError("no creds")):
+ resp = self.client.get(reverse("console:login"))
+ self.assertEqual(resp.status_code, 503)
+
+ def test_oauth_callback_happy_path_opens_session(self) -> None:
+ # Seed the session nonce as the login step would.
+ session = self.client.session
+ session[SESSION_NONCE_KEY] = "nonce-123"
+ session.save()
+ state = issue_console_oauth_state(claims=ConsoleOAuthStateClaims(nonce="nonce-123", next="/console/"))
+
+ fake = MagicMock()
+ fake.exchange_code_for_access_token.return_value = "gho_token"
+ fake.fetch_user_identity.return_value = GitHubUserIdentity(
+ github_user_id=1, github_node_id="node-bob", github_login="bob", github_name="Bob", github_avatar_url=None
+ )
+ with patch("console.views.GitHubOAuthClient", return_value=fake):
+ resp = self.client.get(reverse("console:oauth-callback"), {"code": "c", "state": state})
+
+ self.assertEqual(resp.status_code, 302)
+ self.assertEqual(resp["Location"], "/console/")
+ self.assertEqual(self.client.session.get(SESSION_USER_KEY), self.reviewer.id)
+
+ def test_oauth_callback_nonce_mismatch_rejected(self) -> None:
+ session = self.client.session
+ session[SESSION_NONCE_KEY] = "session-nonce"
+ session.save()
+ state = issue_console_oauth_state(claims=ConsoleOAuthStateClaims(nonce="different-nonce", next="/console/"))
+ resp = self.client.get(reverse("console:oauth-callback"), {"code": "c", "state": state})
+ self.assertEqual(resp.status_code, 400)
+ self.assertIsNone(self.client.session.get(SESSION_USER_KEY))
+
+ # ---- list view -----------------------------------------------------
+
+ def test_home_lists_pending_proposals(self) -> None:
+ self._make_pr(101)
+ self._proposal(101)
+ self._login_session()
+
+ resp = self.client.get(reverse("console:home"))
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "mathlib4 #101")
+ self.assertContains(resp, "Accept")
+ self.assertContains(resp, "Decline")
+
+ # ---- accept --------------------------------------------------------
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=False)
+ def test_accept_when_assign_on_accept_disabled(self) -> None:
+ self._make_pr(101)
+ proposal = self._proposal(101)
+ self._login_session()
+
+ resp = self.client.post(reverse("console:accept", args=[proposal.id]))
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "enabled yet")
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_PROPOSED)
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True)
+ def test_accept_assigns_and_marks_accepted(self) -> None:
+ self._make_pr(101)
+ proposal = self._proposal(101)
+ self._login_session()
+
+ with (
+ patch("core.services.github_operation_tokens.resolve_github_app_operation_token", return_value="tok"),
+ patch("console.views.assign_reviewer_and_record", return_value=("applied", None, None)) as mock_assign,
+ ):
+ resp = self.client.post(reverse("console:accept", args=[proposal.id]))
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "Assignment accepted")
+ mock_assign.assert_called_once()
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_ACCEPTED)
+ self.assertEqual(proposal.decided_via, AssignmentProposal.DECIDED_VIA_CONSOLE)
+ self.assertIsNotNone(proposal.decided_at)
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True)
+ def test_accept_stale_closed_pr_renders_unavailable_and_retires(self) -> None:
+ self._make_pr(101, state=PullRequestState.CLOSED)
+ proposal = self._proposal(101)
+ self._login_session()
+
+ with patch("console.views.assign_reviewer_and_record") as mock_assign:
+ resp = self.client.post(reverse("console:accept", args=[proposal.id]))
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "closed or merged")
+ mock_assign.assert_not_called()
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_SUPERSEDED)
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True)
+ def test_accept_proposal_for_other_reviewer_forbidden(self) -> None:
+ self._make_pr(101)
+ proposal = self._proposal(101, login="carol")
+ self._login_session() # signed in as bob
+
+ resp = self.client.post(reverse("console:accept", args=[proposal.id]))
+ self.assertEqual(resp.status_code, 403)
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_PROPOSED)
+
+ def test_accept_requires_post(self) -> None:
+ self._make_pr(101)
+ proposal = self._proposal(101)
+ self._login_session()
+ resp = self.client.get(reverse("console:accept", args=[proposal.id]))
+ self.assertEqual(resp.status_code, 405)
+
+ def test_accept_without_session_redirects_to_login(self) -> None:
+ self._make_pr(101)
+ proposal = self._proposal(101)
+ resp = self.client.post(reverse("console:accept", args=[proposal.id]))
+ self.assertEqual(resp.status_code, 302)
+ self.assertIn(reverse("console:login"), resp["Location"])
+
+ # ---- decline -------------------------------------------------------
+
+ def test_decline_marks_declined_and_opts_out(self) -> None:
+ self._make_pr(101)
+ proposal = self._proposal(101)
+ self._login_session()
+
+ resp = self.client.post(reverse("console:decline", args=[proposal.id]))
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "declined")
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_DECLINED)
+ self.assertEqual(proposal.decided_via, AssignmentProposal.DECIDED_VIA_CONSOLE)
+ opt_out = ReviewerOptOut.objects.get(repository=self.repo, pr_number=101, reviewer_login="bob")
+ self.assertTrue(opt_out.active)
+
+ def test_decline_already_terminal_renders_unavailable(self) -> None:
+ self._make_pr(101)
+ proposal = self._proposal(101, state=AssignmentProposal.STATE_ACCEPTED)
+ self._login_session()
+
+ resp = self.client.post(reverse("console:decline", args=[proposal.id]))
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "already been decided")
+ self.assertFalse(ReviewerOptOut.objects.filter(repository=self.repo, pr_number=101).exists())
diff --git a/qb_site/console/urls.py b/qb_site/console/urls.py
new file mode 100644
index 00000000..b85911bc
--- /dev/null
+++ b/qb_site/console/urls.py
@@ -0,0 +1,18 @@
+"""URL configuration for the reviewer console (design doc 050)."""
+
+from __future__ import annotations
+
+from django.urls import path
+
+from console import views
+
+app_name = "console"
+
+urlpatterns = [
+ path("", views.home, name="home"),
+ path("login/", views.login, name="login"),
+ path("oauth/callback/", views.oauth_callback, name="oauth-callback"),
+ path("logout/", views.logout, name="logout"),
+ path("proposals//accept/", views.accept, name="accept"),
+ path("proposals//decline/", views.decline, name="decline"),
+]
diff --git a/qb_site/console/views.py b/qb_site/console/views.py
new file mode 100644
index 00000000..ae24a556
--- /dev/null
+++ b/qb_site/console/views.py
@@ -0,0 +1,371 @@
+"""Reviewer console views (design doc 050).
+
+GitHub-OAuth authenticated, session-backed console where a ``confirm``-mode reviewer sees the
+assignment proposals awaiting their decision and accepts/declines them. Accept re-validates against
+live state and reuses the verbatim 046 mutation path (``assign_reviewer_and_record``) to perform the
+GitHub assignment; decline records a permanent per-PR opt-out. Every POST re-validates, so a
+proposal that is no longer actionable renders a clear "no longer available" instead of erroring.
+"""
+
+from __future__ import annotations
+
+import logging
+import secrets
+
+from django.conf import settings
+from django.db import transaction
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import redirect, render
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.http import url_has_allowed_host_and_scheme
+from django.views.decorators.http import require_GET, require_POST
+
+from analyzer.models import AssignmentProposal, QueueSnapshot, ReviewerOptOut
+from analyzer.services.assignment_proposal_validity import ProposalValidity, proposal_validity, resolve_on_queue_exit_policy
+from analyzer.services.queue_rules import default_rule_set_for_repo
+from analyzer.services.reviewer_assignment_apply import assign_reviewer_and_record
+from analyzer.services.reviewer_assignment_engine import _normalize_login
+from console import session as console_session
+from core.models import ReviewerPreference
+from core.services.github_identity import resolve_or_create_user_from_identity
+from core.services.github_oauth import GitHubOAuthClient, GitHubOAuthError
+from core.services.oauth_state import (
+ ConsoleOAuthStateClaims,
+ SignedStateError,
+ issue_console_oauth_state,
+ validate_console_oauth_state,
+)
+from core.services.site_urls import build_site_url
+from syncer.models import PRLabel, PullRequest
+
+log = logging.getLogger(__name__)
+
+SNAPSHOT_FRESH_SECONDS = 7200
+
+
+# --- auth --------------------------------------------------------------------
+
+
+def _safe_next(request: HttpRequest, raw: str | None) -> str:
+ """Return a safe same-site ``next`` path, defaulting to the console home."""
+ default = reverse("console:home")
+ if not raw:
+ return default
+ if url_has_allowed_host_and_scheme(raw, allowed_hosts={request.get_host()}) and raw.startswith("/"):
+ return raw
+ return default
+
+
+@require_GET
+def login(request: HttpRequest) -> HttpResponse:
+ """Start GitHub OAuth: stash a CSRF nonce in the session and redirect to GitHub."""
+ next_path = _safe_next(request, request.GET.get("next"))
+ try:
+ client = GitHubOAuthClient()
+ except GitHubOAuthError:
+ log.warning("console.login: GitHub OAuth is not configured")
+ return render(request, "console/error.html", {"message": "Sign-in is not configured on this server."}, status=503)
+
+ nonce = secrets.token_urlsafe(24)
+ console_session.set_oauth_nonce(request, nonce)
+ state = issue_console_oauth_state(claims=ConsoleOAuthStateClaims(nonce=nonce, next=next_path))
+ redirect_uri = build_site_url(reverse("console:oauth-callback"))
+ return redirect(client.build_authorize_url(state=state, redirect_uri=redirect_uri))
+
+
+@require_GET
+def oauth_callback(request: HttpRequest) -> HttpResponse:
+ """Complete GitHub OAuth: verify state+nonce, resolve the reviewer, open a session."""
+ code = request.GET.get("code")
+ state = request.GET.get("state")
+ expected_nonce = console_session.pop_oauth_nonce(request)
+ if not code or not state:
+ return render(request, "console/error.html", {"message": "Sign-in was cancelled or failed."}, status=400)
+ try:
+ claims = validate_console_oauth_state(state)
+ except SignedStateError:
+ return render(request, "console/error.html", {"message": "Sign-in link expired or was invalid. Try again."}, status=400)
+ # CSRF: the state's nonce must match the one we stored in this browser's session.
+ if not expected_nonce or not secrets.compare_digest(expected_nonce, claims.nonce):
+ return render(request, "console/error.html", {"message": "Sign-in could not be verified. Try again."}, status=400)
+
+ try:
+ client = GitHubOAuthClient()
+ redirect_uri = build_site_url(reverse("console:oauth-callback"))
+ token = client.exchange_code_for_access_token(code=code, redirect_uri=redirect_uri)
+ identity = client.fetch_user_identity(access_token=token)
+ except GitHubOAuthError:
+ log.warning("console.oauth_callback: GitHub OAuth exchange failed", exc_info=True)
+ return render(request, "console/error.html", {"message": "GitHub sign-in failed. Try again."}, status=502)
+
+ user = resolve_or_create_user_from_identity(identity)
+ console_session.set_reviewer(request, user)
+ return redirect(_safe_next(request, claims.next))
+
+
+@require_POST
+def logout(request: HttpRequest) -> HttpResponse:
+ console_session.clear_reviewer(request)
+ return redirect(reverse("console:home"))
+
+
+# --- console -----------------------------------------------------------------
+
+
+def _login_url(next_path: str) -> str:
+ return f"{reverse('console:login')}?next={next_path}"
+
+
+@require_GET
+def home(request: HttpRequest) -> HttpResponse:
+ reviewer = console_session.get_reviewer(request)
+ if reviewer is None:
+ return render(request, "console/login.html", {"login_url": _login_url(reverse("console:home"))})
+
+ context = _build_home_context(reviewer)
+ context["reviewer"] = reviewer
+ return render(request, "console/home.html", context)
+
+
+def _build_home_context(reviewer) -> dict:
+ proposals = list(
+ AssignmentProposal.objects.filter(
+ reviewer_login__iexact=reviewer.github_login,
+ state=AssignmentProposal.STATE_PROPOSED,
+ )
+ .select_related("repository")
+ .order_by("repository__owner", "repository__name", "expires_at", "pr_number")
+ )
+
+ # Preferred labels per repo, to render "why you" (matched topic labels).
+ preferred_by_repo: dict[int, set[str]] = {}
+ capacity_by_repo: dict[int, int] = {}
+ for pref in ReviewerPreference.objects.filter(user=reviewer).only("repository_id", "preferred_labels", "maximum_capacity"):
+ preferred_by_repo[pref.repository_id] = {str(lbl).lower() for lbl in (pref.preferred_labels or [])}
+ capacity_by_repo[pref.repository_id] = int(pref.maximum_capacity)
+
+ # Batch PR metadata + labels for the proposed PRs, grouped by repo.
+ proposal_rows: list[dict] = []
+ for proposal in proposals:
+ repo = proposal.repository
+ pr = PullRequest.objects.filter(repository=repo, number=proposal.pr_number).only("id", "number", "title", "state").first()
+ labels = (
+ list(PRLabel.objects.filter(pull_request=pr).select_related("label_def").values_list("label_def__name", flat=True))
+ if pr is not None
+ else []
+ )
+ preferred = preferred_by_repo.get(repo.id, set())
+ matched = sorted({lbl for lbl in labels if lbl and lbl.lower() in preferred})
+ proposal_rows.append(
+ {
+ "proposal": proposal,
+ "owner": repo.owner,
+ "repo": repo.name,
+ "pr_number": proposal.pr_number,
+ "title": (pr.title if pr is not None else None) or f"PR #{proposal.pr_number}",
+ "url": f"https://github.com/{repo.owner}/{repo.name}/pull/{proposal.pr_number}",
+ "matched_labels": matched,
+ "expires_at": proposal.expires_at,
+ }
+ )
+
+ # Load summary per repo: pending proposals + open PRs assigned + capacity.
+ pending_by_repo: dict[int, int] = {}
+ for proposal in proposals:
+ pending_by_repo[proposal.repository_id] = pending_by_repo.get(proposal.repository_id, 0) + 1
+ load_rows: list[dict] = []
+ for repo_id, capacity in sorted(capacity_by_repo.items()):
+ assigned_open = PullRequest.objects.filter(
+ repository_id=repo_id, state="open", assignees__contains=[reviewer.github_login]
+ ).count()
+ load_rows.append(
+ {
+ "repo_id": repo_id,
+ "assigned_open": assigned_open,
+ "pending": pending_by_repo.get(repo_id, 0),
+ "capacity": capacity,
+ }
+ )
+
+ return {"proposal_rows": proposal_rows, "load_rows": load_rows, "logout_url": reverse("console:logout")}
+
+
+# --- accept / decline --------------------------------------------------------
+
+
+def _queue_membership(repo, *, now) -> tuple[set[int], set[int], bool]:
+ rule_set = default_rule_set_for_repo(repo)
+ cache_key = str(rule_set.id) if rule_set else "default"
+ snapshot = QueueSnapshot.objects.filter(repository=repo, cache_key=cache_key).order_by("-generated_at").first()
+ if snapshot is None or not snapshot.payload:
+ return set(), set(), False
+ fresh = snapshot.generated_at is not None and (now - snapshot.generated_at).total_seconds() <= SNAPSHOT_FRESH_SECONDS
+ payload = snapshot.payload
+ queue_prs = {int(n) for n in (payload.get("lists", {}).get("dashboards", {}).get("Queue", []) or [])}
+ known_prs = {int(n) for n in (payload.get("prs", {}) or {}).keys()}
+ return queue_prs, known_prs, fresh
+
+
+def _live_validity(proposal: AssignmentProposal, *, now) -> tuple[ProposalValidity, PullRequest | None]:
+ repo = proposal.repository
+ pr_number = int(proposal.pr_number)
+ live_pr = PullRequest.objects.filter(repository=repo, number=pr_number).only("id", "number", "state", "assignees").first()
+ pr_state = None if live_pr is None else str(live_pr.state)
+ current_assignees = set() if live_pr is None else {_normalize_login(str(a)) for a in (live_pr.assignees or []) if a}
+ queue_prs, known_prs, fresh = _queue_membership(repo, now=now)
+ on_queue = (pr_number in queue_prs) if (fresh and pr_number in known_prs) else None
+ validity = proposal_validity(
+ proposal,
+ now=now,
+ pr_state=pr_state,
+ current_assignees=current_assignees,
+ on_queue=on_queue,
+ on_queue_exit=resolve_on_queue_exit_policy(),
+ )
+ return validity, live_pr
+
+
+def _retire(proposal: AssignmentProposal, validity: ProposalValidity, *, now) -> None:
+ """Persist a non-live verdict so the stale proposal is cleaned up (idempotent)."""
+ if validity.terminal_state is None:
+ return
+ AssignmentProposal.objects.filter(id=proposal.id, state=AssignmentProposal.STATE_PROPOSED).update(
+ state=validity.terminal_state,
+ decided_at=now,
+ decided_via=validity.decided_via or "",
+ updated_at=now,
+ )
+
+
+def _load_actionable_proposal(request: HttpRequest, proposal_id: int):
+ """Return (reviewer, proposal) or an ``HttpResponse`` to short-circuit with."""
+ reviewer = console_session.get_reviewer(request)
+ if reviewer is None:
+ return redirect(_login_url(reverse("console:home")))
+ proposal = AssignmentProposal.objects.select_related("repository", "snapshot").filter(id=int(proposal_id)).first()
+ if proposal is None:
+ return render(request, "console/unavailable.html", {"message": "That proposal no longer exists."}, status=404)
+ if _normalize_login(proposal.reviewer_login) != _normalize_login(reviewer.github_login):
+ return render(
+ request, "console/unavailable.html", {"message": "That proposal was made to a different reviewer."}, status=403
+ )
+ return reviewer, proposal
+
+
+_UNAVAILABLE_REASON = {
+ "already_terminal": "This proposal has already been decided.",
+ "expired": "This proposal has expired.",
+ "pr_assigned": "This PR already has an assignee.",
+ "pr_closed": "This PR is closed or merged.",
+ "pr_off_queue": "This PR is no longer on the review queue.",
+}
+
+
+@require_POST
+def accept(request: HttpRequest, proposal_id: int) -> HttpResponse:
+ loaded = _load_actionable_proposal(request, proposal_id)
+ if isinstance(loaded, HttpResponse):
+ return loaded
+ reviewer, proposal = loaded
+ now = timezone.now()
+
+ if proposal.state != AssignmentProposal.STATE_PROPOSED:
+ return render(request, "console/unavailable.html", {"message": _UNAVAILABLE_REASON["already_terminal"]})
+
+ # An explicit prior decline (opt-out) blocks re-acceptance.
+ if ReviewerOptOut.objects.filter(
+ repository=proposal.repository, pr_number=proposal.pr_number, reviewer_login__iexact=reviewer.github_login, active=True
+ ).exists():
+ _retire(proposal, ProposalValidity(is_live=False, reason="already_terminal"), now=now)
+ return render(request, "console/unavailable.html", {"message": "You have opted out of this PR."})
+
+ validity, _live_pr = _live_validity(proposal, now=now)
+ if not validity.is_live:
+ _retire(proposal, validity, now=now)
+ return render(
+ request,
+ "console/unavailable.html",
+ {"message": _UNAVAILABLE_REASON.get(validity.reason, "This proposal is no longer available.")},
+ )
+
+ if not bool(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED", False)):
+ # Staged rollout: acceptance is not executing GitHub assignments yet. Leave the proposal
+ # pending and tell the reviewer, rather than record an acceptance we cannot fulfil.
+ return render(request, "console/unavailable.html", {"message": "Accepting isn't enabled yet — please try again later."})
+
+ from core.services.github_operation_tokens import resolve_github_app_operation_token
+
+ token = resolve_github_app_operation_token(
+ operation="assign_pr", owner=proposal.repository.owner, repo=proposal.repository.name
+ )
+ if not token:
+ return render(request, "console/unavailable.html", {"message": "Assignment is temporarily unavailable. Try again later."})
+
+ outcome, _client, _record = assign_reviewer_and_record(
+ repository=proposal.repository,
+ pr_number=int(proposal.pr_number),
+ login=proposal.reviewer_login,
+ snapshot=proposal.snapshot,
+ run_date=now.date(),
+ token=token,
+ )
+ if outcome == "failed":
+ return render(
+ request, "console/unavailable.html", {"message": "GitHub rejected the assignment. Try again shortly."}, status=502
+ )
+
+ # Assignment landed (applied) or was already recorded — mark accepted.
+ AssignmentProposal.objects.filter(id=proposal.id, state=AssignmentProposal.STATE_PROPOSED).update(
+ state=AssignmentProposal.STATE_ACCEPTED,
+ decided_at=now,
+ decided_via=AssignmentProposal.DECIDED_VIA_CONSOLE,
+ updated_at=now,
+ )
+ return render(
+ request,
+ "console/decided.html",
+ {
+ "action": "accepted",
+ "owner": proposal.repository.owner,
+ "repo": proposal.repository.name,
+ "pr_number": proposal.pr_number,
+ },
+ )
+
+
+@require_POST
+def decline(request: HttpRequest, proposal_id: int) -> HttpResponse:
+ loaded = _load_actionable_proposal(request, proposal_id)
+ if isinstance(loaded, HttpResponse):
+ return loaded
+ reviewer, proposal = loaded
+ now = timezone.now()
+
+ if proposal.state != AssignmentProposal.STATE_PROPOSED:
+ return render(request, "console/unavailable.html", {"message": _UNAVAILABLE_REASON["already_terminal"]})
+
+ with transaction.atomic():
+ AssignmentProposal.objects.filter(id=proposal.id, state=AssignmentProposal.STATE_PROPOSED).update(
+ state=AssignmentProposal.STATE_DECLINED,
+ decided_at=now,
+ decided_via=AssignmentProposal.DECIDED_VIA_CONSOLE,
+ updated_at=now,
+ )
+ # Decline == explicit "not this PR" -> permanent per-PR opt-out (reuses builder enforcement).
+ ReviewerOptOut.objects.update_or_create(
+ repository=proposal.repository,
+ pr_number=int(proposal.pr_number),
+ reviewer_login=proposal.reviewer_login,
+ defaults={"active": True, "opted_out_at": now, "cleared_at": None},
+ )
+ return render(
+ request,
+ "console/decided.html",
+ {
+ "action": "declined",
+ "owner": proposal.repository.owner,
+ "repo": proposal.repository.name,
+ "pr_number": proposal.pr_number,
+ },
+ )
diff --git a/qb_site/core/services/github_identity.py b/qb_site/core/services/github_identity.py
new file mode 100644
index 00000000..2a61f738
--- /dev/null
+++ b/qb_site/core/services/github_identity.py
@@ -0,0 +1,63 @@
+"""Resolve a GitHub OAuth identity to a ``core.User`` (Zulip-agnostic).
+
+Used by the reviewer console (design doc 050): a reviewer authenticates with GitHub and we need
+the matching ``core.User`` to key their proposals on. Unlike the registration linker this touches
+no Zulip fields — it only resolves/creates the person and refreshes their GitHub identity fields.
+"""
+
+from __future__ import annotations
+
+from django.db import IntegrityError, transaction
+
+from core.models import User
+from core.services.github_oauth import GitHubUserIdentity
+
+
+@transaction.atomic
+def resolve_or_create_user_from_identity(identity: GitHubUserIdentity) -> User:
+ """Return the ``core.User`` for ``identity`` (by node id, else login), creating one if needed.
+
+ Race-safe against the syncer ingesting the same GitHub user concurrently (savepoint + re-fetch,
+ per the "Concurrent Writers and Unique Keys" rules). Refreshes mutable identity fields
+ (login/name/avatar) when they drift; never clobbers a conflicting non-empty ``github_node_id``.
+ """
+ user = User.objects.select_for_update().filter(github_node_id=identity.github_node_id).first()
+ if user is None:
+ user = User.objects.select_for_update().filter(github_login__iexact=identity.github_login).first()
+
+ if user is None:
+ try:
+ with transaction.atomic():
+ return User.objects.create(
+ github_node_id=identity.github_node_id,
+ github_login=identity.github_login,
+ name=identity.github_name,
+ avatar_url=identity.github_avatar_url,
+ is_active=True,
+ )
+ except IntegrityError:
+ user = User.objects.select_for_update().filter(github_node_id=identity.github_node_id).first()
+ if user is None:
+ user = User.objects.select_for_update().filter(github_login__iexact=identity.github_login).first()
+ if user is None:
+ raise
+
+ changed: set[str] = set()
+ if not user.github_node_id and identity.github_node_id:
+ user.github_node_id = identity.github_node_id
+ changed.add("github_node_id")
+ if user.github_login != identity.github_login:
+ user.github_login = identity.github_login
+ changed.add("github_login")
+ if user.name != identity.github_name:
+ user.name = identity.github_name
+ changed.add("name")
+ if user.avatar_url != identity.github_avatar_url:
+ user.avatar_url = identity.github_avatar_url
+ changed.add("avatar_url")
+ if changed:
+ user.save(update_fields=sorted(changed))
+ return user
+
+
+__all__ = ["resolve_or_create_user_from_identity"]
diff --git a/qb_site/core/services/oauth_state.py b/qb_site/core/services/oauth_state.py
new file mode 100644
index 00000000..f23a886b
--- /dev/null
+++ b/qb_site/core/services/oauth_state.py
@@ -0,0 +1,129 @@
+"""Signed, expiring OAuth ``state`` payloads shared by every GitHub-OAuth flow.
+
+A small Fernet-based primitive (encrypt + integrity + TTL over a JSON dict) plus a token-less
+console helper built on it. The Zulip registration flow's state helper delegates to the same
+primitive so all consumers share one implementation (design doc 050).
+
+The ``state`` round-trips CSRF protection: the caller stores a random ``nonce`` in the session,
+embeds it here, and on callback confirms the returned state's nonce matches the session — so a
+forged callback cannot complete the flow.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import time
+from dataclasses import dataclass
+from typing import Any
+
+from cryptography.fernet import Fernet, InvalidToken
+from django.conf import settings
+
+
+class SignedStateError(Exception):
+ """Base class for signed-state failures."""
+
+
+class SignedStateExpired(SignedStateError):
+ """The state's ``exp`` is in the past."""
+
+
+class SignedStateInvalid(SignedStateError):
+ """The state is malformed, tampered with, or undecryptable."""
+
+
+def _fernet(*, secret: str, salt: str) -> Fernet:
+ material = f"{secret}:{salt}".encode("utf-8")
+ key = base64.urlsafe_b64encode(hashlib.sha256(material).digest())
+ return Fernet(key)
+
+
+def issue_signed_state(
+ payload: dict[str, Any],
+ *,
+ secret: str,
+ salt: str,
+ ttl_seconds: int,
+ now: int | None = None,
+) -> str:
+ """Encrypt ``payload`` (plus ``iat``/``exp``) into an opaque, URL-safe state string."""
+ now_ts = int(now if now is not None else time.time())
+ body = dict(payload)
+ body["iat"] = now_ts
+ body["exp"] = now_ts + int(ttl_seconds)
+ encrypted = _fernet(secret=secret, salt=salt).encrypt(json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8"))
+ return encrypted.decode("utf-8")
+
+
+def read_signed_state(state: str, *, secret: str, salt: str, now: int | None = None) -> dict[str, Any]:
+ """Decrypt + verify a state string. Raises ``SignedStateInvalid``/``SignedStateExpired``."""
+ try:
+ decrypted = _fernet(secret=secret, salt=salt).decrypt(state.encode("utf-8"))
+ except (InvalidToken, ValueError, UnicodeEncodeError) as exc:
+ raise SignedStateInvalid("invalid state") from exc
+ try:
+ payload = json.loads(decrypted.decode("utf-8"))
+ except (ValueError, UnicodeDecodeError) as exc:
+ raise SignedStateInvalid("invalid payload") from exc
+ if not isinstance(payload, dict):
+ raise SignedStateInvalid("invalid payload")
+ exp = payload.get("exp")
+ if not isinstance(exp, int):
+ raise SignedStateInvalid("invalid exp")
+ if int(now if now is not None else time.time()) > exp:
+ raise SignedStateExpired("state expired")
+ return payload
+
+
+# --- Console (token-less) OAuth state -------------------------------------------------
+
+CONSOLE_OAUTH_STATE_SALT = "core.console.oauth_state"
+
+
+@dataclass(frozen=True)
+class ConsoleOAuthStateClaims:
+ nonce: str
+ next: str = ""
+
+
+def _console_secret() -> str:
+ return settings.SECRET_KEY
+
+
+def _console_ttl_seconds() -> int:
+ return int(getattr(settings, "CONSOLE_OAUTH_STATE_TTL_SECONDS", 600))
+
+
+def issue_console_oauth_state(*, claims: ConsoleOAuthStateClaims, now: int | None = None) -> str:
+ return issue_signed_state(
+ {"nonce": claims.nonce, "next": claims.next},
+ secret=_console_secret(),
+ salt=CONSOLE_OAUTH_STATE_SALT,
+ ttl_seconds=_console_ttl_seconds(),
+ now=now,
+ )
+
+
+def validate_console_oauth_state(state: str, *, now: int | None = None) -> ConsoleOAuthStateClaims:
+ payload = read_signed_state(state, secret=_console_secret(), salt=CONSOLE_OAUTH_STATE_SALT, now=now)
+ nonce = payload.get("nonce")
+ if not isinstance(nonce, str) or not nonce:
+ raise SignedStateInvalid("invalid nonce")
+ next_path = payload.get("next")
+ if next_path is not None and not isinstance(next_path, str):
+ raise SignedStateInvalid("invalid next")
+ return ConsoleOAuthStateClaims(nonce=nonce, next=next_path or "")
+
+
+__all__ = [
+ "SignedStateError",
+ "SignedStateExpired",
+ "SignedStateInvalid",
+ "issue_signed_state",
+ "read_signed_state",
+ "ConsoleOAuthStateClaims",
+ "issue_console_oauth_state",
+ "validate_console_oauth_state",
+]
diff --git a/qb_site/core/services/site_urls.py b/qb_site/core/services/site_urls.py
new file mode 100644
index 00000000..6c5c81f7
--- /dev/null
+++ b/qb_site/core/services/site_urls.py
@@ -0,0 +1,33 @@
+"""Resolve the queueboard site's public base URL for building absolute deep-links.
+
+Single source of truth so feature link-builders (the reviewer console, Zulip prefs/registration
+links, …) don't each re-implement the fallback chain. Prefer the canonical ``QUEUEBOARD_BASE_URL``;
+fall back to the legacy ``ZULIP_PREFS_URL_BASE`` so deployments that only set the older variable
+keep working. Both are ``scheme://host`` with no trailing slash.
+"""
+
+from __future__ import annotations
+
+from django.conf import settings
+
+__all__ = ["resolve_site_base_url", "build_site_url"]
+
+
+def resolve_site_base_url() -> str:
+ """Return the site base URL (``scheme://host``, no trailing slash), or ``""`` if unconfigured."""
+ canonical = str(getattr(settings, "QUEUEBOARD_BASE_URL", "") or "").strip().rstrip("/")
+ if canonical:
+ return canonical
+ legacy = str(getattr(settings, "ZULIP_PREFS_URL_BASE", "") or "").strip().rstrip("/")
+ return legacy
+
+
+def build_site_url(path: str) -> str:
+ """Join ``path`` onto the site base URL. Returns the bare ``path`` when no base is configured.
+
+ ``path`` should start with ``/``; the result is an absolute URL when a base exists (suitable for
+ a DM/email) and a site-relative path otherwise (still usable in-app during local dev).
+ """
+ normalized = path if path.startswith("/") else f"/{path}"
+ base = resolve_site_base_url()
+ return f"{base}{normalized}" if base else normalized
diff --git a/qb_site/core/tests/test_console_oauth_helpers.py b/qb_site/core/tests/test_console_oauth_helpers.py
new file mode 100644
index 00000000..c862724e
--- /dev/null
+++ b/qb_site/core/tests/test_console_oauth_helpers.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+from django.test import SimpleTestCase, TestCase, override_settings
+
+from core.models import User
+from core.services.github_identity import resolve_or_create_user_from_identity
+from core.services.github_oauth import GitHubUserIdentity
+from core.services.oauth_state import (
+ ConsoleOAuthStateClaims,
+ SignedStateExpired,
+ SignedStateInvalid,
+ issue_console_oauth_state,
+ issue_signed_state,
+ read_signed_state,
+ validate_console_oauth_state,
+)
+from core.services.site_urls import build_site_url, resolve_site_base_url
+
+
+class SignedStateTests(SimpleTestCase):
+ def test_round_trip(self) -> None:
+ state = issue_signed_state({"k": "v"}, secret="s", salt="salt", ttl_seconds=600, now=1000)
+ payload = read_signed_state(state, secret="s", salt="salt", now=1100)
+ self.assertEqual(payload["k"], "v")
+ self.assertEqual(payload["iat"], 1000)
+ self.assertEqual(payload["exp"], 1600)
+
+ def test_expired(self) -> None:
+ state = issue_signed_state({"k": "v"}, secret="s", salt="salt", ttl_seconds=600, now=1000)
+ with self.assertRaises(SignedStateExpired):
+ read_signed_state(state, secret="s", salt="salt", now=1601)
+
+ def test_wrong_secret_is_invalid(self) -> None:
+ state = issue_signed_state({"k": "v"}, secret="s", salt="salt", ttl_seconds=600, now=1000)
+ with self.assertRaises(SignedStateInvalid):
+ read_signed_state(state, secret="other", salt="salt", now=1100)
+
+ def test_tampered_is_invalid(self) -> None:
+ with self.assertRaises(SignedStateInvalid):
+ read_signed_state("not-a-real-token", secret="s", salt="salt", now=1100)
+
+ def test_console_state_carries_nonce_and_next(self) -> None:
+ state = issue_console_oauth_state(claims=ConsoleOAuthStateClaims(nonce="abc", next="/console/"), now=1000)
+ claims = validate_console_oauth_state(state, now=1100)
+ self.assertEqual(claims.nonce, "abc")
+ self.assertEqual(claims.next, "/console/")
+
+
+class SiteUrlsTests(SimpleTestCase):
+ @override_settings(QUEUEBOARD_BASE_URL="https://qb.example.com", ZULIP_PREFS_URL_BASE="https://legacy.example.com")
+ def test_prefers_canonical(self) -> None:
+ self.assertEqual(resolve_site_base_url(), "https://qb.example.com")
+ self.assertEqual(build_site_url("/console/"), "https://qb.example.com/console/")
+
+ @override_settings(QUEUEBOARD_BASE_URL="", ZULIP_PREFS_URL_BASE="https://legacy.example.com")
+ def test_falls_back_to_legacy(self) -> None:
+ self.assertEqual(resolve_site_base_url(), "https://legacy.example.com")
+
+ @override_settings(QUEUEBOARD_BASE_URL="", ZULIP_PREFS_URL_BASE="")
+ def test_empty_returns_relative_path(self) -> None:
+ self.assertEqual(resolve_site_base_url(), "")
+ self.assertEqual(build_site_url("/console/"), "/console/")
+
+
+class ResolveUserFromIdentityTests(TestCase):
+ def _identity(self, *, node="MDQ6VXNlcjE=", login="alice", name="Alice", avatar="http://a") -> GitHubUserIdentity:
+ return GitHubUserIdentity(
+ github_user_id=1,
+ github_node_id=node,
+ github_login=login,
+ github_name=name,
+ github_avatar_url=avatar,
+ )
+
+ def test_creates_when_absent(self) -> None:
+ user = resolve_or_create_user_from_identity(self._identity())
+ self.assertEqual(user.github_login, "alice")
+ self.assertEqual(user.github_node_id, "MDQ6VXNlcjE=")
+
+ def test_matches_by_node_id_and_refreshes_login(self) -> None:
+ existing = User.objects.create(github_node_id="MDQ6VXNlcjE=", github_login="old-login")
+ user = resolve_or_create_user_from_identity(self._identity(login="new-login"))
+ self.assertEqual(user.id, existing.id)
+ self.assertEqual(user.github_login, "new-login")
+
+ def test_matches_by_login_case_insensitively(self) -> None:
+ existing = User.objects.create(github_login="Alice")
+ user = resolve_or_create_user_from_identity(self._identity(login="alice", node="MDQ6VXNlcjE="))
+ self.assertEqual(user.id, existing.id)
+ # node id backfilled on the previously node-less row
+ self.assertEqual(user.github_node_id, "MDQ6VXNlcjE=")
diff --git a/qb_site/qb_site/settings/base.py b/qb_site/qb_site/settings/base.py
index a3ab0bbe..217d39d4 100644
--- a/qb_site/qb_site/settings/base.py
+++ b/qb_site/qb_site/settings/base.py
@@ -60,6 +60,7 @@ def env_optional_bounded_int(name: str, *, minimum: int, maximum: int) -> int |
"analyzer",
"api",
"zulip_bot",
+ "console",
]
MIDDLEWARE = [
@@ -152,13 +153,22 @@ def env_optional_bounded_int(name: str, *, minimum: int, maximum: int) -> int |
},
}
+# Canonical public base URL of the queueboard Django site (scheme://host, no trailing path).
+# Feature link-builders (reviewer console, Zulip prefs/registration deep-links) fall back to this,
+# so a deployment can set one variable instead of several. NOTE: this is the web app, NOT the Zulip
+# chat server (ZULIP_BASE_URL). Leave empty only in local dev. Resolve via
+# core.services.site_urls.resolve_site_base_url() rather than reading either setting directly.
+QUEUEBOARD_BASE_URL = os.getenv("QUEUEBOARD_BASE_URL", "").strip().rstrip("/")
+
ZULIP_WEBHOOK_TOKEN = os.getenv("ZULIP_WEBHOOK_TOKEN")
ZULIP_BASE_URL = os.getenv("ZULIP_BASE_URL", "")
ZULIP_BOT_EMAIL = os.getenv("ZULIP_BOT_EMAIL", "")
ZULIP_BOT_API_KEY = os.getenv("ZULIP_BOT_API_KEY", "")
ZULIP_USER_EMAIL = os.getenv("ZULIP_USER_EMAIL", "")
ZULIP_USER_API_KEY = os.getenv("ZULIP_USER_API_KEY", "")
-ZULIP_PREFS_URL_BASE = os.getenv("ZULIP_PREFS_URL_BASE", "")
+# Back-compat: existing deployments set this directly. When unset it falls back to the canonical
+# QUEUEBOARD_BASE_URL so a single variable configures every deep-link base.
+ZULIP_PREFS_URL_BASE = os.getenv("ZULIP_PREFS_URL_BASE", "").strip().rstrip("/") or QUEUEBOARD_BASE_URL
ZULIP_PREFS_TOKEN_SECRET = os.getenv("ZULIP_PREFS_TOKEN_SECRET", "")
ZULIP_PREFS_TOKEN_SALT = os.getenv("ZULIP_PREFS_TOKEN_SALT", "zulip_bot.prefs")
ZULIP_PREFS_TOKEN_TTL_SECONDS = int(os.getenv("ZULIP_PREFS_TOKEN_TTL_SECONDS", 1800))
diff --git a/qb_site/qb_site/urls.py b/qb_site/qb_site/urls.py
index bd75360f..c738658f 100644
--- a/qb_site/qb_site/urls.py
+++ b/qb_site/qb_site/urls.py
@@ -7,5 +7,6 @@
path("admin/", admin.site.urls),
path("api/", include("api.urls")),
path("api/zulip/", include("zulip_bot.urls")),
+ path("console/", include("console.urls")),
path("", include("syncer.urls")),
]
diff --git a/qb_site/templates/console/base.html b/qb_site/templates/console/base.html
new file mode 100644
index 00000000..e28d1ed4
--- /dev/null
+++ b/qb_site/templates/console/base.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ {% block title %}Reviewer console{% endblock %}
+
+
+
+ {% block body %}{% endblock %}
+
+
diff --git a/qb_site/templates/console/decided.html b/qb_site/templates/console/decided.html
new file mode 100644
index 00000000..7cf1b81f
--- /dev/null
+++ b/qb_site/templates/console/decided.html
@@ -0,0 +1,13 @@
+{% extends "console/base.html" %}
+{% block title %}{{ action|title }} — Reviewer console{% endblock %}
+{% block body %}
+ {% if action == "accepted" %}
+ Assignment accepted
+ You are now assigned to
+ {{ owner }}/{{ repo }} #{{ pr_number }} .
+ {% else %}
+ Proposal declined
+ You have declined {{ owner }}/{{ repo }} #{{ pr_number }}. You won't be proposed for this PR again.
+ {% endif %}
+ Back to the console
+{% endblock %}
diff --git a/qb_site/templates/console/error.html b/qb_site/templates/console/error.html
new file mode 100644
index 00000000..ac6637e0
--- /dev/null
+++ b/qb_site/templates/console/error.html
@@ -0,0 +1,7 @@
+{% extends "console/base.html" %}
+{% block title %}Sign-in problem — Reviewer console{% endblock %}
+{% block body %}
+ Sign-in problem
+ {{ message }}
+ Back to the console
+{% endblock %}
diff --git a/qb_site/templates/console/home.html b/qb_site/templates/console/home.html
new file mode 100644
index 00000000..89c18f25
--- /dev/null
+++ b/qb_site/templates/console/home.html
@@ -0,0 +1,43 @@
+{% extends "console/base.html" %}
+{% block title %}Reviewer console{% endblock %}
+{% block body %}
+ Reviewer console
+
+ Signed in as {{ reviewer.github_login }}.
+
+
+
+ Proposals awaiting your acceptance
+ {% if proposal_rows %}
+ {% for row in proposal_rows %}
+
+
+
{{ row.title }}
+ {% if row.matched_labels %}
+
+ Matches your areas:
+ {% for label in row.matched_labels %}{{ label }} {% endfor %}
+
+ {% endif %}
+
Expires {{ row.expires_at|date:"Y-m-d H:i" }} UTC
+
+
+
+
+
+ {% endfor %}
+ {% else %}
+ You have no proposals awaiting acceptance right now.
+ {% endif %}
+
+ {% if load_rows %}
+ Your load
+ {% for load in load_rows %}
+ {{ load.assigned_open }} assigned + {{ load.pending }} pending / capacity {{ load.capacity }}
+ {% endfor %}
+ {% endif %}
+{% endblock %}
diff --git a/qb_site/templates/console/login.html b/qb_site/templates/console/login.html
new file mode 100644
index 00000000..6f8b54b5
--- /dev/null
+++ b/qb_site/templates/console/login.html
@@ -0,0 +1,8 @@
+{% extends "console/base.html" %}
+{% block title %}Sign in — Reviewer console{% endblock %}
+{% block body %}
+ Reviewer console
+ Review and act on the PR assignments proposed to you.
+ Sign in with GitHub to see the assignments awaiting your acceptance.
+ Sign in with GitHub
+{% endblock %}
diff --git a/qb_site/templates/console/unavailable.html b/qb_site/templates/console/unavailable.html
new file mode 100644
index 00000000..9b697e9e
--- /dev/null
+++ b/qb_site/templates/console/unavailable.html
@@ -0,0 +1,7 @@
+{% extends "console/base.html" %}
+{% block title %}No longer available — Reviewer console{% endblock %}
+{% block body %}
+ No longer available
+ {{ message }}
+ Back to the console
+{% endblock %}
diff --git a/qb_site/zulip_bot/forms.py b/qb_site/zulip_bot/forms.py
index 8ca8eb09..3d31f885 100644
--- a/qb_site/zulip_bot/forms.py
+++ b/qb_site/zulip_bot/forms.py
@@ -30,6 +30,10 @@
"created_at",
"updated_at",
"notification_settings",
+ # Acceptance-gate mode (design doc 050). Deliberately not self-serve in the Zulip prefs form
+ # yet: set via the ReviewerPreference admin bulk actions / community decision. Expose here only
+ # if reviewers ask to flip their own mode.
+ "assignment_acceptance",
)
diff --git a/qb_site/zulip_bot/services/registration_oauth_state.py b/qb_site/zulip_bot/services/registration_oauth_state.py
index 84a8338a..2c9ebea0 100644
--- a/qb_site/zulip_bot/services/registration_oauth_state.py
+++ b/qb_site/zulip_bot/services/registration_oauth_state.py
@@ -1,15 +1,16 @@
from __future__ import annotations
-import base64
-import hashlib
-import json
-import time
from dataclasses import dataclass
-from typing import Any
-from cryptography.fernet import Fernet, InvalidToken
from django.conf import settings
+from core.services.oauth_state import (
+ SignedStateExpired,
+ SignedStateInvalid,
+ issue_signed_state,
+ read_signed_state,
+)
+
@dataclass(frozen=True)
class RegistrationOAuthStateClaims:
@@ -32,43 +33,30 @@ class RegistrationOAuthStateInvalid(RegistrationOAuthStateError):
def issue_registration_oauth_state(*, claims: RegistrationOAuthStateClaims) -> str:
- now_ts = int(time.time())
- payload = {
- "registration_token": claims.registration_token,
- "registration_nonce": claims.registration_nonce,
- "iat": now_ts,
- "exp": now_ts + _state_ttl_seconds(),
- }
- encrypted = _fernet().encrypt(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
- return encrypted.decode("utf-8")
+ return issue_signed_state(
+ {
+ "registration_token": claims.registration_token,
+ "registration_nonce": claims.registration_nonce,
+ },
+ secret=_token_secret(),
+ salt=_token_salt(),
+ ttl_seconds=_state_ttl_seconds(),
+ )
def validate_registration_oauth_state(state: str) -> RegistrationOAuthStateClaims:
try:
- decrypted = _fernet().decrypt(state.encode("utf-8"))
- except (InvalidToken, ValueError, UnicodeEncodeError) as exc:
+ payload = read_signed_state(state, secret=_token_secret(), salt=_token_salt())
+ except SignedStateExpired as exc:
+ raise RegistrationOAuthStateExpired("state expired") from exc
+ except SignedStateInvalid as exc:
raise RegistrationOAuthStateInvalid("invalid state") from exc
- try:
- payload = json.loads(decrypted.decode("utf-8"))
- except (ValueError, UnicodeDecodeError) as exc:
- raise RegistrationOAuthStateInvalid("invalid payload") from exc
- return _parse_claims(payload)
-
-
-def _parse_claims(payload: Any) -> RegistrationOAuthStateClaims:
- if not isinstance(payload, dict):
- raise RegistrationOAuthStateInvalid("invalid payload")
registration_token = payload.get("registration_token")
registration_nonce = payload.get("registration_nonce")
- exp = payload.get("exp")
if not isinstance(registration_token, str) or not registration_token:
raise RegistrationOAuthStateInvalid("invalid registration_token")
if not isinstance(registration_nonce, str) or not registration_nonce:
raise RegistrationOAuthStateInvalid("invalid registration_nonce")
- if not isinstance(exp, int):
- raise RegistrationOAuthStateInvalid("invalid exp")
- if int(time.time()) > exp:
- raise RegistrationOAuthStateExpired("state expired")
iat = payload.get("iat")
if iat is not None and not isinstance(iat, int):
raise RegistrationOAuthStateInvalid("invalid iat")
@@ -76,7 +64,7 @@ def _parse_claims(payload: Any) -> RegistrationOAuthStateClaims:
registration_token=registration_token,
registration_nonce=registration_nonce,
iat=iat,
- exp=exp,
+ exp=payload.get("exp"),
)
@@ -91,11 +79,5 @@ def _token_salt() -> str:
return getattr(settings, "ZULIP_REGISTRATION_OAUTH_STATE_SALT", "zulip_bot.registration.oauth_state")
-def _fernet() -> Fernet:
- material = f"{_token_secret()}:{_token_salt()}".encode("utf-8")
- key = base64.urlsafe_b64encode(hashlib.sha256(material).digest())
- return Fernet(key)
-
-
def _state_ttl_seconds() -> int:
return int(getattr(settings, "ZULIP_REGISTRATION_OAUTH_STATE_TTL_SECONDS", 600))
diff --git a/qb_site/zulip_bot/tests/test_registration_oauth_state.py b/qb_site/zulip_bot/tests/test_registration_oauth_state.py
index 61df1d21..4fee7f3b 100644
--- a/qb_site/zulip_bot/tests/test_registration_oauth_state.py
+++ b/qb_site/zulip_bot/tests/test_registration_oauth_state.py
@@ -32,13 +32,13 @@ def test_invalid_state_rejected(self) -> None:
validate_registration_oauth_state("not-a-state")
def test_expired_state_rejected(self) -> None:
- with patch("zulip_bot.services.registration_oauth_state.time.time", return_value=1_700_000_000):
+ with patch("core.services.oauth_state.time.time", return_value=1_700_000_000):
state = issue_registration_oauth_state(
claims=RegistrationOAuthStateClaims(
registration_token="reg-token",
registration_nonce="nonce-123",
)
)
- with patch("zulip_bot.services.registration_oauth_state.time.time", return_value=1_700_000_000 + 900):
+ with patch("core.services.oauth_state.time.time", return_value=1_700_000_000 + 900):
with self.assertRaises(RegistrationOAuthStateExpired):
validate_registration_oauth_state(state)
From b13e84941bca408099b62d137e9bf1b760b002d9 Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Thu, 9 Jul 2026 00:06:19 -0400
Subject: [PATCH 07/33] docs: surface console deploy settings; enforce
.env.example hygiene
Follow-up to the acceptance-gate console (design doc 050), preparing for deploy
and for continuing Chunk 5 in a fresh session.
- Fix a phantom setting: CONSOLE_OAUTH_STATE_TTL_SECONDS was read via
getattr(settings, ...) but never wired to os.getenv/base.py or .env.example
(so it was silently uneditable). Wire it through base.py + .env.example.
- Surface the live-deployment adjustments the console needs, where operators look:
- QUEUEBOARD_BASE_URL (new canonical site base) documented in .env.example and
docs/zulip_github_oauth_setup.md, plus a deploy-prereqs note in design doc 050.
- The GitHub OAuth App callback must permit /console/oauth/callback/; register the
callback at the site root so both the registration and console paths are covered.
Documented in the OAuth runbook (was registration-only) and design doc 050.
- Strengthen the "settings must be in base.py AND .env.example" rule in the root
AGENTS.md (add the getattr-phantom antipattern) and add a Settings & .env Hygiene
section to qb_site/AGENTS.md, since this step is often forgotten.
- Record the resolved Chunk 5 (notification) build plan in design doc 050 so it can
be picked up in a fresh session: separate delivery task gated by
ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED, notified_at as the dedup key,
console link via build_site_url, and the narrow attention-ping suppression rule.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.env.example | 9 ++++-
AGENTS.md | 7 +++-
...050-reviewer-assignment-acceptance-gate.md | 30 ++++++++++++++--
docs/zulip_github_oauth_setup.md | 36 +++++++++++++------
qb_site/AGENTS.md | 10 ++++++
qb_site/qb_site/settings/base.py | 3 ++
6 files changed, 79 insertions(+), 16 deletions(-)
diff --git a/.env.example b/.env.example
index 10958b5f..f7c7fa87 100644
--- a/.env.example
+++ b/.env.example
@@ -106,13 +106,20 @@ GH_TOKEN=
# GITHUB_TOKEN=
# REST API base URL used by OAuth/GitHub App token flows.
GITHUB_API_URL=https://api.github.com
-# GitHub OAuth (used by Zulip registration flow)
+# GitHub OAuth (used by the Zulip registration flow AND the reviewer console, design doc 050).
+# DEPLOY NOTE: the console's callback is /console/oauth/callback/ (built from
+# the base URL, NOT from GITHUB_OAUTH_REDIRECT_URI). GitHub requires the redirect_uri to be a
+# subdirectory of the OAuth App's registered "Authorization callback URL", so register that callback
+# at the site root (https:///) — otherwise console sign-in fails with redirect_uri_mismatch.
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
+# Registration-flow callback (the console derives its own callback from QUEUEBOARD_BASE_URL).
GITHUB_OAUTH_REDIRECT_URI=
GITHUB_OAUTH_AUTHORIZE_URL=https://github.com/login/oauth/authorize
GITHUB_OAUTH_TOKEN_URL=https://github.com/login/oauth/access_token
GITHUB_OAUTH_SCOPE=read:user
+# Reviewer console: TTL (seconds) for the signed OAuth state round-trip. Default 10 minutes.
+CONSOLE_OAUTH_STATE_TTL_SECONDS=600
# Shared secret used to validate GitHub webhook signatures (X-Hub-Signature-256).
GITHUB_WEBHOOK_SECRET=
# JSON object describing GitHub App credentials and operation mapping.
diff --git a/AGENTS.md b/AGENTS.md
index 4d856f46..805ba7ee 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,7 +49,12 @@ Notes
- Copy `.env.example` to `.env` for local Django work; supply database credentials, GitHub tokens, and task runner settings as described in `docs/django_backend_plan.md`.
- Run the stack through `docker compose` against PostgreSQL; we no longer support SQLite fallbacks for quick tests.
- Keep secrets out of version control—store them in the `.env` file or your chosen secret manager.
-- **When adding a new Django setting backed by an env var**: always add it in both `qb_site/qb_site/settings/base.py` (as `FOO = os.getenv("FOO", ...)`) *and* `.env.example` (with a comment). Omitting either means the setting silently has no effect in production or is undiscoverable for new deployments.
+- **Every configurable setting MUST be wired through `base.py` AND documented in `.env.example` — no exceptions.** This is forgotten often; treat it as part of "done" for any setting change:
+ 1. Define it in `qb_site/qb_site/settings/base.py` as `FOO = os.getenv("FOO", )` (with a short comment).
+ 2. Add `FOO=` to `.env.example` with a comment explaining it.
+ - **Antipattern that silently breaks this:** reading `getattr(settings, "FOO", default)` in code *without* a matching `os.getenv` line in `base.py`. That "phantom" setting can never be configured — it is always the hardcoded default — and is invisible to `.env.example`. If a value is meant to be tunable, wire it through `base.py`; if it is a true constant, make it a module-level constant, not a `getattr(settings, ...)`.
+ - Consequence of skipping either step: the setting has no effect in production (missing from `base.py`) or is undiscoverable for new deployments (missing from `.env.example`).
+ - When a setting also requires a live-deployment change (base URL, OAuth callback, secrets), surface it in the relevant runbook under `docs/` too (e.g. `docs/zulip_github_oauth_setup.md`).
## Containers & Volumes
- Code is bind-mounted read-only into containers (`.:/app:ro`) to avoid writes into the repo from inside Docker.
diff --git a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
index f9984595..0f2ab32a 100644
--- a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
+++ b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
@@ -333,6 +333,17 @@ a later step.
indefinitely (see "History"). Rollout follows the 028 discipline: *propose → deliver →
assign-on-accept*, each independently toggleable, plus dry-run.
+**Deploy prerequisites (live-env, new for this feature).** Beyond the feature flags above, the
+console + notification rollout needs two live-deployment adjustments, easy to miss because they are
+not new *flags* but new *infrastructure config*:
+
+- Set **`QUEUEBOARD_BASE_URL`** (canonical site base). The console callback and the console link in
+ reviewer DMs are built from it; legacy deployments that only set `ZULIP_PREFS_URL_BASE` keep
+ working via fallback, but new deploys should set `QUEUEBOARD_BASE_URL`.
+- The GitHub OAuth App's **Authorization callback URL** must permit `/console/oauth/callback/`.
+ Because registration and the console use different callback *paths*, register the callback at the
+ **site root** so both are subdirectories of it (see `docs/zulip_github_oauth_setup.md`).
+
## Subtleties / Invariants
- The snapshot is advisory; **re-validate every assignment at accept time** (mirrors 046).
@@ -379,9 +390,22 @@ a later step.
(per-reviewer branch: `auto` → 046 path; `confirm` reachable → proposal; `confirm`
unreachable → fallback), `analyzer.propose_reviewer_assignments` task, expiry sweep task,
settings/flags/beat. Dry-run + flags. Unit + task tests.
-5. **Notification** *(built after Chunk 6 — the DM links to the console)*: per-reviewer digest DM
- (reuse `ZulipClient` + a dedupe record or `notified_at`), de-duped vs the attention "newly
- assigned" ping.
+5. **Notification** *(NEXT — ready to build; console from Chunk 6 provides the link)*: per-reviewer
+ digest DM. **Build plan (resolved):**
+ - New task `analyzer.deliver_assignment_proposals` (or fold into the propose task — prefer a
+ separate task so delivery stays independently schedulable/gated). Gated by the already-defined
+ `ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED` (+ reuse the master `_ENABLED`/dry-run).
+ - Dedup key = **`AssignmentProposal.notified_at`** (already on the model): a reviewer's digest
+ covers their `state=proposed, notified_at IS NULL` proposals; stamp `notified_at=now` after a
+ successful send; new proposals next cycle → next digest. No separate record model needed.
+ - Reuse `zulip_bot.services.zulip_client.ZulipClient.send_direct_message`; reachability =
+ `core.User.zulip_user_id` (unreachable reviewers never get proposals — they fell back to
+ auto in Chunk 4). Link via `core.services.site_urls.build_site_url(reverse("console:home"))`.
+ - De-dupe vs the attention "newly assigned" ping: the overlap is narrow — it only arises *after*
+ an accept assigns the reviewer. Suppress the attention newly-assigned ping for a PR whose
+ assignee arrived via a just-accepted proposal (detect via a recent `AssignmentProposal`
+ `state=accepted, decided_via=console` or the `ReviewerAssignmentApplication`), so the proposal
+ DM is the single touch for that lifecycle. (Confirm this suppression rule when implementing.)
6. ✅ **(landed)** **Console** *(built before Chunk 5)*: dedicated `console/` app — GitHub-OAuth
reviewer session, list view, accept/decline handlers (accept reuses the 046 mutation path),
templates, live re-validation, tests.
diff --git a/docs/zulip_github_oauth_setup.md b/docs/zulip_github_oauth_setup.md
index 613eff31..092ad935 100644
--- a/docs/zulip_github_oauth_setup.md
+++ b/docs/zulip_github_oauth_setup.md
@@ -1,14 +1,18 @@
-# Zulip Registration GitHub OAuth Setup
+# Queueboard GitHub OAuth Setup
-This guide covers how to configure GitHub OAuth for Queueboard's Zulip registration flow.
+This guide covers how to configure GitHub OAuth for Queueboard. Two flows share one OAuth App:
+the **Zulip registration** flow and the **reviewer console** (design doc 050).
## Scope
-- Flow entrypoint: `/api/zulip/register//`
-- OAuth start endpoint: `/api/zulip/register//github/`
-- OAuth callback endpoint: `/api/zulip/register/github/callback/`
+- Registration flow:
+ - entrypoint `/api/zulip/register//`, start `/api/zulip/register//github/`,
+ callback `/api/zulip/register/github/callback/`.
+- Reviewer console:
+ - entrypoint `/console/`, start `/console/login/`, callback `/console/oauth/callback/`.
+ - The console derives its callback from `QUEUEBOARD_BASE_URL` (not `GITHUB_OAUTH_REDIRECT_URI`).
- Current behavior:
- - OAuth verifies GitHub identity and returns a callback confirmation page.
- - DB account linking/bootstrap is implemented separately.
+ - OAuth verifies GitHub identity; the console then opens a session and lists proposals.
+ - DB account linking/bootstrap (registration) is implemented separately.
## 1) Create the GitHub OAuth App
@@ -17,8 +21,7 @@ This guide covers how to configure GitHub OAuth for Queueboard's Zulip registrat
2. Fill in:
- `Application name`: `Queueboard OAuth` (or env-specific name)
- `Homepage URL`: any URL (for example `https://github.com/leanprover-community/queueboard-core`)
- - `Authorization callback URL`:
- `https:///api/zulip/register/github/callback/`
+ - `Authorization callback URL`: **the site root** `https:///`
- You can leave `Enable Device Flow` unchecked.
3. Create the app.
4. Copy:
@@ -26,7 +29,12 @@ This guide covers how to configure GitHub OAuth for Queueboard's Zulip registrat
- Generate and copy `Client Secret`
Notes:
-- GitHub OAuth Apps have a single callback URL setting. Use one app per environment if callback hosts differ (recommended).
+- GitHub OAuth Apps have a **single** callback URL, but GitHub accepts any `redirect_uri` whose path
+ is a **subdirectory** of it. Because two flows use different callback paths
+ (`/api/zulip/register/github/callback/` and `/console/oauth/callback/`), register the callback at
+ the **site root** `https:///` so both are covered. Registering the deeper registration
+ path instead will break console sign-in with `redirect_uri_mismatch`.
+- Use one app per environment if callback hosts differ (recommended).
- Keep client secret out of git and out of logs.
## 2) Configure Environment Variables
@@ -36,10 +44,16 @@ Set these in your Queueboard environment (`.env` or deployment secrets):
- Required:
- `GITHUB_OAUTH_CLIENT_ID`
- `GITHUB_OAUTH_CLIENT_SECRET`
+ - `QUEUEBOARD_BASE_URL` — canonical site base (`https://`, no trailing path). The
+ reviewer console builds its callback + the console link in reviewer DMs from this. New at design
+ doc 050; **must be set for the console/notification rollout.** (Legacy deployments that only set
+ `ZULIP_PREFS_URL_BASE` keep working via fallback, but set `QUEUEBOARD_BASE_URL` going forward.)
- Recommended:
- `GITHUB_OAUTH_REDIRECT_URI`
- Use the same URL configured in GitHub app callback settings, e.g.
+ The registration-flow callback (the console derives its own from `QUEUEBOARD_BASE_URL`), e.g.
`https://queueboard.example/api/zulip/register/github/callback/`
+- Optional:
+ - `CONSOLE_OAUTH_STATE_TTL_SECONDS` (default 600) — console OAuth state round-trip TTL.
Optional overrides (defaults shown):
- `GITHUB_OAUTH_AUTHORIZE_URL=https://github.com/login/oauth/authorize`
diff --git a/qb_site/AGENTS.md b/qb_site/AGENTS.md
index e2941a4c..00065056 100644
--- a/qb_site/AGENTS.md
+++ b/qb_site/AGENTS.md
@@ -118,6 +118,16 @@ Additional expectations:
- Running `makemigrations` on host may emit a Postgres connection warning when DB is not running; file generation still works.
- PostgreSQL is the only supported DB backend for Django runtime/testing in this repo.
+## Settings & .env Hygiene
+- **Any configurable setting must be wired through `qb_site/qb_site/settings/base.py`
+ (`FOO = os.getenv("FOO", )`) AND added to `.env.example` with a comment — in the
+ same change.** This is the single most-forgotten step; treat it as part of "done."
+- Do not read `getattr(settings, "FOO", default)` for a value that has no `os.getenv("FOO", ...)`
+ line in `base.py`: that "phantom" setting is uneditable (always the default) and invisible to
+ `.env.example`. Wire tunables through `base.py`; make true constants module-level constants.
+- If a setting needs a live-deployment change (base URL, OAuth callback, secret), also surface it
+ in the relevant `docs/` runbook. See the root `AGENTS.md` "Configuration & Environment" rule.
+
## Keeping Django Admin in Sync With Models
- Each app registers its models in `qb_site//admin.py` (currently `core`,
`syncer`, `analyzer`). When you add, rename, or remove a model field, update
diff --git a/qb_site/qb_site/settings/base.py b/qb_site/qb_site/settings/base.py
index 217d39d4..a74cad09 100644
--- a/qb_site/qb_site/settings/base.py
+++ b/qb_site/qb_site/settings/base.py
@@ -194,6 +194,9 @@ def env_optional_bounded_int(name: str, *, minimum: int, maximum: int) -> int |
GITHUB_OAUTH_TOKEN_URL = os.getenv("GITHUB_OAUTH_TOKEN_URL", "https://github.com/login/oauth/access_token")
GITHUB_API_URL = os.getenv("GITHUB_API_URL", "https://api.github.com")
GITHUB_OAUTH_SCOPE = os.getenv("GITHUB_OAUTH_SCOPE", "read:user")
+# Reviewer console (design doc 050): TTL for the signed OAuth `state` round-trip (sign-in click ->
+# GitHub -> callback). Ten minutes is plenty; the per-session CSRF nonce is the real guard.
+CONSOLE_OAUTH_STATE_TTL_SECONDS = int(os.getenv("CONSOLE_OAUTH_STATE_TTL_SECONDS", 600))
GITHUB_WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "")
GITHUB_APP_TOKEN_CONFIG: dict[str, object] = {}
_GITHUB_APP_TOKEN_CONFIG_ENV = os.getenv("GITHUB_APP_TOKEN_CONFIG", "").strip()
From 380e66ca546ffa21ebdc01d6e0de3d372fd229ed Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Thu, 9 Jul 2026 03:14:48 -0400
Subject: [PATCH 08/33] feat: add reviewer assignment proposal digest DM (Chunk
5)
Per-reviewer Zulip DM digest of pending, not-yet-notified assignment
proposals, linking to the console. Dedupe carried by
AssignmentProposal.notified_at (stamped race-safely after a successful
send); one DM per reviewer across all repos. Requires both
ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED and _DELIVERY_ENABLED to send;
_DRY_RUN previews.
Also suppress the reviewer-attention "newly assigned" ping when the
assignee arrived via a recently accepted-via-console proposal, so the
proposal DM is the single touch for that lifecycle.
- analyzer.deliver_assignment_proposals task + service + command
- ANALYZER_ASSIGNMENT_DELIVER_* schedule settings + beat + .env.example
- design doc 050 Chunk 5 marked landed; analyzer AGENTS.md task surface
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.env.example | 7 +
...050-reviewer-assignment-acceptance-gate.md | 63 +++--
qb_site/analyzer/AGENTS.md | 8 +
.../commands/deliver_assignment_proposals.py | 62 +++++
.../services/assignment_proposal_delivery.py | 262 ++++++++++++++++++
.../analyzer/services/reviewer_attention.py | 23 +-
qb_site/analyzer/tasks/__init__.py | 2 +
.../tasks/assignment_proposal_delivery.py | 115 ++++++++
.../test_assignment_proposal_delivery.py | 250 +++++++++++++++++
.../tests/services/test_reviewer_attention.py | 64 ++++-
.../test_assignment_proposal_delivery_task.py | 106 +++++++
qb_site/qb_site/settings/base.py | 27 ++
12 files changed, 969 insertions(+), 20 deletions(-)
create mode 100644 qb_site/analyzer/management/commands/deliver_assignment_proposals.py
create mode 100644 qb_site/analyzer/services/assignment_proposal_delivery.py
create mode 100644 qb_site/analyzer/tasks/assignment_proposal_delivery.py
create mode 100644 qb_site/analyzer/tests/services/test_assignment_proposal_delivery.py
create mode 100644 qb_site/analyzer/tests/tasks/test_assignment_proposal_delivery_task.py
diff --git a/.env.example b/.env.example
index f7c7fa87..ac38075d 100644
--- a/.env.example
+++ b/.env.example
@@ -314,6 +314,13 @@ ANALYZER_ASSIGNMENT_PROPOSE_PERIOD_SECONDS=86400
# Expiry/reconcile sweep period (essential maintenance; runs regardless of the master switch).
# Seconds; <=0 disables it.
ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRY_PERIOD_SECONDS=3600
+# Proposal digest DM delivery schedule (daily; default 01:00 UTC, just after propose).
+# PERIOD_SECONDS<=0 disables scheduling. Actual sending also requires
+# ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED and ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED
+# (and honors _DRY_RUN).
+ANALYZER_ASSIGNMENT_DELIVER_PERIOD_SECONDS=86400
+# ANALYZER_ASSIGNMENT_DELIVER_UTC_HOUR=1
+# ANALYZER_ASSIGNMENT_DELIVER_UTC_MINUTE=0
# Reviewer attention daily sweep controls.
# ENABLED: run the sweep task at all (computes reports/counters).
ANALYZER_REVIEWER_ATTENTION_ENABLED=0
diff --git a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
index 0f2ab32a..a792350c 100644
--- a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
+++ b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
@@ -1,6 +1,6 @@
# Reviewer Assignment Acceptance Gate (Propose → Accept → Assign)
-> Status: Living implementation plan (in progress — Chunks 1–4 and 6 landed; Chunk 5 next).
+> Status: Living implementation plan (in progress — Chunks 1–6 landed; Chunk 7 (surfacing) next).
> Captures decisions, invariants, and a chunked build plan; Progress Notes track what has shipped.
## Context
@@ -390,22 +390,26 @@ not new *flags* but new *infrastructure config*:
(per-reviewer branch: `auto` → 046 path; `confirm` reachable → proposal; `confirm`
unreachable → fallback), `analyzer.propose_reviewer_assignments` task, expiry sweep task,
settings/flags/beat. Dry-run + flags. Unit + task tests.
-5. **Notification** *(NEXT — ready to build; console from Chunk 6 provides the link)*: per-reviewer
- digest DM. **Build plan (resolved):**
- - New task `analyzer.deliver_assignment_proposals` (or fold into the propose task — prefer a
- separate task so delivery stays independently schedulable/gated). Gated by the already-defined
- `ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED` (+ reuse the master `_ENABLED`/dry-run).
- - Dedup key = **`AssignmentProposal.notified_at`** (already on the model): a reviewer's digest
- covers their `state=proposed, notified_at IS NULL` proposals; stamp `notified_at=now` after a
- successful send; new proposals next cycle → next digest. No separate record model needed.
- - Reuse `zulip_bot.services.zulip_client.ZulipClient.send_direct_message`; reachability =
- `core.User.zulip_user_id` (unreachable reviewers never get proposals — they fell back to
- auto in Chunk 4). Link via `core.services.site_urls.build_site_url(reverse("console:home"))`.
- - De-dupe vs the attention "newly assigned" ping: the overlap is narrow — it only arises *after*
- an accept assigns the reviewer. Suppress the attention newly-assigned ping for a PR whose
- assignee arrived via a just-accepted proposal (detect via a recent `AssignmentProposal`
- `state=accepted, decided_via=console` or the `ReviewerAssignmentApplication`), so the proposal
- DM is the single touch for that lifecycle. (Confirm this suppression rule when implementing.)
+5. ✅ **(landed)** **Notification** *(console from Chunk 6 provides the link)*: per-reviewer
+ digest DM. **As built:**
+ - New task `analyzer.deliver_assignment_proposals` (separate from propose so delivery stays
+ independently schedulable/gated) + service `assignment_proposal_delivery.py` + management
+ command. Requires BOTH the master `ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED` **and**
+ `ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED` to send; `_DRY_RUN` computes the would-send set.
+ - Dedup key = **`AssignmentProposal.notified_at`**: a reviewer's digest covers their
+ `state=proposed, notified_at IS NULL` proposals; stamp `notified_at=now` (conditional
+ `UPDATE ... WHERE state='proposed' AND notified_at IS NULL`, race-safe) after a successful send;
+ new proposals next cycle → next digest. No separate record model.
+ - One DM per reviewer **across all repos** (not one per repo); reuses
+ `ZulipClient.send_direct_message`; reachability = `core.User.zulip_user_id` (matched
+ case-insensitively to `reviewer_login`; unreachable reviewers are skipped defensively but never
+ get proposals — they fell back to auto in Chunk 4). Link via
+ `build_site_url(reverse("console:home"))`.
+ - De-dupe vs the attention "newly assigned" ping **(confirmed)**: suppress the attention
+ newly-assigned ping for a `(pr, reviewer)` whose assignee arrived via a recently
+ `state=accepted, decided_via=console` `AssignmentProposal` (keyed to the same ping window), so
+ the proposal DM is the single touch for that lifecycle. Data-driven/ungated (no accepted rows →
+ no effect), matching the Chunk 3 builder-integration precedent.
6. ✅ **(landed)** **Console** *(built before Chunk 5)*: dedicated `console/` app — GitHub-OAuth
reviewer session, list view, accept/decline handlers (accept reuses the 046 mutation path),
templates, live re-validation, tests.
@@ -472,6 +476,31 @@ not new *flags* but new *infrastructure config*:
## Progress Notes
+- 2026-07-09: **Chunk 5 landed (Notification).** Built the per-reviewer proposal digest DM. Pieces:
+ - **Service** `analyzer/services/assignment_proposal_delivery.py::deliver_assignment_proposals`:
+ queries `state=proposed, notified_at IS NULL` proposals across the given repos, groups by
+ reviewer login (case-insensitive), resolves `core.User` for Zulip reachability, batches PR titles,
+ renders one Markdown digest DM (console link + per-repo PR list with a Zulip-localized expiry
+ time), sends via `ZulipClient.send_direct_message`, then stamps `notified_at` with a conditional
+ `UPDATE ... WHERE state='proposed' AND notified_at IS NULL` (race-safe vs a concurrent
+ accept/expire). `dry_run` computes the would-send set with no DM and no stamp; a `None` client
+ when enabled counts `failed` rather than raising.
+ - **Task** `analyzer.deliver_assignment_proposals` (+ `deliver_assignment_proposals` management
+ command mirroring propose). Sends only when BOTH `ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED` **and**
+ `ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED` are on; `_DRY_RUN` previews. Constructs the
+ `ZulipClient` (handles init failure), wraps the service call defensively so a failure never
+ crashes beat. Daily beat entry at a fixed UTC clock (default 01:00, just after the 00:45 propose
+ run) via new `ANALYZER_ASSIGNMENT_DELIVER_PERIOD_SECONDS`/`_UTC_HOUR`/`_UTC_MINUTE` settings
+ (`base.py` + `.env.example`).
+ - **De-dupe vs the attention "newly assigned" ping:** `build_reviewer_attention_reports` now
+ suppresses `needs_new_assignment_ping` for a `(pr, reviewer)` whose assignee arrived via a recent
+ `state=accepted, decided_via=console` proposal (same ping window), so the proposal DM is the
+ single touch for that lifecycle. Ungated/data-driven.
+ - Updated `analyzer/AGENTS.md` task surface.
+ Validation: 33 focused tests (14 delivery service, 7 delivery task/command, +3 attention
+ suppression) + full `analyzer` suite (477) green on dockerized Postgres; `makemigrations --check`
+ clean (no model changes — `notified_at` already existed from Chunk 2); beat entry verified; ruff
+ clean. No real Zulip sends exercised (client mocked) — a staging end-to-end DM remains manual.
- 2026-07-09: **Chunk 6 landed (built before Chunk 5).** New `console/` Django app at `/console/`
— a GitHub-OAuth, session-backed reviewer console. Decisions (via checkpoint): dedicated app
(not folded into `zulip_bot`); OAuth consolidated into `core`; console before notification so
diff --git a/qb_site/analyzer/AGENTS.md b/qb_site/analyzer/AGENTS.md
index 868b89d1..4d577745 100644
--- a/qb_site/analyzer/AGENTS.md
+++ b/qb_site/analyzer/AGENTS.md
@@ -72,6 +72,14 @@ Celery task names (as registered via `@shared_task(name=…)`):
human assignee, or left the review queue (per the `proposal_validity` predicate and
`ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT`). Performs no GitHub writes and is
intentionally **not** gated by the master switch, so existing proposals keep draining.
+- `analyzer.deliver_assignment_proposals` — sends one per-reviewer Zulip DM digest
+ (design doc 050) of their pending, not-yet-notified proposals across all repos, linking
+ to the console. Dedupe is carried by `AssignmentProposal.notified_at` (stamped after a
+ successful send; no separate record model). Requires BOTH
+ `ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED` and `ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED`
+ to actually send (+ `_DRY_RUN` computes the would-send set). Reachability is
+ `core.User.zulip_user_id`. Command:
+ `manage.py deliver_assignment_proposals [--repo o/n] [--dry-run] [--enable]`.
- `analyzer.build_area_stats` / `analyzer.refresh_area_stats`
**Reviewer attention tasks**
diff --git a/qb_site/analyzer/management/commands/deliver_assignment_proposals.py b/qb_site/analyzer/management/commands/deliver_assignment_proposals.py
new file mode 100644
index 00000000..e2a5fd65
--- /dev/null
+++ b/qb_site/analyzer/management/commands/deliver_assignment_proposals.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+import json
+
+from django.core.management.base import BaseCommand, CommandError
+
+from analyzer.tasks.assignment_proposal_delivery import deliver_assignment_proposals_task
+from core.models import Repository
+
+
+class Command(BaseCommand):
+ help = (
+ "Send the per-reviewer assignment-proposal digest DM (design doc 050): one Zulip DM per "
+ "confirm-mode reviewer listing their pending, not-yet-notified proposals with a console link. "
+ "Dedupe is carried by AssignmentProposal.notified_at. Use --dry-run to inspect the would-send "
+ "set without sending or stamping notified_at."
+ )
+
+ def add_arguments(self, parser):
+ parser.add_argument("--repo", help="Restrict to a single repository in owner/name form", default=None)
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Compute the would-send set without sending DMs or stamping notified_at.",
+ )
+ parser.add_argument(
+ "--enable",
+ action="store_true",
+ help="Force a real send regardless of the ANALYZER_ASSIGNMENT_PROPOSALS_* flags.",
+ )
+ parser.add_argument(
+ "--include-inactive",
+ action="store_true",
+ help="Include repositories with is_active=False.",
+ )
+
+ def handle(self, *args, **options):
+ repository_id = None
+ repo_arg = options.get("repo")
+ if repo_arg:
+ if "/" not in repo_arg:
+ raise CommandError("Expected --repo in owner/name form")
+ owner, name = repo_arg.split("/", 1)
+ repo = Repository.objects.filter(owner=owner, name=name).only("id").first()
+ if repo is None:
+ raise CommandError(f"Repository not found: {repo_arg}")
+ repository_id = int(repo.id)
+
+ dry_run = bool(options.get("dry_run"))
+ enable = bool(options.get("enable"))
+ # Explicit flags override; absent them, fall back to settings. --enable forces a real send
+ # (dry-run off); --dry-run forces preview.
+ enabled_override = True if enable else (False if dry_run else None)
+ dry_run_override = True if dry_run else (False if enable else None)
+
+ result = deliver_assignment_proposals_task.run(
+ repository_id=repository_id,
+ include_inactive_repositories=bool(options.get("include_inactive")),
+ enabled_override=enabled_override,
+ dry_run_override=dry_run_override,
+ )
+ self.stdout.write(json.dumps(result, indent=2, default=str))
diff --git a/qb_site/analyzer/services/assignment_proposal_delivery.py b/qb_site/analyzer/services/assignment_proposal_delivery.py
new file mode 100644
index 00000000..80bafc60
--- /dev/null
+++ b/qb_site/analyzer/services/assignment_proposal_delivery.py
@@ -0,0 +1,262 @@
+"""Deliver the per-reviewer assignment-proposal digest DM (design doc 050, Chunk 5).
+
+For each ``confirm``-mode reviewer with pending proposals awaiting their decision, send **one**
+Zulip DM listing every such PR with a link to the console (where they accept/decline). The digest
+spans repositories so a reviewer never gets one ping per repo.
+
+Dedupe is carried by ``AssignmentProposal.notified_at`` (no separate record model): a reviewer's
+digest covers only their ``state=proposed, notified_at IS NULL`` proposals, and ``notified_at`` is
+stamped after a successful send. New proposals created next cycle are un-notified again and roll
+into the next digest. The stamp is a conditional ``UPDATE ... WHERE state='proposed' AND
+notified_at IS NULL`` so a concurrent sweep/accept that already retired or notified a row is never
+clobbered.
+
+Reachability is ``core.User.zulip_user_id``; unreachable reviewers never receive proposals in the
+first place (Chunk 4 falls them back to auto), but we still skip defensively. ``dry_run`` computes
+the would-send set with no DMs and no ``notified_at`` writes.
+"""
+
+from __future__ import annotations
+
+import functools
+import logging
+import operator
+from datetime import datetime
+from typing import Any, Iterable
+
+from django.db.models import Q
+from django.urls import reverse
+
+from analyzer.models import AssignmentProposal
+from analyzer.services.reviewer_assignment_engine import _normalize_login
+from core.models import Repository, User
+from core.services.site_urls import build_site_url
+from core.utils.zulip_time import format_global_time
+from syncer.models import PullRequest
+from zulip_bot.services.zulip_client import ZulipApiError, ZulipClient
+
+log = logging.getLogger(__name__)
+
+MAX_MESSAGE_CHARS = 9000
+
+
+def _empty_stats() -> dict[str, int]:
+ return {
+ "pending_proposals": 0,
+ "reviewers": 0,
+ "attempted": 0,
+ "sent": 0,
+ "would_send": 0,
+ "failed": 0,
+ "skipped_no_user": 0,
+ "skipped_no_zulip_user_id": 0,
+ "skipped_disabled": 0,
+ "proposals_notified": 0,
+ }
+
+
+def _split_message_chunks(*, content: str, max_chars: int) -> list[str]:
+ """Split a long message on line boundaries so each chunk fits Zulip's size ceiling."""
+ if len(content) <= max_chars:
+ return [content]
+ chunks: list[str] = []
+ current: list[str] = []
+ current_len = 0
+ for line in content.splitlines():
+ line_len = len(line) + 1
+ if current and current_len + line_len > max_chars:
+ chunks.append("\n".join(current))
+ current = [line]
+ current_len = line_len
+ continue
+ current.append(line)
+ current_len += line_len
+ if current:
+ chunks.append("\n".join(current))
+ return chunks
+
+
+def _resolve_users_by_login(login_norms: Iterable[str]) -> dict[str, User]:
+ """Map normalized github_login -> User for reachability, matched case-insensitively.
+
+ Proposals key on ``reviewer_login`` exactly as the console does (``__iexact``), so we resolve
+ the same way rather than assuming the stored casing matches ``User.github_login``.
+ """
+ logins = [login for login in login_norms if login]
+ if not logins:
+ return {}
+ predicate = functools.reduce(operator.or_, (Q(github_login__iexact=login) for login in logins))
+ users = User.objects.filter(predicate).only("id", "github_login", "zulip_user_id")
+ return {_normalize_login(user.github_login): user for user in users if user.github_login}
+
+
+def _pr_titles(proposals: list[AssignmentProposal]) -> dict[tuple[int, int], str]:
+ """Batch PR titles keyed by ``(repository_id, pr_number)`` for nicer digest lines."""
+ numbers_by_repo: dict[int, set[int]] = {}
+ for proposal in proposals:
+ numbers_by_repo.setdefault(int(proposal.repository_id), set()).add(int(proposal.pr_number))
+ titles: dict[tuple[int, int], str] = {}
+ for repo_id, numbers in numbers_by_repo.items():
+ for number, title in PullRequest.objects.filter(repository_id=repo_id, number__in=numbers).values_list("number", "title"):
+ titles[(repo_id, int(number))] = title
+ return titles
+
+
+def _format_expiry(expires_at: datetime, now: datetime) -> str:
+ """Absolute (Zulip-localized) deadline plus a coarse relative hint."""
+ tag = format_global_time(expires_at)
+ remaining = (expires_at - now).total_seconds()
+ if remaining <= 0:
+ return f"expires {tag} (expiring now)"
+ days = int(remaining // 86400)
+ if days >= 1:
+ return f"expires {tag} (in {days}d)"
+ hours = max(1, int(remaining // 3600))
+ return f"expires {tag} (in {hours}h)"
+
+
+def _render_digest(
+ *,
+ reviewer_login: str,
+ proposals: list[AssignmentProposal],
+ titles: dict[tuple[int, int], str],
+ console_url: str,
+ now: datetime,
+) -> str:
+ count = len(proposals)
+ lines: list[str] = [
+ "### Review assignment proposals awaiting your response",
+ "",
+ f"You have {count} proposed review assignment{'' if count == 1 else 's'} waiting for your decision.",
+ f"Accept or decline them here: {console_url}",
+ "",
+ ]
+ by_repo: dict[str, list[AssignmentProposal]] = {}
+ for proposal in proposals:
+ label = f"{proposal.repository.owner}/{proposal.repository.name}"
+ by_repo.setdefault(label, []).append(proposal)
+ for label in sorted(by_repo):
+ lines.append(f"#### {label}")
+ for proposal in by_repo[label]:
+ title = titles.get((int(proposal.repository_id), int(proposal.pr_number))) or f"PR #{proposal.pr_number}"
+ url = f"https://github.com/{label}/pull/{proposal.pr_number}"
+ lines.append(f"- [#{proposal.pr_number}]({url}) {title}")
+ lines.append(f" - {_format_expiry(proposal.expires_at, now)}")
+ lines.append("")
+ lines.append(
+ "Declining opts you out of that PR. Letting a proposal expire simply passes it to another "
+ "reviewer, and you may be proposed again later."
+ )
+ return "\n".join(lines).strip()
+
+
+def deliver_assignment_proposals(
+ repositories: Iterable[Repository],
+ *,
+ now: datetime,
+ enabled: bool,
+ dry_run: bool,
+ client: ZulipClient | None = None,
+) -> dict[str, Any]:
+ """Send one digest DM per reviewer for their un-notified pending proposals across ``repositories``.
+
+ ``dry_run`` computes the would-send set without sending or stamping. When ``enabled`` but not
+ ``dry_run``, ``client`` must be provided (the task constructs it); a ``None`` client counts every
+ reviewer as ``failed`` rather than raising.
+ """
+ repos = list(repositories)
+ result: dict[str, Any] = {"stats": _empty_stats(), "per_reviewer": []}
+ stats = result["stats"]
+ if not repos:
+ result["status"] = "ok"
+ return result
+
+ proposals = list(
+ AssignmentProposal.objects.filter(
+ repository__in=repos,
+ state=AssignmentProposal.STATE_PROPOSED,
+ notified_at__isnull=True,
+ )
+ .select_related("repository")
+ .order_by("reviewer_login", "repository__owner", "repository__name", "expires_at", "pr_number")
+ )
+ stats["pending_proposals"] = len(proposals)
+ if not proposals:
+ result["status"] = "ok"
+ return result
+
+ by_login: dict[str, list[AssignmentProposal]] = {}
+ for proposal in proposals:
+ by_login.setdefault(_normalize_login(proposal.reviewer_login), []).append(proposal)
+ stats["reviewers"] = len(by_login)
+
+ users_by_login = _resolve_users_by_login(by_login.keys())
+ titles = _pr_titles(proposals)
+ console_url = build_site_url(reverse("console:home"))
+
+ for login_norm, reviewer_proposals in sorted(by_login.items()):
+ original_login = reviewer_proposals[0].reviewer_login
+ entry: dict[str, Any] = {"reviewer_login": original_login, "proposals": len(reviewer_proposals)}
+ user = users_by_login.get(login_norm)
+ if user is None:
+ stats["skipped_no_user"] += 1
+ entry["status"] = "skipped_no_user"
+ result["per_reviewer"].append(entry)
+ continue
+ if user.zulip_user_id is None:
+ stats["skipped_no_zulip_user_id"] += 1
+ entry["status"] = "skipped_no_zulip_user_id"
+ result["per_reviewer"].append(entry)
+ continue
+
+ if dry_run:
+ stats["would_send"] += 1
+ entry["status"] = "would_send"
+ result["per_reviewer"].append(entry)
+ continue
+ if not enabled:
+ stats["skipped_disabled"] += 1
+ entry["status"] = "skipped_disabled"
+ result["per_reviewer"].append(entry)
+ continue
+ if client is None:
+ stats["failed"] += 1
+ entry["status"] = "failed_client_init"
+ result["per_reviewer"].append(entry)
+ continue
+
+ message = _render_digest(
+ reviewer_login=original_login,
+ proposals=reviewer_proposals,
+ titles=titles,
+ console_url=console_url,
+ now=now,
+ )
+ stats["attempted"] += 1
+ try:
+ for chunk in _split_message_chunks(content=message, max_chars=MAX_MESSAGE_CHARS):
+ client.send_direct_message(to=[int(user.zulip_user_id)], content=chunk)
+ except ZulipApiError as exc:
+ stats["failed"] += 1
+ entry["status"] = "failed_send"
+ entry["error"] = str(exc)[:500]
+ result["per_reviewer"].append(entry)
+ continue
+
+ # Stamp only rows still proposed+un-notified so a concurrent accept/expire is not clobbered.
+ stamped = AssignmentProposal.objects.filter(
+ id__in=[int(proposal.id) for proposal in reviewer_proposals],
+ state=AssignmentProposal.STATE_PROPOSED,
+ notified_at__isnull=True,
+ ).update(notified_at=now, updated_at=now)
+ stats["sent"] += 1
+ stats["proposals_notified"] += int(stamped)
+ entry["status"] = "sent"
+ entry["notified"] = int(stamped)
+ result["per_reviewer"].append(entry)
+
+ result["status"] = "ok"
+ return result
+
+
+__all__ = ["deliver_assignment_proposals", "MAX_MESSAGE_CHARS"]
diff --git a/qb_site/analyzer/services/reviewer_attention.py b/qb_site/analyzer/services/reviewer_attention.py
index 45309e19..1be3afa3 100644
--- a/qb_site/analyzer/services/reviewer_attention.py
+++ b/qb_site/analyzer/services/reviewer_attention.py
@@ -5,7 +5,7 @@
from django.db.models import Q
-from analyzer.models import PRQueueWindow, QueueRuleSet
+from analyzer.models import AssignmentProposal, PRQueueWindow, QueueRuleSet
from core.models import Repository, ReviewerPreference
from core.services.reviewer_notification_settings import parse_notification_policy
from syncer.models import PRTimelineEvent, PRTimelineEventType, PullRequest
@@ -150,6 +150,21 @@ def build_reviewer_attention_reports(
if prev is None or occurred_at > prev:
last_assignment_by_pr_and_reviewer[key] = occurred_at
+ # De-dupe against the acceptance-gate proposal DM (design doc 050): when an assignee arrived
+ # because the reviewer just accepted a proposal on the console, they already got the proposal DM,
+ # so suppress the redundant "newly assigned" ping for that (pr, reviewer). Keyed to the same ping
+ # window as the ping itself. Data-driven: no accepted-via-console rows -> no effect.
+ recently_accepted_via_console: set[tuple[int, str]] = set()
+ if pr_by_number:
+ accepted_rows = AssignmentProposal.objects.filter(
+ repository=repository,
+ pr_number__in=list(pr_by_number.keys()),
+ state=AssignmentProposal.STATE_ACCEPTED,
+ decided_via=AssignmentProposal.DECIDED_VIA_CONSOLE,
+ decided_at__gte=new_assignment_ping_cutoff,
+ ).values_list("pr_number", "reviewer_login")
+ recently_accepted_via_console = {(int(n), _normalize_login(str(login))) for n, login in accepted_rows}
+
reports: list[ReviewerAttentionReport] = []
for pref in prefs:
reviewer_login = _normalize_login(getattr(pref.user, "github_login", None))
@@ -198,7 +213,11 @@ def build_reviewer_attention_reports(
days_since_anchor = int(total_seconds // 86400)
needs_auto_unassign = days_since_anchor >= policy.auto_unassign_days
needs_nudge = (days_since_anchor >= policy.stale_nudge_days) and not needs_auto_unassign
- if last_assigned_at is not None and last_assigned_at >= new_assignment_ping_cutoff:
+ if (
+ last_assigned_at is not None
+ and last_assigned_at >= new_assignment_ping_cutoff
+ and (pr_number, reviewer_login) not in recently_accepted_via_console
+ ):
needs_new_assignment_ping = True
items.append(
diff --git a/qb_site/analyzer/tasks/__init__.py b/qb_site/analyzer/tasks/__init__.py
index 66442f83..5b0e8cd4 100644
--- a/qb_site/analyzer/tasks/__init__.py
+++ b/qb_site/analyzer/tasks/__init__.py
@@ -31,6 +31,7 @@
from analyzer.tasks.reviewer_assignment_apply import apply_reviewer_assignments_task
from analyzer.tasks.reviewer_assignment_propose import propose_reviewer_assignments_task
from analyzer.tasks.assignment_proposal_expiry import expire_assignment_proposals_task
+from analyzer.tasks.assignment_proposal_delivery import deliver_assignment_proposals_task
log = logging.getLogger(__name__)
@@ -164,4 +165,5 @@ def process_pr_task(pr_id: int) -> Dict[str, Any]:
"apply_reviewer_assignments_task",
"propose_reviewer_assignments_task",
"expire_assignment_proposals_task",
+ "deliver_assignment_proposals_task",
]
diff --git a/qb_site/analyzer/tasks/assignment_proposal_delivery.py b/qb_site/analyzer/tasks/assignment_proposal_delivery.py
new file mode 100644
index 00000000..3c512d9e
--- /dev/null
+++ b/qb_site/analyzer/tasks/assignment_proposal_delivery.py
@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from celery import shared_task
+from django.conf import settings
+from django.utils import timezone
+
+from analyzer.services.assignment_proposal_delivery import deliver_assignment_proposals
+from core.models import Repository
+from zulip_bot.services.zulip_client import ZulipApiError, ZulipClient
+
+log = logging.getLogger(__name__)
+
+
+@shared_task(name="analyzer.deliver_assignment_proposals")
+def deliver_assignment_proposals_task(
+ *,
+ repository_id: int | None = None,
+ include_inactive_repositories: bool = False,
+ enabled_override: bool | None = None,
+ dry_run_override: bool | None = None,
+) -> dict[str, Any]:
+ """Send the per-reviewer assignment-proposal digest DM (design doc 050, Chunk 5).
+
+ One DM per ``confirm``-mode reviewer covering their pending, not-yet-notified proposals across
+ all repositories, linking to the console. Dedupe is carried by ``AssignmentProposal.notified_at``
+ (stamped after a successful send). Actual delivery requires BOTH the master
+ ``ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED`` and ``ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED``;
+ ``ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN`` computes the would-send set without any DM or stamp.
+ """
+ master_enabled = bool(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED", False))
+ delivery_enabled = bool(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED", False))
+ enabled = bool(enabled_override) if enabled_override is not None else (master_enabled and delivery_enabled)
+ dry_run = (
+ bool(dry_run_override)
+ if dry_run_override is not None
+ else bool(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN", False))
+ )
+
+ if not enabled and not dry_run:
+ return {
+ "skipped": True,
+ "reason": "feature_disabled",
+ "enabled": enabled,
+ "dry_run": dry_run,
+ "master_enabled": master_enabled,
+ "delivery_enabled": delivery_enabled,
+ }
+
+ repos_qs = Repository.objects.only("id", "owner", "name")
+ if not include_inactive_repositories:
+ repos_qs = repos_qs.filter(is_active=True)
+ if repository_id is not None:
+ repos_qs = repos_qs.filter(id=int(repository_id))
+ repos = list(repos_qs.order_by("owner", "name", "id"))
+
+ if repository_id is not None and not repos:
+ return {"skipped": True, "reason": "repo_not_found_or_inactive", "repository_id": int(repository_id)}
+
+ now = timezone.now()
+ client: ZulipClient | None = None
+ client_init_error: str | None = None
+ if enabled and not dry_run:
+ try:
+ client = ZulipClient()
+ except ZulipApiError as exc:
+ client_init_error = str(exc)
+ log.warning("analyzer.deliver_assignment_proposals: unable to initialize Zulip client: %s", client_init_error)
+
+ try:
+ delivery = deliver_assignment_proposals(repos, now=now, enabled=enabled, dry_run=dry_run, client=client)
+ except Exception as exc: # defensive: delivery failure must not crash the beat loop
+ log.exception("analyzer.deliver_assignment_proposals: delivery failed")
+ return {
+ "skipped": False,
+ "enabled": enabled,
+ "dry_run": dry_run,
+ "status": "error",
+ "error": str(exc)[:2000],
+ "repos": len(repos),
+ }
+
+ result = {
+ "skipped": False,
+ "enabled": enabled,
+ "dry_run": dry_run,
+ "master_enabled": master_enabled,
+ "delivery_enabled": delivery_enabled,
+ "include_inactive_repositories": bool(include_inactive_repositories),
+ "repository_id": int(repository_id) if repository_id is not None else None,
+ "repos": len(repos),
+ "run_at": now.isoformat(),
+ "client_init_error": client_init_error,
+ "totals": delivery.get("stats", {}),
+ "per_reviewer": delivery.get("per_reviewer", []),
+ }
+ stats = delivery.get("stats", {})
+ log.info(
+ "analyzer.deliver_assignment_proposals: repos=%s reviewers=%s sent=%s would_send=%s failed=%s "
+ "notified=%s dry_run=%s enabled=%s",
+ len(repos),
+ stats.get("reviewers", 0),
+ stats.get("sent", 0),
+ stats.get("would_send", 0),
+ stats.get("failed", 0),
+ stats.get("proposals_notified", 0),
+ dry_run,
+ enabled,
+ )
+ return result
+
+
+__all__ = ["deliver_assignment_proposals_task"]
diff --git a/qb_site/analyzer/tests/services/test_assignment_proposal_delivery.py b/qb_site/analyzer/tests/services/test_assignment_proposal_delivery.py
new file mode 100644
index 00000000..9cdb1eb4
--- /dev/null
+++ b/qb_site/analyzer/tests/services/test_assignment_proposal_delivery.py
@@ -0,0 +1,250 @@
+from __future__ import annotations
+
+from datetime import timedelta
+from unittest.mock import MagicMock
+
+from django.test import TestCase, override_settings
+from django.utils import timezone
+
+from analyzer.models import AssignmentProposal
+from analyzer.services.assignment_proposal_delivery import deliver_assignment_proposals
+from core.models import Repository, User
+from syncer.models import PullRequest
+from syncer.models.pull_request import PullRequestState
+from zulip_bot.services.zulip_client import ZulipApiError
+
+
+@override_settings(QUEUEBOARD_BASE_URL="https://queue.example.org")
+class DeliverAssignmentProposalsTests(TestCase):
+ def setUp(self) -> None:
+ self.repo = Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master")
+ self.other_repo = Repository.objects.create(owner="leanprover-community", name="batteries", default_branch="main")
+ self.now = timezone.now()
+ self._zulip_seq = 5000
+
+ # ---- helpers -------------------------------------------------------
+
+ def _make_user(self, login: str, *, reachable: bool = True) -> User:
+ zulip_user_id = None
+ if reachable:
+ self._zulip_seq += 1
+ zulip_user_id = self._zulip_seq
+ return User.objects.create(github_login=login, zulip_user_id=zulip_user_id)
+
+ def _make_pr(self, repo: Repository, number: int, *, title: str = "") -> PullRequest:
+ return PullRequest.objects.create(
+ repository=repo,
+ number=number,
+ state=PullRequestState.OPEN,
+ is_draft=False,
+ gh_created_at=self.now,
+ gh_updated_at=self.now,
+ base_ref_name="master",
+ head_ref_name=f"branch-{number}",
+ head_repo_owner_login=repo.owner,
+ head_repo_name=repo.name,
+ title=title or f"PR {number}",
+ body="b",
+ additions=1,
+ deletions=0,
+ changed_files_count=1,
+ assignees=[],
+ )
+
+ def _make_proposal(self, repo: Repository, number: int, login: str, *, notified: bool = False, days: int = 7):
+ return AssignmentProposal.objects.create(
+ repository=repo,
+ pr_number=number,
+ reviewer_login=login,
+ state=AssignmentProposal.STATE_PROPOSED,
+ expires_at=self.now + timedelta(days=days),
+ notified_at=self.now if notified else None,
+ )
+
+ def _deliver(self, *, enabled=True, dry_run=False, client=None):
+ if client is None:
+ client = MagicMock()
+ result = deliver_assignment_proposals(
+ [self.repo, self.other_repo],
+ now=self.now,
+ enabled=enabled,
+ dry_run=dry_run,
+ client=client,
+ )
+ return result, client
+
+ # ---- happy path ----------------------------------------------------
+
+ def test_sends_one_digest_per_reviewer_across_repos_and_stamps_notified_at(self) -> None:
+ bob = self._make_user("bob")
+ self._make_pr(self.repo, 101, title="Fix ring lemma")
+ self._make_pr(self.other_repo, 202, title="Batteries tweak")
+ p1 = self._make_proposal(self.repo, 101, "bob")
+ p2 = self._make_proposal(self.other_repo, 202, "bob")
+
+ result, client = self._deliver()
+
+ # One DM to the reviewer, covering both repos.
+ client.send_direct_message.assert_called_once()
+ _, kwargs = client.send_direct_message.call_args
+ self.assertEqual(kwargs["to"], [int(bob.zulip_user_id)])
+ content = kwargs["content"]
+ self.assertIn("https://queue.example.org/console/", content)
+ self.assertIn("#101", content)
+ self.assertIn("Fix ring lemma", content)
+ self.assertIn("#202", content)
+
+ self.assertEqual(result["stats"]["sent"], 1)
+ self.assertEqual(result["stats"]["reviewers"], 1)
+ self.assertEqual(result["stats"]["proposals_notified"], 2)
+ p1.refresh_from_db()
+ p2.refresh_from_db()
+ self.assertEqual(p1.notified_at, self.now)
+ self.assertEqual(p2.notified_at, self.now)
+
+ def test_distinct_reviewers_get_separate_dms(self) -> None:
+ self._make_user("bob")
+ self._make_user("carol")
+ self._make_proposal(self.repo, 101, "bob")
+ self._make_proposal(self.repo, 102, "carol")
+
+ result, client = self._deliver()
+
+ self.assertEqual(client.send_direct_message.call_count, 2)
+ self.assertEqual(result["stats"]["sent"], 2)
+ self.assertEqual(result["stats"]["reviewers"], 2)
+
+ # ---- dedupe via notified_at ---------------------------------------
+
+ def test_already_notified_proposals_are_skipped(self) -> None:
+ self._make_user("bob")
+ self._make_proposal(self.repo, 101, "bob", notified=True)
+
+ result, client = self._deliver()
+
+ client.send_direct_message.assert_not_called()
+ self.assertEqual(result["stats"]["pending_proposals"], 0)
+ self.assertEqual(result["stats"]["sent"], 0)
+
+ def test_rerun_is_idempotent(self) -> None:
+ self._make_user("bob")
+ self._make_proposal(self.repo, 101, "bob")
+
+ first, client1 = self._deliver()
+ self.assertEqual(first["stats"]["sent"], 1)
+
+ second, client2 = self._deliver()
+ client2.send_direct_message.assert_not_called()
+ self.assertEqual(second["stats"]["sent"], 0)
+ self.assertEqual(second["stats"]["pending_proposals"], 0)
+
+ def test_new_proposal_next_cycle_triggers_a_fresh_digest(self) -> None:
+ self._make_user("bob")
+ self._make_proposal(self.repo, 101, "bob")
+ first, _ = self._deliver()
+ self.assertEqual(first["stats"]["sent"], 1)
+
+ # A new proposal appears (notified_at NULL); next run sends again.
+ self._make_proposal(self.repo, 102, "bob")
+ second, client2 = self._deliver()
+ client2.send_direct_message.assert_called_once()
+ self.assertEqual(second["stats"]["sent"], 1)
+ self.assertEqual(second["stats"]["proposals_notified"], 1)
+
+ # ---- reachability / gating ----------------------------------------
+
+ def test_unreachable_reviewer_is_skipped_without_stamping(self) -> None:
+ self._make_user("bob", reachable=False)
+ p1 = self._make_proposal(self.repo, 101, "bob")
+
+ result, client = self._deliver()
+
+ client.send_direct_message.assert_not_called()
+ self.assertEqual(result["stats"]["skipped_no_zulip_user_id"], 1)
+ p1.refresh_from_db()
+ self.assertIsNone(p1.notified_at)
+
+ def test_unknown_login_is_skipped(self) -> None:
+ # A proposal whose login has no core.User row at all.
+ self._make_proposal(self.repo, 101, "ghost")
+
+ result, client = self._deliver()
+
+ client.send_direct_message.assert_not_called()
+ self.assertEqual(result["stats"]["skipped_no_user"], 1)
+
+ def test_login_matched_case_insensitively(self) -> None:
+ bob = self._make_user("Bob")
+ self._make_proposal(self.repo, 101, "bob")
+
+ result, client = self._deliver()
+
+ client.send_direct_message.assert_called_once()
+ _, kwargs = client.send_direct_message.call_args
+ self.assertEqual(kwargs["to"], [int(bob.zulip_user_id)])
+ self.assertEqual(result["stats"]["sent"], 1)
+
+ def test_dry_run_sends_nothing_and_does_not_stamp(self) -> None:
+ self._make_user("bob")
+ p1 = self._make_proposal(self.repo, 101, "bob")
+
+ result, client = self._deliver(dry_run=True)
+
+ client.send_direct_message.assert_not_called()
+ self.assertEqual(result["stats"]["would_send"], 1)
+ self.assertEqual(result["stats"]["sent"], 0)
+ p1.refresh_from_db()
+ self.assertIsNone(p1.notified_at)
+
+ def test_disabled_sends_nothing(self) -> None:
+ self._make_user("bob")
+ self._make_proposal(self.repo, 101, "bob")
+
+ result, client = self._deliver(enabled=False, dry_run=False)
+
+ client.send_direct_message.assert_not_called()
+ self.assertEqual(result["stats"]["skipped_disabled"], 1)
+
+ # ---- failures / robustness ----------------------------------------
+
+ def test_send_failure_leaves_notified_at_null(self) -> None:
+ self._make_user("bob")
+ p1 = self._make_proposal(self.repo, 101, "bob")
+ client = MagicMock()
+ client.send_direct_message.side_effect = ZulipApiError("boom")
+
+ result, _ = self._deliver(client=client)
+
+ self.assertEqual(result["stats"]["failed"], 1)
+ self.assertEqual(result["stats"]["sent"], 0)
+ p1.refresh_from_db()
+ self.assertIsNone(p1.notified_at)
+
+ def test_none_client_when_enabled_counts_failed(self) -> None:
+ self._make_user("bob")
+ self._make_proposal(self.repo, 101, "bob")
+
+ result = deliver_assignment_proposals([self.repo], now=self.now, enabled=True, dry_run=False, client=None)
+
+ self.assertEqual(result["stats"]["failed"], 1)
+ self.assertEqual(result["stats"]["sent"], 0)
+
+ def test_only_proposed_state_is_delivered(self) -> None:
+ self._make_user("bob")
+ AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=101,
+ reviewer_login="bob",
+ state=AssignmentProposal.STATE_ACCEPTED,
+ expires_at=self.now + timedelta(days=7),
+ )
+
+ result, client = self._deliver()
+
+ client.send_direct_message.assert_not_called()
+ self.assertEqual(result["stats"]["pending_proposals"], 0)
+
+ def test_no_repos_is_noop(self) -> None:
+ result = deliver_assignment_proposals([], now=self.now, enabled=True, dry_run=False, client=MagicMock())
+ self.assertEqual(result["status"], "ok")
+ self.assertEqual(result["stats"]["pending_proposals"], 0)
diff --git a/qb_site/analyzer/tests/services/test_reviewer_attention.py b/qb_site/analyzer/tests/services/test_reviewer_attention.py
index 7b3c064b..9ab2548b 100644
--- a/qb_site/analyzer/tests/services/test_reviewer_attention.py
+++ b/qb_site/analyzer/tests/services/test_reviewer_attention.py
@@ -4,7 +4,7 @@
from django.test import TestCase
-from analyzer.models import PRQueueWindow, QueueRuleSet
+from analyzer.models import AssignmentProposal, PRQueueWindow, QueueRuleSet
from analyzer.services.reviewer_attention import build_reviewer_attention_reports
from core.models import Repository, ReviewerPreference, User
from syncer.models import PullRequest, PRTimelineEvent, PRTimelineEventType
@@ -243,3 +243,65 @@ def test_policy_start_at_suppresses_new_assignment_ping_before_floor(self) -> No
item = reports[0].items[0]
self.assertFalse(item.needs_new_assignment_ping)
+
+ def _make_accepted_proposal(self, *, pr_number: int, reviewer_login: str, decided_at: datetime, decided_via: str) -> None:
+ AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=pr_number,
+ reviewer_login=reviewer_login,
+ state=AssignmentProposal.STATE_ACCEPTED,
+ expires_at=self.now - timedelta(days=1),
+ decided_at=decided_at,
+ decided_via=decided_via,
+ )
+
+ def test_new_assignment_ping_suppressed_after_console_accept(self) -> None:
+ # design doc 050 Chunk 5: the proposal DM already covered this assignment, so the attention
+ # sweep must not also ping "newly assigned" for it.
+ pr = self._mk_pr(111, assignees=["alice"])
+ self._add_assignment_event(pr=pr, assignee_login="alice", occurred_at=self.now - timedelta(hours=2))
+ self._make_accepted_proposal(
+ pr_number=111,
+ reviewer_login="Alice", # case-insensitive match against the login
+ decided_at=self.now - timedelta(hours=2),
+ decided_via=AssignmentProposal.DECIDED_VIA_CONSOLE,
+ )
+
+ reports = build_reviewer_attention_reports(
+ repository=self.repo,
+ as_of=self.now,
+ new_assignment_ping_window_seconds=24 * 60 * 60,
+ )
+ item = reports[0].items[0]
+
+ self.assertFalse(item.needs_new_assignment_ping)
+
+ def test_new_assignment_ping_not_suppressed_by_old_or_non_console_accept(self) -> None:
+ # An accept outside the ping window, or one not made via the console (e.g. a direct assign),
+ # does not suppress the ping.
+ pr_old = self._mk_pr(112, assignees=["alice"])
+ self._add_assignment_event(pr=pr_old, assignee_login="alice", occurred_at=self.now - timedelta(hours=2))
+ self._make_accepted_proposal(
+ pr_number=112,
+ reviewer_login="alice",
+ decided_at=self.now - timedelta(days=3), # before the 24h ping window
+ decided_via=AssignmentProposal.DECIDED_VIA_CONSOLE,
+ )
+ pr_noncon = self._mk_pr(113, assignees=["alice"])
+ self._add_assignment_event(pr=pr_noncon, assignee_login="alice", occurred_at=self.now - timedelta(hours=2))
+ self._make_accepted_proposal(
+ pr_number=113,
+ reviewer_login="alice",
+ decided_at=self.now - timedelta(hours=2),
+ decided_via=AssignmentProposal.DECIDED_VIA_SYNC_SUPERSEDED,
+ )
+
+ reports = build_reviewer_attention_reports(
+ repository=self.repo,
+ as_of=self.now,
+ new_assignment_ping_window_seconds=24 * 60 * 60,
+ )
+ items = {item.pr_number: item for item in reports[0].items}
+
+ self.assertTrue(items[112].needs_new_assignment_ping)
+ self.assertTrue(items[113].needs_new_assignment_ping)
diff --git a/qb_site/analyzer/tests/tasks/test_assignment_proposal_delivery_task.py b/qb_site/analyzer/tests/tasks/test_assignment_proposal_delivery_task.py
new file mode 100644
index 00000000..0fe3d9d5
--- /dev/null
+++ b/qb_site/analyzer/tests/tasks/test_assignment_proposal_delivery_task.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+from datetime import timedelta
+from io import StringIO
+from unittest.mock import MagicMock, patch
+
+from django.core.management import call_command
+from django.test import TestCase, override_settings
+from django.utils import timezone
+
+from analyzer.models import AssignmentProposal
+from analyzer.tasks.assignment_proposal_delivery import deliver_assignment_proposals_task
+from core.models import Repository, User
+
+
+@override_settings(QUEUEBOARD_BASE_URL="https://queue.example.org")
+class DeliverAssignmentProposalsTaskTests(TestCase):
+ def setUp(self) -> None:
+ self.repo = Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master")
+ self.now = timezone.now()
+
+ def _seed(self) -> None:
+ User.objects.create(github_login="bob", zulip_user_id=4242)
+ AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=101,
+ reviewer_login="bob",
+ state=AssignmentProposal.STATE_PROPOSED,
+ expires_at=self.now + timedelta(days=7),
+ )
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=False, ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=False)
+ def test_skips_when_disabled(self) -> None:
+ res = deliver_assignment_proposals_task.apply().get()
+ self.assertTrue(res["skipped"])
+ self.assertEqual(res["reason"], "feature_disabled")
+
+ @override_settings(
+ ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=True,
+ ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED=False,
+ ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=False,
+ )
+ def test_master_on_but_delivery_off_is_disabled(self) -> None:
+ # Delivery requires BOTH the master and the delivery flag.
+ self._seed()
+ res = deliver_assignment_proposals_task.apply().get()
+ self.assertTrue(res["skipped"])
+ self.assertEqual(res["reason"], "feature_disabled")
+ self.assertTrue(AssignmentProposal.objects.filter(pr_number=101, notified_at__isnull=True).exists())
+
+ @override_settings(
+ ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=True,
+ ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED=True,
+ ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=False,
+ )
+ def test_enabled_sends_and_stamps(self) -> None:
+ self._seed()
+ client = MagicMock()
+ with patch("analyzer.tasks.assignment_proposal_delivery.ZulipClient", return_value=client):
+ res = deliver_assignment_proposals_task.apply().get()
+
+ self.assertFalse(res["skipped"])
+ self.assertTrue(res["enabled"])
+ self.assertEqual(res["totals"]["sent"], 1)
+ client.send_direct_message.assert_called_once()
+ self.assertTrue(AssignmentProposal.objects.filter(pr_number=101, notified_at__isnull=False).exists())
+
+ @override_settings(
+ ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=False,
+ ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED=False,
+ ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=True,
+ )
+ def test_dry_run_computes_without_client(self) -> None:
+ self._seed()
+ # No ZulipClient is constructed in dry-run; a mock guards against accidental construction.
+ with patch("analyzer.tasks.assignment_proposal_delivery.ZulipClient") as client_cls:
+ res = deliver_assignment_proposals_task.apply().get()
+
+ client_cls.assert_not_called()
+ self.assertFalse(res["skipped"])
+ self.assertTrue(res["dry_run"])
+ self.assertEqual(res["totals"]["would_send"], 1)
+ self.assertTrue(AssignmentProposal.objects.filter(pr_number=101, notified_at__isnull=True).exists())
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=True)
+ def test_repo_filter_miss_returns_not_found(self) -> None:
+ res = deliver_assignment_proposals_task.apply(kwargs={"repository_id": 999999}).get()
+ self.assertTrue(res["skipped"])
+ self.assertEqual(res["reason"], "repo_not_found_or_inactive")
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=False, ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=True)
+ def test_command_honors_dry_run_when_run_bare(self) -> None:
+ self._seed()
+ with patch("analyzer.tasks.assignment_proposal_delivery.ZulipClient") as client_cls:
+ call_command("deliver_assignment_proposals", stdout=StringIO())
+ client_cls.assert_not_called()
+ self.assertTrue(AssignmentProposal.objects.filter(pr_number=101, notified_at__isnull=True).exists())
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=False, ANALYZER_ASSIGNMENT_PROPOSALS_DELIVERY_ENABLED=False)
+ def test_command_enable_forces_send(self) -> None:
+ self._seed()
+ client = MagicMock()
+ with patch("analyzer.tasks.assignment_proposal_delivery.ZulipClient", return_value=client):
+ call_command("deliver_assignment_proposals", "--enable", stdout=StringIO())
+ client.send_direct_message.assert_called_once()
+ self.assertTrue(AssignmentProposal.objects.filter(pr_number=101, notified_at__isnull=False).exists())
diff --git a/qb_site/qb_site/settings/base.py b/qb_site/qb_site/settings/base.py
index a74cad09..2ad7739b 100644
--- a/qb_site/qb_site/settings/base.py
+++ b/qb_site/qb_site/settings/base.py
@@ -455,6 +455,21 @@ def env_optional_bounded_int(name: str, *, minimum: int, maximum: int) -> int |
# supersede those whose PR left the queue) and is intentionally NOT gated by the master switch,
# so flipping the gate off lets existing proposals drain. PERIOD_SECONDS <= 0 disables it.
ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRY_PERIOD_SECONDS = int(os.getenv("ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRY_PERIOD_SECONDS", 3600))
+# Delivery task schedule (daily; default 01:00 UTC, shortly after propose creates the day's
+# proposals at 00:45). PERIOD_SECONDS <= 0 disables scheduling. Actual sending is additionally
+# gated inside the task by ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED AND _DELIVERY_ENABLED (+ dry-run),
+# so scheduling it while off is a cheap no-op (feature_disabled).
+ANALYZER_ASSIGNMENT_DELIVER_PERIOD_SECONDS = int(os.getenv("ANALYZER_ASSIGNMENT_DELIVER_PERIOD_SECONDS", 86400))
+ANALYZER_ASSIGNMENT_DELIVER_UTC_HOUR = env_optional_bounded_int(
+ "ANALYZER_ASSIGNMENT_DELIVER_UTC_HOUR",
+ minimum=0,
+ maximum=23,
+)
+ANALYZER_ASSIGNMENT_DELIVER_UTC_MINUTE = env_optional_bounded_int(
+ "ANALYZER_ASSIGNMENT_DELIVER_UTC_MINUTE",
+ minimum=0,
+ maximum=59,
+)
ANALYZER_REVIEWER_ATTENTION_ENABLED = env_bool(os.getenv("ANALYZER_REVIEWER_ATTENTION_ENABLED"), False)
ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED = env_bool(
os.getenv("ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED"),
@@ -721,6 +736,18 @@ def env_optional_bounded_int(name: str, *, minimum: int, maximum: int) -> int |
"task": "analyzer.expire_assignment_proposals",
"schedule": ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRY_PERIOD_SECONDS,
}
+# Deliver the per-reviewer proposal digest DM (design doc 050), daily at a fixed UTC clock time
+# (default 01:00, just after the propose run). Beat fires unconditionally; the task no-ops unless
+# ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED AND _DELIVERY_ENABLED (+ dry-run). PERIOD_SECONDS <= 0
+# disables scheduling.
+if ANALYZER_ASSIGNMENT_DELIVER_PERIOD_SECONDS > 0:
+ CELERY_BEAT_SCHEDULE["deliver_assignment_proposals"] = {
+ "task": "analyzer.deliver_assignment_proposals",
+ "schedule": crontab(
+ hour=ANALYZER_ASSIGNMENT_DELIVER_UTC_HOUR if ANALYZER_ASSIGNMENT_DELIVER_UTC_HOUR is not None else 1,
+ minute=ANALYZER_ASSIGNMENT_DELIVER_UTC_MINUTE if ANALYZER_ASSIGNMENT_DELIVER_UTC_MINUTE is not None else 0,
+ ),
+ }
reviewer_attention_schedule = None
if (ANALYZER_REVIEWER_ATTENTION_UTC_HOUR is not None) or (ANALYZER_REVIEWER_ATTENTION_UTC_MINUTE is not None):
reviewer_attention_schedule = crontab(
From 923d544d1a48e19dac5af55d437c4d074021eb31 Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Thu, 9 Jul 2026 03:23:41 -0400
Subject: [PATCH 09/33] feat: surface proposed/assigned assignment state (Chunk
7)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Surface the acceptance-gate {unassigned, proposed, assigned} state on the
board/API, in single-PR queue info, and in the Zulip pr-info command —
always distinct from GitHub assignees (a proposal is not an assignee).
- queueboard snapshot: each PR entry gains a `proposal` field
({reviewer, expires_at} or null) from one batched state=proposed query;
the raw payload is served verbatim by the API.
- PRQueueInfo: proposed_to + proposal_expires_at, read live via a single
indexed point query in both the snapshot and DB paths.
- pr-info command: distinct "Proposed to X (awaiting acceptance, expires …)"
line; proposed login resolved through the silent-mention map.
AssignmentProposal ReadOnlyAdmin (Chunk 2) already provides the retained
history changelist. design doc 050 Chunk 7 marked landed.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
...050-reviewer-assignment-acceptance-gate.md | 40 +++++++++--
qb_site/analyzer/services/pr_info.py | 41 +++++++++++-
.../analyzer/services/queueboard_snapshot.py | 23 ++++++-
.../analyzer/tests/services/test_pr_info.py | 67 ++++++++++++++++++-
.../tests/test_queueboard_snapshot.py | 30 ++++++++-
qb_site/zulip_bot/commands/pr_info.py | 10 +++
.../tests/commands/test_pr_info_command.py | 21 ++++++
7 files changed, 223 insertions(+), 9 deletions(-)
diff --git a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
index a792350c..7ba223ab 100644
--- a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
+++ b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
@@ -1,6 +1,6 @@
# Reviewer Assignment Acceptance Gate (Propose → Accept → Assign)
-> Status: Living implementation plan (in progress — Chunks 1–6 landed; Chunk 7 (surfacing) next).
+> Status: Living implementation plan (in progress — Chunks 1–7 landed; Chunk 8 (docs) next).
> Captures decisions, invariants, and a chunked build plan; Progress Notes track what has shipped.
## Context
@@ -413,10 +413,20 @@ not new *flags* but new *infrastructure config*:
6. ✅ **(landed)** **Console** *(built before Chunk 5)*: dedicated `console/` app — GitHub-OAuth
reviewer session, list view, accept/decline handlers (accept reuses the 046 mutation path),
templates, live re-validation, tests.
-7. **Surfacing:** the {unassigned, proposed, assigned} state in the queue snapshot and in
- `pr_info`/`PRQueueInfo` (so the dashboard, API, and the Zulip `pr-info` command render it
- identically, distinct from GitHub assignees); the `AssignmentProposal` admin changelist
- for full (retained) history. No cleanup task in Phase 1.
+7. ✅ **(landed)** **Surfacing:** the {unassigned, proposed, assigned} state in the queue snapshot
+ and in `pr_info`/`PRQueueInfo` (so the dashboard, API, and the Zulip `pr-info` command render it
+ identically, distinct from GitHub assignees); the `AssignmentProposal` admin changelist for full
+ (retained) history. No cleanup task in Phase 1. **As built:** the snapshot builder embeds a
+ `proposal` field (`{reviewer, expires_at}` or `null`) on each PR entry (one batched
+ `state=proposed` query; the raw payload is served verbatim by the API, so the field flows to
+ board/API consumers for free). `PRQueueInfo` gains `proposed_to`/`proposal_expires_at`, read
+ **live** (single indexed point query via `an_ap_pr_state_idx`, fresher than the cached board for
+ this transactional state) in both the snapshot and DB paths. `pr-info` renders a distinct
+ "**Proposed to** X (awaiting acceptance, expires …)" line, never conflated with assignees. The
+ `AssignmentProposal` `ReadOnlyAdmin` (Chunk 2) already carries the full retained history
+ (state/decided_via/repository filters, date hierarchy, search) — no admin change needed. The
+ optional "(N prior proposals)" hint on default views is deferred (kept out to avoid a per-PR
+ history query in the hot snapshot build); the trail lives in admin.
8. **Docs:** update `qb_site/analyzer/AGENTS.md` (task surface), `qb_site/zulip_bot/AGENTS.md`
and `qb_site/core/*` notes as needed, and the root pointer; converge this living plan
toward a final record once shipped.
@@ -476,6 +486,26 @@ not new *flags* but new *infrastructure config*:
## Progress Notes
+- 2026-07-09: **Chunk 7 landed (Surfacing).** The {unassigned, proposed, assigned} assignment-axis
+ state now appears on all three surfaces, always distinct from GitHub assignees:
+ - **Board / API:** `QueueboardSnapshotBuilder` batches a single `state=proposed` query
+ (`_active_proposals_for_repo`) and embeds a `proposal` field (`{reviewer, expires_at}` or `null`)
+ on each PR entry via `_build_pr_entry`. The snapshot API serves the raw payload verbatim, so the
+ field reaches board/API consumers with no serializer change.
+ - **Single PR (`pr_info`):** `PRQueueInfo` gains `proposed_to` + `proposal_expires_at`, populated
+ by a new `_active_proposal` helper that reads the live proposal (single point query on
+ `an_ap_pr_state_idx`) in both the snapshot and DB builder paths — fresher than the cached board
+ for this transactional accept/decline state.
+ - **Zulip `pr-info`:** renders a distinct "**Proposed to** X (awaiting acceptance, expires
+ )" line (the proposed login is resolved through the same silent-mention map), never
+ merged into the Assignees line.
+ - **History:** the `AssignmentProposal` `ReadOnlyAdmin` from Chunk 2 already provides the full
+ retained-history changelist (state/decided_via/repository filters, `created_at` date hierarchy,
+ search) — no change needed. The optional "(N prior proposals)" hint on default views is
+ deferred (avoids a per-PR history query in the hot snapshot build); the trail lives in admin.
+ Validation: new tests (3 snapshot-path + 1 DB-path `pr_info`, 1 snapshot-builder entry, 2 `pr-info`
+ command rendering) + full `analyzer` and `zulip_bot` suites (791) green on Postgres; `api` suite
+ green; `makemigrations --check` clean (no model changes); ruff clean.
- 2026-07-09: **Chunk 5 landed (Notification).** Built the per-reviewer proposal digest DM. Pieces:
- **Service** `analyzer/services/assignment_proposal_delivery.py::deliver_assignment_proposals`:
queries `state=proposed, notified_at IS NULL` proposals across the given repos, groups by
diff --git a/qb_site/analyzer/services/pr_info.py b/qb_site/analyzer/services/pr_info.py
index 82b29d0c..098d65b3 100644
--- a/qb_site/analyzer/services/pr_info.py
+++ b/qb_site/analyzer/services/pr_info.py
@@ -4,7 +4,7 @@
from datetime import datetime, timezone
from typing import Any
-from analyzer.models import PRDependency, PRQueueWindow, QueueSnapshot
+from analyzer.models import AssignmentProposal, PRDependency, PRQueueWindow, QueueSnapshot
from analyzer.services.ci_evaluation import ci_status_for_pr
from analyzer.services.queue_rules import QueueRules, default_rule_set_for_repo, load_rules_for_repo
from core.models import Repository
@@ -42,6 +42,11 @@ class PRQueueInfo:
labels: list[str]
assignee_logins: list[str]
+ # Acceptance-gate state (design doc 050): the reviewer a pending proposal is awaiting acceptance
+ # from, and its expiry. A proposal is NOT an assignee; these are rendered distinct from
+ # ``assignee_logins`` and are None when there is no active proposal.
+ proposed_to: str | None
+ proposal_expires_at: datetime | None
ci_status: str
ci_requires_success: bool
@@ -76,6 +81,11 @@ def get_pr_queue_info(owner: str, repo_name: str, pr_number: int) -> PRQueueInfo
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").first()
+ # Read the acceptance-gate "proposed to X" state live (single indexed point query) rather than
+ # from the snapshot payload: it is transactional (accept/decline happen in real time on the
+ # console) and cheap for one PR, so a point lookup is fresher than the cached board build.
+ active_proposal = _active_proposal(repository, pr_number)
+
now = datetime.now(timezone.utc)
snapshot_generated_at: datetime | None = None
snapshot_is_stale = False
@@ -101,6 +111,7 @@ def get_pr_queue_info(owner: str, repo_name: str, pr_number: int) -> PRQueueInfo
snapshot_is_stale=snapshot_is_stale,
repository=repository,
has_ruleset=rule_set is not None,
+ active_proposal=active_proposal,
)
return _from_db(
@@ -111,7 +122,29 @@ def get_pr_queue_info(owner: str, repo_name: str, pr_number: int) -> PRQueueInfo
snapshot_generated_at=snapshot_generated_at,
snapshot_is_stale=snapshot_is_stale,
has_ruleset=rule_set is not None,
+ active_proposal=active_proposal,
+ )
+
+
+def _active_proposal(repository: Repository, pr_number: int) -> tuple[str, datetime | None] | None:
+ """Return ``(reviewer_login, expires_at)`` for the active proposal on this PR, or None.
+
+ At most one active (``proposed``) proposal exists per PR (DB partial-unique), so ``.first()`` is
+ the single live proposal. Served by the ``an_ap_pr_state_idx`` index on ``(repository, pr_number,
+ state)``.
+ """
+ row = (
+ AssignmentProposal.objects.filter(
+ repository=repository,
+ pr_number=pr_number,
+ state=AssignmentProposal.STATE_PROPOSED,
+ )
+ .values_list("reviewer_login", "expires_at")
+ .first()
)
+ if row is None:
+ return None
+ return row[0], _ensure_utc(row[1])
def _from_snapshot(
@@ -125,6 +158,7 @@ def _from_snapshot(
snapshot_is_stale: bool,
repository: Repository,
has_ruleset: bool,
+ active_proposal: tuple[str, datetime | None] | None,
) -> PRQueueInfo:
dashboards: dict[str, list[int]] = (snapshot_payload.get("lists") or {}).get("dashboards", {})
on_queue = pr_number in dashboards.get("Queue", [])
@@ -188,6 +222,8 @@ def _from_snapshot(
merged_at=None,
labels=labels,
assignee_logins=assignees,
+ proposed_to=active_proposal[0] if active_proposal else None,
+ proposal_expires_at=active_proposal[1] if active_proposal else None,
ci_status=ci_status,
ci_requires_success=_ci_missing_is_failure(rules),
on_queue=on_queue,
@@ -211,6 +247,7 @@ def _from_db(
snapshot_generated_at: datetime | None,
snapshot_is_stale: bool,
has_ruleset: bool,
+ active_proposal: tuple[str, datetime | None] | None,
) -> PRQueueInfo | None:
try:
pr = PullRequest.objects.select_related("author").get(repository=repository, number=pr_number)
@@ -312,6 +349,8 @@ def _from_db(
merged_at=_ensure_utc(pr.merged_at),
labels=labels,
assignee_logins=assignees,
+ proposed_to=active_proposal[0] if active_proposal else None,
+ proposal_expires_at=active_proposal[1] if active_proposal else None,
ci_status=ci_status,
ci_requires_success=_ci_missing_is_failure(rules),
on_queue=on_queue,
diff --git a/qb_site/analyzer/services/queueboard_snapshot.py b/qb_site/analyzer/services/queueboard_snapshot.py
index 43c72a97..1cb5b6c1 100644
--- a/qb_site/analyzer/services/queueboard_snapshot.py
+++ b/qb_site/analyzer/services/queueboard_snapshot.py
@@ -11,7 +11,7 @@
from django.db.models import F, Q, QuerySet, Window
from django.db.models.functions import RowNumber
-from analyzer.models import PRDependency, PRQueueWindow, QueueRuleSet, QueueSnapshot, PRRevision
+from analyzer.models import AssignmentProposal, PRDependency, PRQueueWindow, QueueRuleSet, QueueSnapshot, PRRevision
from analyzer.services.queue_rules import QueueRules, default_rule_set_for_repo, rules_for_rule_set
from core.models import Repository
from syncer.models import PRLabel, PullRequest
@@ -364,6 +364,7 @@ def build(self, repository: Repository, rule_set: QueueRuleSet | None = None) ->
label_map = self._labels_for_repo(repository)
dependency_map = self._dependencies_for_repo(repository)
+ proposal_map = self._active_proposals_for_repo(repository)
if need_ci_data:
head_sha_map = self._head_shas_for_repo(repository)
missing_head_pr_ids = {pr_id for pr_id, sha in head_sha_map.items() if not sha}
@@ -471,6 +472,7 @@ def build(self, repository: Repository, rule_set: QueueRuleSet | None = None) ->
pr_status=pr_status,
queue_fields=queue_fields,
queue_status=queue_status,
+ proposal=proposal_map.get(pr.number),
)
prs[pr.number] = entry
@@ -928,6 +930,21 @@ def _format_queue_window(
delta = relativedelta.relativedelta(generated_at, start_dt)
return f"since {start_dt:%Y-%m-%d %H:%M} ({format_delta(delta)})"
+ def _active_proposals_for_repo(self, repository: Repository) -> dict[int, dict]:
+ """Return {pr_number: {"reviewer": login, "expires_at": iso}} for active (proposed) proposals.
+
+ Surfaces the acceptance-gate "proposed to X" state on the board/API (design doc 050): a
+ confirm-mode PR has no GitHub assignee during the pending window, so this is the only place
+ the state is visible. Rendered distinct from ``assignees``; never a GitHub PR-page write.
+ """
+ rows = AssignmentProposal.objects.filter(
+ repository=repository,
+ state=AssignmentProposal.STATE_PROPOSED,
+ ).values_list("pr_number", "reviewer_login", "expires_at")
+ return {
+ int(pr_number): {"reviewer": login, "expires_at": _isoformat(expires_at)} for pr_number, login, expires_at in rows
+ }
+
def _build_pr_entry(
self,
pr: PullRequest,
@@ -939,6 +956,7 @@ def _build_pr_entry(
pr_status: str,
queue_fields: dict,
queue_status: DataStatus,
+ proposal: dict | None = None,
) -> dict:
comments_status = _data_status(bool(pr.comments_incomplete), pr.last_synced_at)
assignees_status = _data_status(bool(pr.assignees_incomplete), pr.last_synced_at)
@@ -964,6 +982,9 @@ def _build_pr_entry(
"number_modified_files": pr.changed_files_count,
"approvals": pr.approvals or [],
"assignees": pr.assignees or [],
+ # Acceptance-gate state (design doc 050): {"reviewer", "expires_at"} while a proposal is
+ # pending, else null. A proposal is NOT an assignee — surfaced separately, never merged.
+ "proposal": proposal,
"users_commented": users_commented,
"number_total_comments": pr.number_total_comments,
"direct_dependencies": list(dependencies),
diff --git a/qb_site/analyzer/tests/services/test_pr_info.py b/qb_site/analyzer/tests/services/test_pr_info.py
index e52a667e..836b9691 100644
--- a/qb_site/analyzer/tests/services/test_pr_info.py
+++ b/qb_site/analyzer/tests/services/test_pr_info.py
@@ -4,7 +4,7 @@
from django.test import TestCase
-from analyzer.models import PRQueueWindow, QueueRuleSet
+from analyzer.models import AssignmentProposal, PRQueueWindow, QueueRuleSet
from analyzer.models.queue_snapshot import QueueSnapshot
from analyzer.services.pr_info import (
get_pr_queue_info,
@@ -318,6 +318,52 @@ def test_case_insensitive_repo_lookup(self) -> None:
self.assertIsNotNone(info)
+ def test_surfaces_active_proposal_distinct_from_assignees(self) -> None:
+ # design doc 050 Chunk 7: a proposed-but-not-accepted reviewer has no GitHub assignee, so
+ # proposed_to is surfaced separately from assignee_logins.
+ entry = _pr_entry(123, assignees=[])
+ _mk_snapshot(self.repo, self.now, {"123": entry}, {"Queue": [123]}, cache_key=self.cache_key)
+ AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=123,
+ reviewer_login="bob",
+ state=AssignmentProposal.STATE_PROPOSED,
+ expires_at=self.now + timedelta(days=7),
+ )
+
+ info = get_pr_queue_info("leanprover-community", "mathlib4", 123)
+
+ assert info is not None
+ self.assertEqual(info.proposed_to, "bob")
+ self.assertEqual(info.proposal_expires_at, self.now + timedelta(days=7))
+ self.assertEqual(info.assignee_logins, [])
+
+ def test_no_active_proposal_is_none(self) -> None:
+ entry = _pr_entry(124)
+ _mk_snapshot(self.repo, self.now, {"124": entry}, {"Queue": [124]}, cache_key=self.cache_key)
+
+ info = get_pr_queue_info("leanprover-community", "mathlib4", 124)
+
+ assert info is not None
+ self.assertIsNone(info.proposed_to)
+ self.assertIsNone(info.proposal_expires_at)
+
+ def test_terminal_proposal_is_not_surfaced(self) -> None:
+ entry = _pr_entry(125)
+ _mk_snapshot(self.repo, self.now, {"125": entry}, {"Queue": [125]}, cache_key=self.cache_key)
+ AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=125,
+ reviewer_login="bob",
+ state=AssignmentProposal.STATE_ACCEPTED,
+ expires_at=self.now - timedelta(days=1),
+ )
+
+ info = get_pr_queue_info("leanprover-community", "mathlib4", 125)
+
+ assert info is not None
+ self.assertIsNone(info.proposed_to)
+
class GetPrQueueInfoDbTests(TestCase):
def setUp(self) -> None:
@@ -357,6 +403,25 @@ def test_merged_pr_from_db(self) -> None:
self.assertFalse(info.on_queue)
self.assertEqual(info.off_queue_reasons, []) # closed PRs don't get reasons
+ def test_db_path_surfaces_active_proposal(self) -> None:
+ # No snapshot exists in this test class, so get_pr_queue_info takes the db path; the live
+ # proposal query still populates proposed_to.
+ _mk_pr(self.repo, 55, state="open")
+ AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=55,
+ reviewer_login="carol",
+ state=AssignmentProposal.STATE_PROPOSED,
+ expires_at=self.now + timedelta(days=5),
+ )
+
+ info = get_pr_queue_info("leanprover-community", "mathlib4", 55)
+
+ assert info is not None
+ self.assertEqual(info.source, "db")
+ self.assertEqual(info.proposed_to, "carol")
+ self.assertEqual(info.proposal_expires_at, self.now + timedelta(days=5))
+
def test_pr_with_active_queue_window(self) -> None:
pr = _mk_pr(self.repo, 55, assignees=["bob"])
window_start = self.now - timedelta(days=5)
diff --git a/qb_site/analyzer/tests/test_queueboard_snapshot.py b/qb_site/analyzer/tests/test_queueboard_snapshot.py
index de9d79ec..681963e2 100644
--- a/qb_site/analyzer/tests/test_queueboard_snapshot.py
+++ b/qb_site/analyzer/tests/test_queueboard_snapshot.py
@@ -5,7 +5,7 @@
from django.test import TestCase
-from analyzer.models import PRDependency, PRQueueWindow, QueueRuleSet, PRRevision
+from analyzer.models import AssignmentProposal, PRDependency, PRQueueWindow, QueueRuleSet, PRRevision
from analyzer.models.queue_snapshot import QueueSnapshot
from analyzer.services.queueboard_snapshot import QueueboardSnapshotBuilder
from core.models import Repository, User
@@ -130,6 +130,34 @@ def test_builds_snapshot_with_queue_filters(self):
self.assertEqual(snapshot["lists"]["dashboards"]["Queue"], [1])
self.assertEqual(snapshot["lists"]["dashboards"]["NeedsDecision"], [2])
+ def test_pr_entry_carries_active_proposal(self):
+ # design doc 050 Chunk 7: a pending proposal is surfaced on the board via the entry's
+ # `proposal` field, distinct from `assignees` (which stays empty until acceptance).
+ pr1 = self._make_pr(1)
+ self._make_pr(2)
+ AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=pr1.number,
+ reviewer_login="bob",
+ state=AssignmentProposal.STATE_PROPOSED,
+ expires_at=self.now + timedelta(days=7),
+ )
+ # A terminal proposal on PR 2 must not surface.
+ AssignmentProposal.objects.create(
+ repository=self.repo,
+ pr_number=2,
+ reviewer_login="carol",
+ state=AssignmentProposal.STATE_DECLINED,
+ expires_at=self.now - timedelta(days=1),
+ )
+
+ snapshot = QueueboardSnapshotBuilder(chunk_size=1).build(self.repo)
+ prs = snapshot["prs"]
+
+ self.assertEqual(prs[1]["proposal"], {"reviewer": "bob", "expires_at": (self.now + timedelta(days=7)).isoformat()})
+ self.assertEqual(prs[1]["assignees"], [])
+ self.assertIsNone(prs[2]["proposal"])
+
def test_queue_membership_respects_rule_set_ci_requirement(self):
pr = self._make_pr(60)
rule_set_ci = QueueRuleSet.objects.create(
diff --git a/qb_site/zulip_bot/commands/pr_info.py b/qb_site/zulip_bot/commands/pr_info.py
index 68b268a3..bdb4a9b9 100644
--- a/qb_site/zulip_bot/commands/pr_info.py
+++ b/qb_site/zulip_bot/commands/pr_info.py
@@ -7,6 +7,7 @@
from analyzer.services.pr_info import DependencyInfo, PRQueueInfo, get_pr_queue_info
from analyzer.services.reviewer_attention_format import format_compact_duration, format_since_timestamp
from core.models import User
+from core.utils.zulip_time import format_global_time
from zulip_bot.commands import CommandContext, CommandResult, register_command
from zulip_bot.services.zulip_client import ZulipApiError, ZulipClient
@@ -55,6 +56,8 @@ def pr_info_command(context: CommandContext, args: str) -> CommandResult:
if info.author_login:
all_logins.add(info.author_login)
all_logins.update(info.assignee_logins)
+ if info.proposed_to:
+ all_logins.add(info.proposed_to)
mention_map = _build_mention_map(all_logins)
# Send one message per PR.
@@ -207,6 +210,13 @@ def _format_pr_info(info: PRQueueInfo, mention_map: dict[str, str], now: datetim
assignees_str = _mentions(info.assignee_logins, mention_map)
lines.append(f"CI: {ci_display} · Assignees: {assignees_str}")
+ # Acceptance-gate proposal (design doc 050): shown distinct from assignees — the reviewer has
+ # been *proposed* but has not accepted, so there is no GitHub assignee yet.
+ if info.proposed_to:
+ proposed_mention = _mention(info.proposed_to, mention_map)
+ expiry = f", expires {format_global_time(info.proposal_expires_at)}" if info.proposal_expires_at else ""
+ lines.append(f"**Proposed to** {proposed_mention} (awaiting acceptance{expiry})")
+
# Labels.
if info.labels:
lines.append("Labels: " + " ".join(f"`{lbl}`" for lbl in info.labels))
diff --git a/qb_site/zulip_bot/tests/commands/test_pr_info_command.py b/qb_site/zulip_bot/tests/commands/test_pr_info_command.py
index 95141acf..210562d3 100644
--- a/qb_site/zulip_bot/tests/commands/test_pr_info_command.py
+++ b/qb_site/zulip_bot/tests/commands/test_pr_info_command.py
@@ -56,6 +56,8 @@ def _make_pr_info(
author_login: str | None = "alice",
labels: list[str] | None = None,
assignee_logins: list[str] | None = None,
+ proposed_to: str | None = None,
+ proposal_expires_at: datetime | None = None,
ci_status: str = "pass",
ci_requires_success: bool = False,
off_queue_reasons: list[str] | None = None,
@@ -80,6 +82,8 @@ def _make_pr_info(
merged_at=None,
labels=labels or ["awaiting-review"],
assignee_logins=assignee_logins or [],
+ proposed_to=proposed_to,
+ proposal_expires_at=proposal_expires_at,
ci_status=ci_status,
ci_requires_success=ci_requires_success,
on_queue=on_queue,
@@ -260,6 +264,23 @@ def test_has_ruleset_no_warning(self) -> None:
text = _format_pr_info(info, {}, self._now())
self.assertNotIn("No queue ruleset is configured", text)
+ def test_proposal_line_rendered_distinct_from_assignees(self) -> None:
+ info = _make_pr_info(
+ assignee_logins=[],
+ proposed_to="bob",
+ proposal_expires_at=_dt(2026, 3, 8, 12),
+ )
+ text = _format_pr_info(info, {"bob": "@_**Bob|7**"}, self._now())
+ self.assertIn("**Proposed to** @_**Bob|7** (awaiting acceptance", text)
+ self.assertIn("expires ", text)
+ # A proposal is not an assignee: the assignees line stays empty.
+ self.assertIn("Assignees: —", text)
+
+ def test_no_proposal_line_when_absent(self) -> None:
+ info = _make_pr_info(proposed_to=None)
+ text = _format_pr_info(info, {}, self._now())
+ self.assertNotIn("Proposed to", text)
+
def test_dependency_link_included(self) -> None:
dep = DependencyInfo(
owner="leanprover-community",
From 5185a861f637062c53bd7e82dc041a8613784fe8 Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Thu, 9 Jul 2026 03:25:17 -0400
Subject: [PATCH 10/33] docs: converge design doc 050 + AGENTS notes (Chunk 8)
All build chunks (1-8) for the reviewer-assignment acceptance gate are
landed; the feature is pending staged rollout (flags default off) and a
staging end-to-end validation.
- root AGENTS.md: list qb_site/console/ in the AGENTS-locations pointer
- analyzer AGENTS.md: snapshot `proposal` field + pr_info proposed_to notes
- zulip_bot AGENTS.md: pr-info proposal-line note
- design doc 050: mark Chunks 7-8 landed, converge status, add an operator
rollout checklist
Co-Authored-By: Claude Opus 4.8 (1M context)
---
AGENTS.md | 2 +-
...050-reviewer-assignment-acceptance-gate.md | 34 ++++++++++++++++---
qb_site/analyzer/AGENTS.md | 6 ++--
qb_site/zulip_bot/AGENTS.md | 2 +-
4 files changed, 35 insertions(+), 9 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 805ba7ee..c0698570 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,7 +68,7 @@ Notes
## Keeping AGENTS.md Files Updated
- Every directory with significant logic has its own `AGENTS.md` (mirrored as `CLAUDE.md`).
Current locations: root, `qb_site/`, `qb_site/syncer/`, `qb_site/analyzer/`,
- `qb_site/zulip_bot/`, `src/queueboard/`.
+ `qb_site/zulip_bot/`, `qb_site/console/`, `src/queueboard/`.
- When you add, rename, or remove management commands, Celery tasks, key services, or
directory structure, update the relevant `AGENTS.md` in the same commit/PR.
- When you add a new app or significant sub-directory, create a matching `AGENTS.md`
diff --git a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
index 7ba223ab..db0c74b6 100644
--- a/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
+++ b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md
@@ -1,7 +1,8 @@
# Reviewer Assignment Acceptance Gate (Propose → Accept → Assign)
-> Status: Living implementation plan (in progress — Chunks 1–7 landed; Chunk 8 (docs) next).
-> Captures decisions, invariants, and a chunked build plan; Progress Notes track what has shipped.
+> Status: All build chunks (1–8) landed; **pending staged rollout** (feature flags default off) and
+> a staging end-to-end validation. Captures decisions, invariants, and the chunked build plan;
+> Progress Notes track what has shipped. Converge to a final record after production rollout.
## Context
@@ -427,9 +428,13 @@ not new *flags* but new *infrastructure config*:
(state/decided_via/repository filters, date hierarchy, search) — no admin change needed. The
optional "(N prior proposals)" hint on default views is deferred (kept out to avoid a per-PR
history query in the hot snapshot build); the trail lives in admin.
-8. **Docs:** update `qb_site/analyzer/AGENTS.md` (task surface), `qb_site/zulip_bot/AGENTS.md`
- and `qb_site/core/*` notes as needed, and the root pointer; converge this living plan
- toward a final record once shipped.
+8. ✅ **(landed)** **Docs:** `qb_site/analyzer/AGENTS.md` task surface (propose/expire/deliver) +
+ `queueboard_snapshot`/`pr_info` proposal notes; `qb_site/zulip_bot/AGENTS.md` `pr-info` proposal
+ note; the root `AGENTS.md` AGENTS-locations pointer now lists `qb_site/console/`; `console/AGENTS.md`
+ added in Chunk 6. Core helpers (`site_urls`, `oauth_state`, `github_identity`) are documented via
+ `console/AGENTS.md` (no separate `core/AGENTS.md` exists). This plan is converged to
+ "all chunks landed; pending rollout" — finalize after production rollout + the staging
+ end-to-end check.
## Validation Plan
@@ -486,6 +491,25 @@ not new *flags* but new *infrastructure config*:
## Progress Notes
+- 2026-07-09: **Chunk 8 landed (Docs) — all build chunks complete.** Updated `analyzer/AGENTS.md`
+ (task surface already carried propose/expire/deliver; added the `queueboard_snapshot` `proposal`
+ field and `pr_info` `proposed_to` notes), `zulip_bot/AGENTS.md` (`pr-info` proposal line), and the
+ root `AGENTS.md` AGENTS-locations pointer (added `qb_site/console/`, created in Chunk 6). No
+ `core/AGENTS.md` exists; the new core helpers are documented via `console/AGENTS.md`. This plan is
+ now converged to "all chunks landed; pending rollout". **Operator rollout checklist** (staged, per
+ the 028 discipline — each flag independent, all default off):
+ 1. Live-env prereqs: set `QUEUEBOARD_BASE_URL`; ensure the GitHub OAuth App callback permits
+ `/console/oauth/callback/` (see `docs/zulip_github_oauth_setup.md`).
+ 2. Set reviewer modes: the `ReviewerPreference.assignment_acceptance` bulk admin action flips a
+ selection to `confirm` (all existing rows were backfilled to `auto`; new rows default `confirm`).
+ 3. `ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=1` first and inspect the propose/deliver task output.
+ 4. Enable in sequence: `_ENABLED` (propose creates proposals + direct-assigns auto reviewers) →
+ `_DELIVERY_ENABLED` (digest DMs) → `_ASSIGN_ON_ACCEPT_ENABLED` (console accept performs the
+ GitHub assign). Disable the legacy `ANALYZER_REVIEWER_ASSIGNMENT_APPLY_ENABLED` — propose
+ supersedes apply; run one, not both. The expiry sweep runs regardless (essential maintenance).
+ 5. Staging end-to-end: flip one reviewer to `confirm`, receive a digest DM, sign into the console,
+ accept, confirm the assignee lands on GitHub + a `ReviewerAssignmentApplication` records it, and
+ a second propose run is a no-op. (Still a manual step — no real GitHub/Zulip calls in tests.)
- 2026-07-09: **Chunk 7 landed (Surfacing).** The {unassigned, proposed, assigned} assignment-axis
state now appears on all three surfaces, always distinct from GitHub assignees:
- **Board / API:** `QueueboardSnapshotBuilder` batches a single `state=proposed` query
diff --git a/qb_site/analyzer/AGENTS.md b/qb_site/analyzer/AGENTS.md
index 4d577745..85824075 100644
--- a/qb_site/analyzer/AGENTS.md
+++ b/qb_site/analyzer/AGENTS.md
@@ -8,13 +8,15 @@
- snapshots (queueboard/reviewer assignment/area stats/convergence).
- Keep derived logic in `services/` and orchestration/sweeps in `tasks/`.
- Key read-only services:
- - `queueboard_snapshot.py` — builds and caches the full per-repo queue snapshot payload.
+ - `queueboard_snapshot.py` — builds and caches the full per-repo queue snapshot payload. Each PR
+ entry carries a `proposal` field (`{reviewer, expires_at}` or `null`) for the acceptance-gate
+ "proposed to X" state (design doc 050), surfaced distinct from `assignees`.
- `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.
+ - `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. Also exposes the acceptance-gate `proposed_to`/`proposal_expires_at` (design doc 050), read live from the single active `AssignmentProposal`, distinct from `assignee_logins`.
- `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.
## High-Value Commands
diff --git a/qb_site/zulip_bot/AGENTS.md b/qb_site/zulip_bot/AGENTS.md
index 17aabf35..216b71c2 100644
--- a/qb_site/zulip_bot/AGENTS.md
+++ b/qb_site/zulip_bot/AGENTS.md
@@ -18,7 +18,7 @@ cd qb_site/zulip_bot/frontend && npm test
## Command Architecture Notes
- Commands live in `commands/`: `assign`, `unassign`, `assigned-prs`, `pr-info`, `prefs`, `help`, `echo`, `register_test`, `close-pr`, `label-pr`.
-- `pr-info`: parses GitHub PR links from Zulip `rendered_content`, reacts with 👀, then sends one message per PR (up to 10) with queue info sourced from `analyzer.services.pr_info`. Replies in the same conversation as the triggering message.
+- `pr-info`: parses GitHub PR links from Zulip `rendered_content`, reacts with 👀, then sends one message per PR (up to 10) with queue info sourced from `analyzer.services.pr_info`. Replies in the same conversation as the triggering message. Renders the acceptance-gate "Proposed to X (awaiting acceptance, expires …)" state (design doc 050) on its own line, distinct from Assignees.
- Assignment command flow (all under `services/`) is split for clarity:
- parse: `assignment_command_parser.py`,
- validate: `assignment_validation.py`,
From 0d6024661110819d3fdc416f84108fe2fd65753c Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Thu, 9 Jul 2026 03:56:57 -0400
Subject: [PATCH 11/33] fix: never report console accept as assigned unless it
landed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The console accept handler treated `assign_reviewer_and_record`'s
`already_recorded` outcome as success and marked the proposal `accepted`
("you are now assigned"). But `already_recorded` only means a row for
(run_date, repo, pr, reviewer) already exists — which is a success *only*
when that row is APPLIED. A prior same-day FAILED attempt (e.g. an earlier
click GitHub rejected) also returns `already_recorded`, so a retry — which
the 502 message explicitly invites — would falsely tell the reviewer they
were assigned with no GitHub assignee.
`assign_reviewer_and_record` now returns the existing record on
`already_recorded` (was `None`) so the caller can inspect its status; the
console marks accepted only when the assignment actually landed (`applied`,
or `already_recorded` with an APPLIED row) and otherwise leaves the proposal
pending with a "try again" page. The batch apply/propose callers ignore the
third return value, so their behavior is unchanged.
Adds console tests for the failed, already-recorded-FAILED, and
already-recorded-APPLIED accept paths.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../services/reviewer_assignment_apply.py | 14 +++--
qb_site/console/tests/test_views.py | 58 ++++++++++++++++++-
qb_site/console/views.py | 24 ++++++--
qb_site/templates/console/unavailable.html | 2 +-
4 files changed, 85 insertions(+), 13 deletions(-)
diff --git a/qb_site/analyzer/services/reviewer_assignment_apply.py b/qb_site/analyzer/services/reviewer_assignment_apply.py
index 06e11a00..f8e2a665 100644
--- a/qb_site/analyzer/services/reviewer_assignment_apply.py
+++ b/qb_site/analyzer/services/reviewer_assignment_apply.py
@@ -105,11 +105,13 @@ def assign_reviewer_and_record(
Idempotently creates the PENDING ``ReviewerAssignmentApplication`` for
``(run_date, repo, pr, reviewer)``; if a row already exists returns
- ``("already_recorded", assignment_client, None)`` without mutating. Otherwise POSTs the
- assignee via ``GitHubAssignmentClient`` (built from ``token`` when not supplied), confirms the
- login actually landed (GitHub silently drops unassignable logins), marks the row
- APPLIED/FAILED, and enqueues ``syncer.sync_pr`` on success. Returns ``(outcome, client,
- record)`` with ``outcome`` in {"applied", "failed", "already_recorded"}.
+ ``("already_recorded", assignment_client, )`` without mutating. The caller can
+ inspect ``record.status`` to tell an already-APPLIED row from a prior FAILED/PENDING one —
+ ``already_recorded`` does *not* imply the assignment ever landed. Otherwise POSTs the assignee
+ via ``GitHubAssignmentClient`` (built from ``token`` when not supplied), confirms the login
+ actually landed (GitHub silently drops unassignable logins), marks the row APPLIED/FAILED, and
+ enqueues ``syncer.sync_pr`` on success. Returns ``(outcome, client, record)`` with ``outcome``
+ in {"applied", "failed", "already_recorded"}.
Shared verbatim by the legacy apply sweep (doc 046), the acceptance-gate propose step's
auto/fallback direct-assign path, and the console accept handler (doc 050) so the GitHub
@@ -128,7 +130,7 @@ def assign_reviewer_and_record(
},
)
if not created:
- return ("already_recorded", assignment_client, None)
+ return ("already_recorded", assignment_client, record)
if assignment_client is None:
assignment_client = GitHubAssignmentClient(token=token)
diff --git a/qb_site/console/tests/test_views.py b/qb_site/console/tests/test_views.py
index 3158edff..5ca8e673 100644
--- a/qb_site/console/tests/test_views.py
+++ b/qb_site/console/tests/test_views.py
@@ -7,7 +7,7 @@
from django.urls import reverse
from django.utils import timezone
-from analyzer.models import AssignmentProposal, ReviewerOptOut
+from analyzer.models import AssignmentProposal, ReviewerAssignmentApplication, ReviewerOptOut
from console.session import SESSION_NONCE_KEY, SESSION_USER_KEY
from core.models import Repository, ReviewerPreference, User
from core.services.github_oauth import GitHubOAuthError, GitHubUserIdentity
@@ -162,6 +162,62 @@ def test_accept_assigns_and_marks_accepted(self) -> None:
self.assertEqual(proposal.decided_via, AssignmentProposal.DECIDED_VIA_CONSOLE)
self.assertIsNotNone(proposal.decided_at)
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True)
+ def test_accept_failed_outcome_leaves_proposal_pending(self) -> None:
+ self._make_pr(101)
+ proposal = self._proposal(101)
+ self._login_session()
+
+ with (
+ patch("core.services.github_operation_tokens.resolve_github_app_operation_token", return_value="tok"),
+ patch("console.views.assign_reviewer_and_record", return_value=("failed", None, None)) as mock_assign,
+ ):
+ resp = self.client.post(reverse("console:accept", args=[proposal.id]))
+
+ self.assertEqual(resp.status_code, 502)
+ mock_assign.assert_called_once()
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_PROPOSED)
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True)
+ def test_accept_already_recorded_failed_row_is_not_marked_accepted(self) -> None:
+ # Regression (design doc 050 review): a prior same-day FAILED application returns
+ # already_recorded, but the assignment never landed — the reviewer must NOT be told they are
+ # assigned, and the proposal must stay pending for a later retry.
+ self._make_pr(101)
+ proposal = self._proposal(101)
+ self._login_session()
+ failed_record = ReviewerAssignmentApplication(status=ReviewerAssignmentApplication.STATUS_FAILED)
+
+ with (
+ patch("core.services.github_operation_tokens.resolve_github_app_operation_token", return_value="tok"),
+ patch("console.views.assign_reviewer_and_record", return_value=("already_recorded", None, failed_record)),
+ ):
+ resp = self.client.post(reverse("console:accept", args=[proposal.id]))
+
+ self.assertEqual(resp.status_code, 502)
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_PROPOSED)
+
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True)
+ def test_accept_already_recorded_applied_row_marks_accepted(self) -> None:
+ # Benign double-accept: the row already exists and is APPLIED, so acceptance is idempotent.
+ self._make_pr(101)
+ proposal = self._proposal(101)
+ self._login_session()
+ applied_record = ReviewerAssignmentApplication(status=ReviewerAssignmentApplication.STATUS_APPLIED)
+
+ with (
+ patch("core.services.github_operation_tokens.resolve_github_app_operation_token", return_value="tok"),
+ patch("console.views.assign_reviewer_and_record", return_value=("already_recorded", None, applied_record)),
+ ):
+ resp = self.client.post(reverse("console:accept", args=[proposal.id]))
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "Assignment accepted")
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_ACCEPTED)
+
@override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True)
def test_accept_stale_closed_pr_renders_unavailable_and_retires(self) -> None:
self._make_pr(101, state=PullRequestState.CLOSED)
diff --git a/qb_site/console/views.py b/qb_site/console/views.py
index ae24a556..69d4fa12 100644
--- a/qb_site/console/views.py
+++ b/qb_site/console/views.py
@@ -21,7 +21,7 @@
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_GET, require_POST
-from analyzer.models import AssignmentProposal, QueueSnapshot, ReviewerOptOut
+from analyzer.models import AssignmentProposal, QueueSnapshot, ReviewerAssignmentApplication, ReviewerOptOut
from analyzer.services.assignment_proposal_validity import ProposalValidity, proposal_validity, resolve_on_queue_exit_policy
from analyzer.services.queue_rules import default_rule_set_for_repo
from analyzer.services.reviewer_assignment_apply import assign_reviewer_and_record
@@ -302,7 +302,7 @@ def accept(request: HttpRequest, proposal_id: int) -> HttpResponse:
if not token:
return render(request, "console/unavailable.html", {"message": "Assignment is temporarily unavailable. Try again later."})
- outcome, _client, _record = assign_reviewer_and_record(
+ outcome, _client, record = assign_reviewer_and_record(
repository=proposal.repository,
pr_number=int(proposal.pr_number),
login=proposal.reviewer_login,
@@ -310,12 +310,26 @@ def accept(request: HttpRequest, proposal_id: int) -> HttpResponse:
run_date=now.date(),
token=token,
)
- if outcome == "failed":
+ # Only mark accepted when the assignment actually landed on GitHub. ``already_recorded`` means a
+ # row for (today, repo, pr, reviewer) already existed — a success ONLY when that row is APPLIED.
+ # A prior FAILED/PENDING attempt (e.g. an earlier click GitHub rejected) must never be reported
+ # to the reviewer as an assignment that took; leave the proposal pending so a later daily run
+ # (fresh run_date) can retry. See design doc 050 review.
+ landed = outcome == "applied" or (
+ outcome == "already_recorded" and record is not None and record.status == ReviewerAssignmentApplication.STATUS_APPLIED
+ )
+ if not landed:
return render(
- request, "console/unavailable.html", {"message": "GitHub rejected the assignment. Try again shortly."}, status=502
+ request,
+ "console/unavailable.html",
+ {
+ "heading": "Couldn’t assign you just now",
+ "message": "GitHub didn’t confirm the assignment. Please try again in a little while.",
+ },
+ status=502,
)
- # Assignment landed (applied) or was already recorded — mark accepted.
+ # Assignment landed — mark accepted.
AssignmentProposal.objects.filter(id=proposal.id, state=AssignmentProposal.STATE_PROPOSED).update(
state=AssignmentProposal.STATE_ACCEPTED,
decided_at=now,
diff --git a/qb_site/templates/console/unavailable.html b/qb_site/templates/console/unavailable.html
index 9b697e9e..36672f74 100644
--- a/qb_site/templates/console/unavailable.html
+++ b/qb_site/templates/console/unavailable.html
@@ -1,7 +1,7 @@
{% extends "console/base.html" %}
{% block title %}No longer available — Reviewer console{% endblock %}
{% block body %}
- No longer available
+ {{ heading|default:"No longer available" }}
{{ message }}
Back to the console
{% endblock %}
From 317bc8fa10aa9116f4be479965d9814a2c0126e7 Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Thu, 9 Jul 2026 03:57:48 -0400
Subject: [PATCH 12/33] fix: retire proposal on accept when reviewer has opted
out
When a reviewer accepted a proposal for a PR they already had an active
ReviewerOptOut on (e.g. a GitHub self-unassign reconciled into an opt-out),
the handler rendered "you have opted out" but retired the proposal with a
no-op verdict (terminal_state=None), leaving it `proposed`. Because
proposal_validity does not consult opt-outs, the expiry sweep wouldn't clear
it either, so it lingered as "proposed" on the console and board until it
timed out.
Retire it to `superseded` (decided_via=sync_superseded) instead, and reword
the message. Adds a regression test.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
qb_site/console/tests/test_views.py | 22 ++++++++++++++++++++++
qb_site/console/views.py | 22 +++++++++++++++++++---
2 files changed, 41 insertions(+), 3 deletions(-)
diff --git a/qb_site/console/tests/test_views.py b/qb_site/console/tests/test_views.py
index 5ca8e673..51d45489 100644
--- a/qb_site/console/tests/test_views.py
+++ b/qb_site/console/tests/test_views.py
@@ -244,6 +244,28 @@ def test_accept_proposal_for_other_reviewer_forbidden(self) -> None:
proposal.refresh_from_db()
self.assertEqual(proposal.state, AssignmentProposal.STATE_PROPOSED)
+ @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True)
+ def test_accept_with_active_opt_out_supersedes_proposal(self) -> None:
+ # Regression (design doc 050 review): an active opt-out on the PR blocks acceptance, and the
+ # dangling proposal is retired to superseded rather than left pending (proposal_validity does
+ # not consult opt-outs, so a bare no-op would leave it showing "proposed" until it timed out).
+ self._make_pr(101)
+ proposal = self._proposal(101)
+ ReviewerOptOut.objects.create(
+ repository=self.repo, pr_number=101, reviewer_login="bob", active=True, opted_out_at=self.now
+ )
+ self._login_session()
+
+ with patch("console.views.assign_reviewer_and_record") as mock_assign:
+ resp = self.client.post(reverse("console:accept", args=[proposal.id]))
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "opted out")
+ mock_assign.assert_not_called()
+ proposal.refresh_from_db()
+ self.assertEqual(proposal.state, AssignmentProposal.STATE_SUPERSEDED)
+ self.assertEqual(proposal.decided_via, AssignmentProposal.DECIDED_VIA_SYNC_SUPERSEDED)
+
def test_accept_requires_post(self) -> None:
self._make_pr(101)
proposal = self._proposal(101)
diff --git a/qb_site/console/views.py b/qb_site/console/views.py
index 69d4fa12..392f7d75 100644
--- a/qb_site/console/views.py
+++ b/qb_site/console/views.py
@@ -273,12 +273,28 @@ def accept(request: HttpRequest, proposal_id: int) -> HttpResponse:
if proposal.state != AssignmentProposal.STATE_PROPOSED:
return render(request, "console/unavailable.html", {"message": _UNAVAILABLE_REASON["already_terminal"]})
- # An explicit prior decline (opt-out) blocks re-acceptance.
+ # An active opt-out (explicit decline, or a GitHub self-unassign reconciled into one) blocks
+ # re-acceptance. Retire the dangling proposal to ``superseded`` rather than leaving it pending:
+ # a bare no-op verdict (terminal_state=None) would keep it showing as "proposed" on the console
+ # and board until it timed out, since proposal_validity does not consult opt-outs.
if ReviewerOptOut.objects.filter(
repository=proposal.repository, pr_number=proposal.pr_number, reviewer_login__iexact=reviewer.github_login, active=True
).exists():
- _retire(proposal, ProposalValidity(is_live=False, reason="already_terminal"), now=now)
- return render(request, "console/unavailable.html", {"message": "You have opted out of this PR."})
+ _retire(
+ proposal,
+ ProposalValidity(
+ is_live=False,
+ reason="opted_out",
+ terminal_state=AssignmentProposal.STATE_SUPERSEDED,
+ decided_via=AssignmentProposal.DECIDED_VIA_SYNC_SUPERSEDED,
+ ),
+ now=now,
+ )
+ return render(
+ request,
+ "console/unavailable.html",
+ {"message": "You’ve opted out of this PR, so this proposal no longer applies."},
+ )
validity, _live_pr = _live_validity(proposal, now=now)
if not validity.is_live:
From a1b26fda83fa17f90856a0e68104a32dfe288a01 Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Thu, 9 Jul 2026 03:59:33 -0400
Subject: [PATCH 13/33] fix: gate reviewer console to already-known users
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`/console/` is a public URL and its OAuth callback called
resolve_or_create_user_from_identity, minting a core.User for any GitHub
account that authorized the app — an unbounded row-creation surface for
unrelated strangers.
Add a `create` flag to resolve_or_create_user_from_identity (default True,
returns User | None) and have the console call it with create=False: an
identity we have never seen (not registered via the Zulip flow, not ingested
by the syncer) now gets a 403 "registered reviewers only" page and no row,
instead of a session. Known reviewers are unaffected.
Adds console (unknown-login denied) and core-helper (resolve-only) tests;
documents the create=False gating in console/AGENTS.md.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
qb_site/console/AGENTS.md | 4 ++-
qb_site/console/tests/test_views.py | 25 +++++++++++++++++++
qb_site/console/views.py | 18 ++++++++++++-
qb_site/core/services/github_identity.py | 14 +++++++++--
.../core/tests/test_console_oauth_helpers.py | 11 ++++++++
5 files changed, 68 insertions(+), 4 deletions(-)
diff --git a/qb_site/console/AGENTS.md b/qb_site/console/AGENTS.md
index c90b36bf..c50bb8e6 100644
--- a/qb_site/console/AGENTS.md
+++ b/qb_site/console/AGENTS.md
@@ -15,7 +15,9 @@
- CSRF: a random nonce is stored in the session and echoed in the Fernet-signed `state`
(`core.services.oauth_state.issue_console_oauth_state`); the callback requires them to match.
- Identity → user: `core.services.github_identity.resolve_or_create_user_from_identity`
- (Zulip-agnostic; never touches `zulip_user_id`).
+ (Zulip-agnostic; never touches `zulip_user_id`). The console calls it with **`create=False`**
+ so an unknown GitHub account gets a 403 instead of minting a `core.User`; only people already
+ known (registered via the Zulip flow, or ingested by the syncer) can open a session.
- Console access is keyed on the authenticated `github_login`, matched **case-insensitively**
against `AssignmentProposal.reviewer_login`. A reviewer can only act on their own proposals.
diff --git a/qb_site/console/tests/test_views.py b/qb_site/console/tests/test_views.py
index 51d45489..1e3647a8 100644
--- a/qb_site/console/tests/test_views.py
+++ b/qb_site/console/tests/test_views.py
@@ -106,6 +106,31 @@ def test_oauth_callback_happy_path_opens_session(self) -> None:
self.assertEqual(resp["Location"], "/console/")
self.assertEqual(self.client.session.get(SESSION_USER_KEY), self.reviewer.id)
+ def test_oauth_callback_unknown_login_denied(self) -> None:
+ # A GitHub account we have never seen must not get a session or mint a core.User row
+ # (design doc 050 review): the console is for already-registered reviewers only.
+ session = self.client.session
+ session[SESSION_NONCE_KEY] = "nonce-123"
+ session.save()
+ state = issue_console_oauth_state(claims=ConsoleOAuthStateClaims(nonce="nonce-123", next="/console/"))
+
+ fake = MagicMock()
+ fake.exchange_code_for_access_token.return_value = "gho_token"
+ fake.fetch_user_identity.return_value = GitHubUserIdentity(
+ github_user_id=999,
+ github_node_id="node-stranger",
+ github_login="stranger",
+ github_name="Stranger",
+ github_avatar_url=None,
+ )
+ with patch("console.views.GitHubOAuthClient", return_value=fake):
+ resp = self.client.get(reverse("console:oauth-callback"), {"code": "c", "state": state})
+
+ self.assertEqual(resp.status_code, 403)
+ self.assertContains(resp, "registered reviewers", status_code=403)
+ self.assertIsNone(self.client.session.get(SESSION_USER_KEY))
+ self.assertFalse(User.objects.filter(github_login__iexact="stranger").exists())
+
def test_oauth_callback_nonce_mismatch_rejected(self) -> None:
session = self.client.session
session[SESSION_NONCE_KEY] = "session-nonce"
diff --git a/qb_site/console/views.py b/qb_site/console/views.py
index 392f7d75..f8204594 100644
--- a/qb_site/console/views.py
+++ b/qb_site/console/views.py
@@ -99,7 +99,23 @@ def oauth_callback(request: HttpRequest) -> HttpResponse:
log.warning("console.oauth_callback: GitHub OAuth exchange failed", exc_info=True)
return render(request, "console/error.html", {"message": "GitHub sign-in failed. Try again."}, status=502)
- user = resolve_or_create_user_from_identity(identity)
+ # Resolve-only: the console is for people we already know (registered via the Zulip flow, or
+ # ingested by the syncer). A GitHub account we have never seen is not given a session, so the
+ # public sign-in URL cannot mint a core.User row for an arbitrary authenticated stranger.
+ user = resolve_or_create_user_from_identity(identity, create=False)
+ if user is None:
+ log.info("console.oauth_callback: unknown GitHub login %r denied", identity.github_login)
+ return render(
+ request,
+ "console/error.html",
+ {
+ "message": (
+ "This console is only for registered reviewers. If you review for a tracked "
+ "repository, register with the Zulip bot first, then sign in here."
+ )
+ },
+ status=403,
+ )
console_session.set_reviewer(request, user)
return redirect(_safe_next(request, claims.next))
diff --git a/qb_site/core/services/github_identity.py b/qb_site/core/services/github_identity.py
index 2a61f738..695660e7 100644
--- a/qb_site/core/services/github_identity.py
+++ b/qb_site/core/services/github_identity.py
@@ -3,6 +3,8 @@
Used by the reviewer console (design doc 050): a reviewer authenticates with GitHub and we need
the matching ``core.User`` to key their proposals on. Unlike the registration linker this touches
no Zulip fields — it only resolves/creates the person and refreshes their GitHub identity fields.
+The console calls it with ``create=False`` so it gates on already-known people rather than minting
+a row for any GitHub account that signs in.
"""
from __future__ import annotations
@@ -14,8 +16,14 @@
@transaction.atomic
-def resolve_or_create_user_from_identity(identity: GitHubUserIdentity) -> User:
- """Return the ``core.User`` for ``identity`` (by node id, else login), creating one if needed.
+def resolve_or_create_user_from_identity(identity: GitHubUserIdentity, *, create: bool = True) -> User | None:
+ """Return the ``core.User`` for ``identity`` (by node id, else login).
+
+ With ``create=True`` (the default) a matching user is created when none exists. With
+ ``create=False`` the lookup is resolve-only: it returns ``None`` for an identity we have never
+ seen, so a caller like the reviewer console can gate access to already-known people (registered
+ via the Zulip flow, or ingested by the syncer) instead of minting a row for any GitHub account
+ that completes OAuth.
Race-safe against the syncer ingesting the same GitHub user concurrently (savepoint + re-fetch,
per the "Concurrent Writers and Unique Keys" rules). Refreshes mutable identity fields
@@ -26,6 +34,8 @@ def resolve_or_create_user_from_identity(identity: GitHubUserIdentity) -> User:
user = User.objects.select_for_update().filter(github_login__iexact=identity.github_login).first()
if user is None:
+ if not create:
+ return None
try:
with transaction.atomic():
return User.objects.create(
diff --git a/qb_site/core/tests/test_console_oauth_helpers.py b/qb_site/core/tests/test_console_oauth_helpers.py
index c862724e..4a739db8 100644
--- a/qb_site/core/tests/test_console_oauth_helpers.py
+++ b/qb_site/core/tests/test_console_oauth_helpers.py
@@ -89,3 +89,14 @@ def test_matches_by_login_case_insensitively(self) -> None:
self.assertEqual(user.id, existing.id)
# node id backfilled on the previously node-less row
self.assertEqual(user.github_node_id, "MDQ6VXNlcjE=")
+
+ def test_resolve_only_returns_none_when_absent(self) -> None:
+ # create=False must not mint a row for an unknown identity (console gating, doc 050 review).
+ user = resolve_or_create_user_from_identity(self._identity(login="stranger"), create=False)
+ self.assertIsNone(user)
+ self.assertFalse(User.objects.filter(github_login__iexact="stranger").exists())
+
+ def test_resolve_only_returns_existing(self) -> None:
+ existing = User.objects.create(github_node_id="MDQ6VXNlcjE=", github_login="alice")
+ user = resolve_or_create_user_from_identity(self._identity(login="alice"), create=False)
+ self.assertEqual(user.id, existing.id)
From 8549007b1a8a9e901d13d729b3cbba538817fa32 Mon Sep 17 00:00:00 2001
From: Bryan Gin-ge Chen
Date: Thu, 9 Jul 2026 04:02:00 -0400
Subject: [PATCH 14/33] feat: group console proposals by repo; batch queries;
local times
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Console home improvements (design doc 050 review):
- Group pending proposals under a per-repo heading, each with its own load
line (assigned + pending / capacity), instead of a flat list plus an
unlabelled load summary — a reviewer active in multiple repos can now tell
which repo each line belongs to. Groups are ordered by (owner, name).
- Fix an N+1: batch the proposed PRs (one query per repo) and their labels
(one query) rather than a PullRequest + PRLabel query per proposal.
- Render the expiry in the viewer's own timezone: the server emits a UTC
ISO-8601 timestamp in a element and a small inline script
reformats it client-side via Intl.DateTimeFormat, keeping a "… UTC" text
fallback when JS is off (was a hardcoded "UTC" label only correct while
TIME_ZONE=UTC).
Updates the home-listing test and adds a multi-repo grouping test.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
qb_site/console/tests/test_views.py | 53 ++++++++++++++++-
qb_site/console/views.py | 91 ++++++++++++++++++++---------
qb_site/templates/console/base.html | 25 +++++++-
qb_site/templates/console/home.html | 58 +++++++++---------
4 files changed, 168 insertions(+), 59 deletions(-)
diff --git a/qb_site/console/tests/test_views.py b/qb_site/console/tests/test_views.py
index 1e3647a8..02326c93 100644
--- a/qb_site/console/tests/test_views.py
+++ b/qb_site/console/tests/test_views.py
@@ -149,10 +149,61 @@ def test_home_lists_pending_proposals(self) -> None:
resp = self.client.get(reverse("console:home"))
self.assertEqual(resp.status_code, 200)
- self.assertContains(resp, "mathlib4 #101")
+ self.assertContains(resp, "leanprover-community/mathlib4") # per-repo heading
+ self.assertContains(resp, "#101")
+ self.assertContains(resp, "0 assigned + 1 pending / capacity 5") # per-repo load line
self.assertContains(resp, "Accept")
self.assertContains(resp, "Decline")
+ def test_home_groups_proposals_by_repo(self) -> None:
+ # Proposals across repos render under separate per-repo headings, each with its own load
+ # line, ordered by (owner, name) — so "batteries" sorts before "mathlib4" (doc 050 review).
+ repo2 = Repository.objects.create(owner="leanprover-community", name="batteries", default_branch="main")
+ ReviewerPreference.objects.create(
+ repository=repo2,
+ user=self.reviewer,
+ preferred_labels=[],
+ maximum_capacity=3,
+ assignment_acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM,
+ )
+ self._make_pr(101)
+ self._proposal(101)
+ PullRequest.objects.create(
+ repository=repo2,
+ number=5,
+ state=PullRequestState.OPEN,
+ is_draft=False,
+ gh_created_at=self.now,
+ gh_updated_at=self.now,
+ base_ref_name="main",
+ head_ref_name="branch-5",
+ head_repo_owner_login="leanprover-community",
+ head_repo_name="batteries",
+ title="Array lemmas",
+ body="b",
+ additions=1,
+ deletions=0,
+ changed_files_count=1,
+ assignees=[],
+ )
+ AssignmentProposal.objects.create(
+ repository=repo2,
+ pr_number=5,
+ reviewer_login="bob",
+ state=AssignmentProposal.STATE_PROPOSED,
+ expires_at=self.now + timedelta(days=7),
+ )
+ self._login_session()
+
+ resp = self.client.get(reverse("console:home"))
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "leanprover-community/mathlib4")
+ self.assertContains(resp, "leanprover-community/batteries")
+ self.assertContains(resp, "capacity 5")
+ self.assertContains(resp, "capacity 3")
+ content = resp.content.decode()
+ self.assertLess(content.index("batteries"), content.index("mathlib4"))
+
# ---- accept --------------------------------------------------------
@override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=False)
diff --git a/qb_site/console/views.py b/qb_site/console/views.py
index f8204594..aa2e42fc 100644
--- a/qb_site/console/views.py
+++ b/qb_site/console/views.py
@@ -11,6 +11,7 @@
import logging
import secrets
+from typing import Iterable
from django.conf import settings
from django.db import transaction
@@ -154,57 +155,93 @@ def _build_home_context(reviewer) -> dict:
.order_by("repository__owner", "repository__name", "expires_at", "pr_number")
)
- # Preferred labels per repo, to render "why you" (matched topic labels).
+ # Preferred labels + capacity per repo, to render "why you" and the per-repo load line.
preferred_by_repo: dict[int, set[str]] = {}
capacity_by_repo: dict[int, int] = {}
for pref in ReviewerPreference.objects.filter(user=reviewer).only("repository_id", "preferred_labels", "maximum_capacity"):
preferred_by_repo[pref.repository_id] = {str(lbl).lower() for lbl in (pref.preferred_labels or [])}
capacity_by_repo[pref.repository_id] = int(pref.maximum_capacity)
- # Batch PR metadata + labels for the proposed PRs, grouped by repo.
- proposal_rows: list[dict] = []
+ # Batch PR metadata + labels once (was a query per proposal).
+ prs_by_repo_number = _batch_prs(proposals)
+ labels_by_pr_id = _batch_labels(prs_by_repo_number.values())
+
+ # Group proposals by repo, preserving the (owner, name, expires_at, pr_number) ordering above.
+ groups: dict[int, dict] = {}
for proposal in proposals:
repo = proposal.repository
- pr = PullRequest.objects.filter(repository=repo, number=proposal.pr_number).only("id", "number", "title", "state").first()
- labels = (
- list(PRLabel.objects.filter(pull_request=pr).select_related("label_def").values_list("label_def__name", flat=True))
- if pr is not None
- else []
- )
+ group = groups.get(repo.id)
+ if group is None:
+ group = {
+ "repo_id": repo.id,
+ "owner": repo.owner,
+ "repo": repo.name,
+ "repo_label": f"{repo.owner}/{repo.name}",
+ "proposals": [],
+ }
+ groups[repo.id] = group
+ pr = prs_by_repo_number.get((repo.id, int(proposal.pr_number)))
+ labels = labels_by_pr_id.get(pr.id, []) if pr is not None else []
preferred = preferred_by_repo.get(repo.id, set())
matched = sorted({lbl for lbl in labels if lbl and lbl.lower() in preferred})
- proposal_rows.append(
+ group["proposals"].append(
{
"proposal": proposal,
- "owner": repo.owner,
- "repo": repo.name,
"pr_number": proposal.pr_number,
"title": (pr.title if pr is not None else None) or f"PR #{proposal.pr_number}",
"url": f"https://github.com/{repo.owner}/{repo.name}/pull/{proposal.pr_number}",
"matched_labels": matched,
"expires_at": proposal.expires_at,
+ # ISO-8601 (UTC) for client-side rendering in the viewer's own timezone; the template
+ # keeps a UTC text fallback for no-JS.
+ "expires_at_iso": proposal.expires_at.isoformat() if proposal.expires_at else "",
}
)
- # Load summary per repo: pending proposals + open PRs assigned + capacity.
- pending_by_repo: dict[int, int] = {}
- for proposal in proposals:
- pending_by_repo[proposal.repository_id] = pending_by_repo.get(proposal.repository_id, 0) + 1
- load_rows: list[dict] = []
- for repo_id, capacity in sorted(capacity_by_repo.items()):
+ # Attach a per-repo load line (assigned-open + pending / capacity) to each group.
+ repo_groups: list[dict] = []
+ for repo_id in sorted(groups, key=lambda rid: (groups[rid]["owner"], groups[rid]["repo"])):
+ group = groups[repo_id]
assigned_open = PullRequest.objects.filter(
repository_id=repo_id, state="open", assignees__contains=[reviewer.github_login]
).count()
- load_rows.append(
- {
- "repo_id": repo_id,
- "assigned_open": assigned_open,
- "pending": pending_by_repo.get(repo_id, 0),
- "capacity": capacity,
- }
- )
+ group["load"] = {
+ "assigned_open": assigned_open,
+ "pending": len(group["proposals"]),
+ "capacity": capacity_by_repo.get(repo_id),
+ }
+ repo_groups.append(group)
+
+ return {"repo_groups": repo_groups, "logout_url": reverse("console:logout")}
- return {"proposal_rows": proposal_rows, "load_rows": load_rows, "logout_url": reverse("console:logout")}
+
+def _batch_prs(proposals: list[AssignmentProposal]) -> dict[tuple[int, int], PullRequest]:
+ """Fetch the proposed PRs keyed by ``(repository_id, number)`` in one query per repo."""
+ numbers_by_repo: dict[int, set[int]] = {}
+ for proposal in proposals:
+ numbers_by_repo.setdefault(int(proposal.repository_id), set()).add(int(proposal.pr_number))
+ out: dict[tuple[int, int], PullRequest] = {}
+ for repo_id, numbers in numbers_by_repo.items():
+ for pr in PullRequest.objects.filter(repository_id=repo_id, number__in=numbers).only("id", "number", "title", "state"):
+ out[(repo_id, int(pr.number))] = pr
+ return out
+
+
+def _batch_labels(prs: Iterable[PullRequest]) -> dict[int, list[str]]:
+ """Map ``pull_request_id -> [label name, ...]`` for the given PRs in a single query."""
+ pr_ids = [pr.id for pr in prs]
+ if not pr_ids:
+ return {}
+ out: dict[int, list[str]] = {}
+ rows = (
+ PRLabel.objects.filter(pull_request_id__in=pr_ids)
+ .select_related("label_def")
+ .values_list("pull_request_id", "label_def__name")
+ )
+ for pr_id, name in rows:
+ if name:
+ out.setdefault(int(pr_id), []).append(name)
+ return out
# --- accept / decline --------------------------------------------------------
diff --git a/qb_site/templates/console/base.html b/qb_site/templates/console/base.html
index e28d1ed4..59292d65 100644
--- a/qb_site/templates/console/base.html
+++ b/qb_site/templates/console/base.html
@@ -10,8 +10,10 @@
max-width: 760px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
h1 { font-size: 1.5rem; margin-bottom: .25rem; }
.meta { color: #6b7280; font-size: .9rem; margin-top: 0; }
- .card { border: 1px solid #d1d5db; border-radius: 10px; padding: 1rem; margin: 1rem 0; }
- .card h2 { font-size: 1.05rem; margin: 0 0 .25rem; }
+ .repo { margin-top: 1.75rem; }
+ .repo-title { font-size: 1.15rem; margin: 0 0 .1rem; padding-bottom: .3rem; border-bottom: 2px solid #d1d5db; }
+ .card { border: 1px solid #d1d5db; border-radius: 10px; padding: 1rem; margin: .75rem 0; }
+ .card h3 { font-size: 1.02rem; font-weight: 600; margin: 0 0 .25rem; }
.labels { margin: .35rem 0; }
.label { display: inline-block; font-size: .78rem; background: #eef2ff; color: #3730a3;
border-radius: 999px; padding: .1rem .55rem; margin: .1rem .2rem .1rem 0; }
@@ -24,10 +26,11 @@
.btn { display: inline-block; background: #111827; color: #fff; padding: .55rem 1rem;
border-radius: 8px; text-decoration: none; }
.empty { color: #6b7280; }
- .loadline { font-size: .9rem; color: #374151; }
+ .loadline { font-size: .9rem; color: #374151; margin: .1rem 0 .5rem; }
form.inline { display: inline; }
@media (prefers-color-scheme: dark) {
body { background: #0b0f19; color: #e5e7eb; }
+ .repo-title { border-bottom-color: #374151; }
.card { border-color: #374151; }
.label { background: #1e1b4b; color: #c7d2fe; }
.btn { background: #e5e7eb; color: #0b0f19; }
@@ -37,5 +40,21 @@
{% block body %}{% endblock %}
+