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 }}. +

{% csrf_token %}
+

+ +

Proposals awaiting your acceptance

+ {% if proposal_rows %} + {% for row in proposal_rows %} +
+

{{ row.owner }}/{{ row.repo }} #{{ row.pr_number }}

+
{{ 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
+
+
+ {% csrf_token %} +
+
+ {% csrf_token %} +
+
+
+ {% 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